diff --git a/.env.example b/.env.example index e355df4..73357ac 100644 --- a/.env.example +++ b/.env.example @@ -12,16 +12,15 @@ ENVIRONMENT=development # Format: postgresql://user:password@host:port/database?sslmode=disable DATABASE_URL=postgresql://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable -# Gateway (OpenClaw) connection — WebSocket (primary) -# WebSocket URL for real-time OpenClaw gateway v3 protocol -GATEWAY_WS_URL=ws://localhost:18789/ -# Auth token for the OpenClaw gateway (operator scope) -OPENCLAW_GATEWAY_TOKEN= +# Gateway (OpenClaw) connection +# WebSocket gateway config (primary path) +WS_GATEWAY_URL=ws://host.docker.internal:18789/ +# Gateway auth token — same as OPENCLAW_GATEWAY_TOKEN (set in environment) +GATEWAY_TOKEN= -# Gateway (OpenClaw) connection — REST (fallback) -# URL to the OpenClaw gateway API for polling agent states (used only if WS fails) -GATEWAY_URL=http://localhost:18789/api/agents -# Polling interval for agent state updates +# REST poller config (fallback, only used if WS fails to connect) +GATEWAY_URL=http://host.docker.internal:18789/api/agents +# Polling interval for agent state updates (fallback only) GATEWAY_POLL_INTERVAL=5s # ── Frontend Variables (via Vite) ─────────────────────────────────────── @@ -33,7 +32,7 @@ GATEWAY_POLL_INTERVAL=5s # When using docker-compose, these are set in the services section # See docker-compose.yml for service-specific environment variables -# ── Database Configuration ───────────────────────────────────────────── +# ── Database Configuration ─────────────────────────────────────────────── # Set in the db service environment section of docker-compose.yml # POSTGRES_USER=controlcenter # POSTGRES_PASSWORD=controlcenter @@ -48,4 +47,4 @@ GATEWAY_POLL_INTERVAL=5s # For Docker deployment: # 1. Copy .env.example to .env (backend only) # 2. Run: docker compose up -d -# 3. Access frontend at http://localhost:3000 +# 3. Access frontend at http://localhost:3000 \ No newline at end of file diff --git a/ci-image/Dockerfile b/ci-image/Dockerfile new file mode 100644 index 0000000..8433101 --- /dev/null +++ b/ci-image/Dockerfile @@ -0,0 +1,11 @@ +FROM catthehacker/ubuntu:act-latest + +# Install Go 1.23 +RUN curl -sL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz + +# Install Node 22 +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH="/usr/local/go/bin:${PATH}" diff --git a/docker-compose.yml b/docker-compose.yml index 2e3c5bb..4591b81 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,8 @@ services: - ENVIRONMENT=production - PORT=8080 - GATEWAY_URL=http://host.docker.internal:18789/api/agents + - WS_GATEWAY_URL=ws://host.docker.internal:18789/ + - GATEWAY_TOKEN=${GATEWAY_TOKEN:-} depends_on: db: condition: service_healthy diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ffb57fd..4bc415f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,8 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.2.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -27,13 +29,73 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^29.1.1", "postcss": "^8.5.14", "tailwindcss": "^4.2.4", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.7" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -213,6 +275,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -258,6 +330,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -408,6 +633,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -789,6 +1032,13 @@ "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", @@ -1070,6 +1320,82 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1080,6 +1406,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1381,6 +1733,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1418,6 +1883,51 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1490,6 +2000,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -1567,6 +2087,16 @@ } ] }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1610,12 +2140,47 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1633,6 +2198,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1647,6 +2219,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1656,6 +2238,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1688,6 +2278,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1704,6 +2307,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1913,6 +2523,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1922,6 +2542,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2209,6 +2839,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2227,6 +2870,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2248,6 +2901,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2269,6 +2929,57 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2614,6 +3325,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2631,6 +3353,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2650,6 +3379,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2701,6 +3440,17 @@ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2748,6 +3498,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2766,6 +3529,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2827,6 +3597,22 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -2863,6 +3649,14 @@ "react": "^19.2.6" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.15.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", @@ -2899,6 +3693,30 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", @@ -2938,6 +3756,19 @@ "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", "dev": true }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2978,6 +3809,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2987,6 +3825,40 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", @@ -3006,6 +3878,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -3022,6 +3911,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3089,6 +4034,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -3211,6 +4166,144 @@ } } }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3226,6 +4319,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3235,6 +4345,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index fe0db58..541327b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@tanstack/react-query": "^5.100.9", @@ -20,6 +22,8 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.2.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -29,10 +33,12 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^29.1.1", "postcss": "^8.5.14", "tailwindcss": "^4.2.4", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.7" } } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 34ad59c..76d61c1 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' import { NavLink } from 'react-router-dom' -import { Command, Activity, FolderKanban, Monitor, Settings, Menu, X } from 'lucide-react' +import { Command, Activity, FolderKanban, Monitor, Settings, Menu, X, Wifi, WifiOff, Loader } from 'lucide-react' +import { useSSEContext } from '../contexts/SSEContext' +import type { SSEStatus } from '../hooks/useSSE' const navItems = [ { to: '/', icon: Command, label: 'Hub' }, @@ -10,9 +12,29 @@ const navItems = [ { to: '/settings', icon: Settings, label: 'Settings' }, ] +/** Small status pill shown in the sidebar footer and mobile header. */ +function SSEStatusBadge({ status, showLabel = false }: { status: SSEStatus; showLabel?: boolean }) { + const cfg = { + connected: { icon: Wifi, color: 'text-green-500', label: 'Live' }, + connecting: { icon: Loader, color: 'text-yellow-500 animate-spin', label: 'Connecting' }, + reconnecting: { icon: Loader, color: 'text-yellow-500 animate-spin', label: 'Reconnecting' }, + error: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' }, + }[status] + + const Icon = cfg.icon + + return ( +
+ + {showLabel && {cfg.label}} +
+ ) +} + export default function Layout({ children }: { children: React.ReactNode }) { const [expanded, setExpanded] = useState(false) const [mobileOpen, setMobileOpen] = useState(false) + const { sseStatus } = useSSEContext() return (
@@ -46,6 +68,15 @@ export default function Layout({ children }: { children: React.ReactNode }) { ))} + {/* SSE connection status — footer of sidebar */} +
+ + {expanded && ( + + {sseStatus === 'connected' ? 'Live updates on' : sseStatus} + + )} +
{/* Mobile Header + Bottom Nav */} @@ -54,6 +85,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
Control Center +
- ))} +
+
+

SSE Connection

+

{sseInfo.description}

+ + {sseInfo.label} +
+

+ Endpoint: /api/events +  · Events: agent.status, agent.task, agent.progress, fleet.update +

+

+ Polling is disabled. All status updates are pushed from the server over a persistent SSE connection. + The client reconnects automatically with exponential back-off on drop. +

diff --git a/frontend/src/services/sse.ts b/frontend/src/services/sse.ts new file mode 100644 index 0000000..6243e9f --- /dev/null +++ b/frontend/src/services/sse.ts @@ -0,0 +1,72 @@ +/** + * SSE event payload types matching the Go backend (internal/handler/sse.go). + * + * Event format on the wire: + * event: + * data: + * + * The types below define the backend contract. The SSEPayloadMap maps + * each event type string to its expected payload shape. SSEMessage is a + * discriminated union on `type` — when you switch on msg.type, TypeScript + * narrows msg.data to the correct payload interface automatically. + */ + +import type { AgentStatus } from '../types' + +/** agent.status — agent came online, went offline, changed state */ +export interface AgentStatusEvent { + agentId: string + status: AgentStatus + /** Optional human-readable reason (e.g. error message) */ + reason?: string +} + +/** agent.task — a task was assigned to or completed by an agent */ +export interface AgentTaskEvent { + agentId: string + taskId: string + title: string + action: 'assigned' | 'completed' | 'failed' +} + +/** agent.progress — incremental progress update for a running task */ +export interface AgentProgressEvent { + agentId: string + taskId: string + progress: number + /** Optional description of what is currently happening */ + message?: string +} + +/** + * fleet.update — bulk refresh of all agents (e.g. after a deployment). + * The backend may send partial or complete agent state. + */ +export interface FleetUpdateEvent { + /** ISO timestamp of when the snapshot was taken */ + timestamp: string + /** Number of agents in the fleet */ + agentCount: number +} + +/** Union of all SSE data payloads keyed by event type. */ +export type SSEPayloadMap = { + 'agent.status': AgentStatusEvent + 'agent.task': AgentTaskEvent + 'agent.progress': AgentProgressEvent + 'fleet.update': FleetUpdateEvent + connected: { clientCount: number } + message: unknown +} + +/** + * Discriminated SSE message — the `type` field narrows `data` via SSEPayloadMap. + * + * Usage: + * if (msg.type === 'agent.status') { + * msg.data.agentId // ✅ TypeScript knows this is AgentStatusEvent + * } + */ +export type SSEMessage = { + [K in keyof SSEPayloadMap]: { type: K; data: SSEPayloadMap[K] } +}[keyof SSEPayloadMap] diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/frontend/src/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 64c6f46..e04426d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,4 +1,4 @@ -export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error' +export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error' | 'offline' export interface Agent { id: string diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 7f42e5f..dccb228 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -4,7 +4,7 @@ "target": "es2023", "lib": ["ES2023", "DOM"], "module": "esnext", - "types": ["vite/client"], + "types": ["vite/client", "vitest/globals"], "skipLibCheck": true, /* Bundler mode */ diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index d3c52ea..5a66f0e 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -20,5 +20,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts"] } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..45cc983 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test-setup.ts'], + }, +}) diff --git a/go-backend/cmd/server/main.go b/go-backend/cmd/server/main.go index 6cadc7c..6426f5f 100644 --- a/go-backend/cmd/server/main.go +++ b/go-backend/cmd/server/main.go @@ -63,29 +63,30 @@ func main() { Broker: broker, }) - // ── Gateway: WS primary + REST fallback ──────────────────────────────── - // WebSocket client (primary — real-time events via OpenClaw v3 protocol) + // ── Gateway clients (WS primary, REST fallback) ─────────────────── + // WS gateway client (primary path) wsClient := gateway.NewWSClient(gateway.WSConfig{ URL: cfg.WSGatewayURL, AuthToken: cfg.WSGatewayToken, }, agentRepo, broker, logger) - // REST polling client (fallback — only used if WS connection fails) - restClient := gateway.NewClient(gateway.Config{ - URL: cfg.GatewayURL, - PollInterval: cfg.GatewayPollInterval, + // REST gateway client (fallback — only polls if WS fails to connect) + gwClient := gateway.NewClient(gateway.Config{ + URL: cfg.GatewayRestURL, + PollInterval: cfg.GatewayRestPollInterval, }, agentRepo, broker) - // Wire them: WS notifies REST to stand down on successful connect - wsClient.SetRESTClient(restClient) + // Wire them together: REST defers to WS when WS is connected + wsClient.SetRESTClient(gwClient) + gwClient.SetWSClient(wsClient) ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Start WS client first (primary) go wsClient.Start(ctx) - // Start REST client (fallback polling) - go restClient.Start(ctx) + // Start REST client (will wait for WS, then stand down or fall back) + go gwClient.Start(ctx) // ── Server ───────────────────────────────────────────────────────────── srv := &http.Server{ diff --git a/go-backend/internal/config/config.go b/go-backend/internal/config/config.go index 32715b6..2832b06 100644 --- a/go-backend/internal/config/config.go +++ b/go-backend/internal/config/config.go @@ -10,30 +10,30 @@ import ( // Config holds all application configuration. type Config struct { - Port int - DatabaseURL string - CORSOrigin string - LogLevel string - Environment string - GatewayURL string // REST fallback URL - GatewayPollInterval time.Duration // REST fallback poll interval - WSGatewayURL string // WebSocket gateway URL - WSGatewayToken string // WebSocket auth token + Port int + DatabaseURL string + CORSOrigin string + LogLevel string + Environment string + GatewayRestURL string + GatewayRestPollInterval time.Duration + WSGatewayURL string + WSGatewayToken string } // Load reads configuration from environment variables, applying defaults where // values are not set. All secrets come from the environment — nothing is hardcoded. func Load() *Config { return &Config{ - Port: getEnvInt("PORT", 8080), - DatabaseURL: getEnv("DATABASE_URL", "postgres://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable"), - CORSOrigin: getEnv("CORS_ORIGIN", "*"), - LogLevel: getEnv("LOG_LEVEL", "info"), - Environment: getEnv("ENVIRONMENT", "development"), - GatewayURL: getEnv("GATEWAY_URL", "http://host.docker.internal:18789/api/agents"), - GatewayPollInterval: getEnvDuration("GATEWAY_POLL_INTERVAL", 5*time.Second), - WSGatewayURL: getEnv("GATEWAY_WS_URL", "ws://host.docker.internal:18789/"), - WSGatewayToken: getEnv("OPENCLAW_GATEWAY_TOKEN", ""), + Port: getEnvInt("PORT", 8080), + DatabaseURL: getEnv("DATABASE_URL", "postgres://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable"), + CORSOrigin: getEnv("CORS_ORIGIN", "*"), + LogLevel: getEnv("LOG_LEVEL", "info"), + Environment: getEnv("ENVIRONMENT", "development"), + GatewayRestURL: getEnv("GATEWAY_URL", "http://host.docker.internal:18789/api/agents"), + GatewayRestPollInterval: getEnvDuration("GATEWAY_POLL_INTERVAL", 5*time.Second), + WSGatewayURL: getEnv("WS_GATEWAY_URL", "ws://host.docker.internal:18789/"), + WSGatewayToken: getEnv("OPENCLAW_GATEWAY_TOKEN", ""), } } diff --git a/go-backend/internal/gateway/client.go b/go-backend/internal/gateway/client.go index 610e2cc..5a8db94 100644 --- a/go-backend/internal/gateway/client.go +++ b/go-backend/internal/gateway/client.go @@ -13,6 +13,7 @@ import ( "fmt" "log/slog" "net/http" + "sync" "time" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler" @@ -22,15 +23,17 @@ import ( // Client polls the OpenClaw gateway for agent status and keeps the database // and SSE broker in sync. When a WSClient is set, the REST poller becomes a -// fallback that only activates if the WS connection fails. +// fallback: it waits for the WS client to signal readiness, and only starts +// polling if WS fails to connect after initial backoff retries. type Client struct { url string pollInterval time.Duration httpClient *http.Client agents repository.AgentRepo broker *handler.Broker - wsClient *Client // optional WS client; when set, REST is fallback only - wsReady chan struct{} // closed once WS connection is established + wsClient *WSClient // optional WS client; when set, REST is fallback only + wsReady chan struct{} // closed once WS connection is established + wsReadyOnce sync.Once // protects wsReady close from double-close race } // Config holds gateway client configuration, typically loaded from environment. @@ -63,36 +66,56 @@ func NewClient(cfg Config, agents repository.AgentRepo, broker *handler.Broker) // to it. When set, the REST client waits for WS readiness before deciding // whether to poll. func (c *Client) SetWSClient(ws *WSClient) { - _ = ws // stored for future reconnection coordination + c.wsClient = ws } // MarkWSReady signals that the WS connection is live and the REST poller // should stand down. Called by WSClient after a successful handshake. func (c *Client) MarkWSReady() { - select { - case <-c.wsReady: - // already closed - default: + c.wsReadyOnce.Do(func() { close(c.wsReady) - } + }) } // Start begins the gateway client loop. When a WS client is wired, it // waits up to 30 seconds for the WS connection to become ready. If WS -// connects, the REST poller stands down. If WS fails to connect within -// the timeout, REST polling activates as fallback. +// connects, the REST poller stands down and only logs periodically. If WS +// fails to connect within the timeout, REST polling activates as fallback. func (c *Client) Start(ctx context.Context) { - slog.Info("gateway client starting", - "url", c.url, - "pollInterval", c.pollInterval.String()) + if c.wsClient != nil { + slog.Info("gateway client waiting for WS connection", "timeout", "30s") + select { + case <-c.wsReady: + slog.Info("gateway client using WS — REST poller standing down") + // WS is live; keep this goroutine alive but idle. If WS + // disconnects later, we could re-enter polling, but for now + // the WS client handles its own reconnection. + <-ctx.Done() + slog.Info("gateway client stopped (WS mode)") + return + case <-time.After(30 * time.Second): + slog.Warn("gateway client: WS not ready after 30s — falling back to REST polling", + "url", c.url, + "pollInterval", c.pollInterval.String()) + case <-ctx.Done(): + slog.Info("gateway client stopped while waiting for WS") + return + } + } else { + slog.Info("gateway client using REST polling (no WS client configured)", + "url", c.url, + "pollInterval", c.pollInterval.String()) + } + + // REST fallback polling ticker := time.NewTicker(c.pollInterval) defer ticker.Stop() for { select { case <-ctx.Done(): - slog.Info("gateway client stopped") + slog.Info("gateway client stopped (REST fallback)") return case <-ticker.C: c.poll(ctx) diff --git a/go-backend/internal/gateway/events.go b/go-backend/internal/gateway/events.go index fbb25c1..a0f660e 100644 --- a/go-backend/internal/gateway/events.go +++ b/go-backend/internal/gateway/events.go @@ -20,15 +20,15 @@ import ( // sessionChangedPayload represents a single session delta from a // sessions.changed event. type sessionChangedPayload struct { - SessionKey string `json:"sessionKey"` - AgentID string `json:"agentId"` - Status string `json:"status"` // running, streaming, done, error - TotalTokens int `json:"totalTokens"` - LastActivityAt string `json:"lastActivityAt"` - CurrentTask string `json:"currentTask"` - TaskProgress *int `json:"taskProgress,omitempty"` - TaskElapsed string `json:"taskElapsed"` - ErrorMessage string `json:"errorMessage"` + SessionKey string `json:"sessionKey"` + AgentID string `json:"agentId"` + Status string `json:"status"` // running, streaming, done, error + TotalTokens int `json:"totalTokens"` + LastActivityAt string `json:"lastActivityAt"` + CurrentTask string `json:"currentTask"` + TaskProgress *int `json:"taskProgress,omitempty"` + TaskElapsed string `json:"taskElapsed"` + ErrorMessage string `json:"errorMessage"` } // presencePayload represents a device presence update event. @@ -51,8 +51,18 @@ type agentConfigPayload struct { // ── Handler registration ───────────────────────────────────────────────── // registerEventHandlers sets up all live event handlers on the WSClient. -// Called once after a successful handshake + initial sync. +// Call this once after a successful handshake + initial sync. func (c *WSClient) registerEventHandlers() { + if c.agents == nil || c.broker == nil { + c.logger.Info("event handlers skipped (no repository or broker)") + return + } + + // Clear existing handlers to prevent duplicates on reconnect + c.mu.Lock() + c.handlers = make(map[string][]eventHandler) + c.mu.Unlock() + c.OnEvent("sessions.changed", c.handleSessionsChanged) c.OnEvent("presence", c.handlePresence) c.OnEvent("agent.config", c.handleAgentConfig) @@ -68,11 +78,14 @@ func (c *WSClient) registerEventHandlers() { // For each changed session: map the gateway status to an AgentStatus, update // the agent in the DB, then broadcast via SSE. func (c *WSClient) handleSessionsChanged(payload json.RawMessage) { - c.logger.Debug("handleSessionsChanged", "payload", string(payload)) + c.logger.Debug("handleSessionsChanged start", "payload", string(payload)) // Try array first, then single object var deltas []sessionChangedPayload - if err := json.Unmarshal(payload, &deltas); err != nil || len(deltas) == 0 { + if err := json.Unmarshal(payload, &deltas); err == nil && len(deltas) > 0 { + // Array of deltas + } else { + // Try single object var single sessionChangedPayload if err := json.Unmarshal(payload, &single); err != nil { c.logger.Warn("sessions.changed: unparseable payload, skipping", "error", err) @@ -92,27 +105,43 @@ func (c *WSClient) handleSessionsChanged(payload json.RawMessage) { agentStatus := mapSessionStatus(d.Status) + // Build partial update update := models.UpdateAgentRequest{ Status: &agentStatus, } + // Session key + if d.SessionKey != "" { + // SessionKey is not in UpdateAgentRequest directly, but we set + // status and task fields that are available. + } + + // Current task if d.CurrentTask != "" { update.CurrentTask = &d.CurrentTask } + // Task progress if d.TaskProgress != nil { update.TaskProgress = d.TaskProgress + } else if d.TotalTokens > 0 { + // Derive progress from token count as fallback + prog := min(d.TotalTokens/100, 100) + update.TaskProgress = &prog } + // Task elapsed if d.TaskElapsed != "" { update.TaskElapsed = &d.TaskElapsed } + // Error message if d.ErrorMessage != "" { update.ErrorMessage = &d.ErrorMessage } - // If session ended, clear task and progress + // If session ended (done or empty status), set agent to idle and + // clear the current task if agentStatus == models.AgentStatusIdle { emptyTask := "" update.CurrentTask = &emptyTask @@ -120,7 +149,7 @@ func (c *WSClient) handleSessionsChanged(payload json.RawMessage) { update.TaskProgress = &zeroProg } - // DB update first + // Update DB first updated, err := c.agents.Update(ctx, d.AgentID, update) if err != nil { c.logger.Warn("sessions.changed: DB update failed", @@ -128,23 +157,27 @@ func (c *WSClient) handleSessionsChanged(payload json.RawMessage) { continue } - // Then SSE broadcast + // Then broadcast c.broker.Broadcast("agent.status", updated) if d.TaskProgress != nil || d.CurrentTask != "" { c.broker.Broadcast("agent.progress", updated) } c.logger.Debug("sessions.changed: agent updated", - "agentId", d.AgentID, "status", string(agentStatus)) + "agentId", d.AgentID, + "status", string(agentStatus)) } + + c.logger.Debug("handleSessionsChanged end") } // ── presence ───────────────────────────────────────────────────────────── // handlePresence processes presence events from the gateway. Updates the -// agent's lastActivity and broadcasts status if the connection state changed. +// agent's lastActivity timestamp and broadcasts status if the connection +// state changed. func (c *WSClient) handlePresence(payload json.RawMessage) { - c.logger.Debug("handlePresence", "payload", string(payload)) + c.logger.Debug("handlePresence start", "payload", string(payload)) var p presencePayload if err := json.Unmarshal(payload, &p); err != nil { @@ -153,21 +186,31 @@ func (c *WSClient) handlePresence(payload json.RawMessage) { } if p.AgentID == "" { + c.logger.Debug("presence: skipping event with empty agentId") return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + // The Update method always sets last_activity = now, so a no-op update + // (just triggering the last_activity refresh) is sufficient. We send + // an empty-ish update — the repo always bumps last_activity. + // If connection state is reported, also update status. update := models.UpdateAgentRequest{} - // If device disconnected, set agent to idle if p.Connected != nil && !*p.Connected { + // Device disconnected — set agent to idle idle := models.AgentStatusIdle update.Status = &idle } - // DB update first (Update always bumps last_activity) + // Pass lastActivityAt from the event so DB and SSE stay consistent + if p.LastActivityAt != "" { + update.LastActivityAt = &p.LastActivityAt + } + + // Update DB first updated, err := c.agents.Update(ctx, p.AgentID, update) if err != nil { c.logger.Warn("presence: DB update failed", @@ -175,24 +218,21 @@ func (c *WSClient) handlePresence(payload json.RawMessage) { return } - if p.LastActivityAt != "" { - updated.LastActivity = p.LastActivityAt - } - - // Then SSE broadcast + // Then broadcast c.broker.Broadcast("agent.status", updated) c.logger.Debug("presence: agent updated", - "agentId", p.AgentID, "connected", p.Connected) + "agentId", p.AgentID, + "connected", p.Connected) } // ── agent.config ───────────────────────────────────────────────────────── // handleAgentConfig processes agent.config events from the gateway. Updates -// agent metadata (channel) in the DB and broadcasts a fleet.update with the -// full fleet snapshot. +// agent metadata (name, channel) in the DB and broadcasts a fleet.update +// with the full fleet snapshot. func (c *WSClient) handleAgentConfig(payload json.RawMessage) { - c.logger.Debug("handleAgentConfig", "payload", string(payload)) + c.logger.Debug("handleAgentConfig start", "payload", string(payload)) var cfg agentConfigPayload if err := json.Unmarshal(payload, &cfg); err != nil { @@ -201,19 +241,27 @@ func (c *WSClient) handleAgentConfig(payload json.RawMessage) { } if cfg.ID == "" { + c.logger.Debug("agent.config: skipping event with empty id") return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + // Build partial update with available fields. update := models.UpdateAgentRequest{} + if cfg.Name != "" { + update.DisplayName = &cfg.Name + } + if cfg.Role != "" { + update.Role = &cfg.Role + } if cfg.Channel != "" { update.Channel = &cfg.Channel } - // DB update first + // Update DB first updated, err := c.agents.Update(ctx, cfg.ID, update) if err != nil { c.logger.Warn("agent.config: DB update failed", @@ -221,23 +269,19 @@ func (c *WSClient) handleAgentConfig(payload json.RawMessage) { return } - // Apply display name/role from config event - if cfg.Name != "" { - updated.DisplayName = cfg.Name - } - if cfg.Role != "" { - updated.Role = cfg.Role - } - - // Broadcast full fleet snapshot so frontend gets updated agent info + // Then broadcast fleet snapshot allAgents, err := c.agents.List(ctx, "") if err != nil { - c.logger.Warn("agent.config: fleet list failed, broadcasting single agent", "error", err) + c.logger.Warn("agent.config: failed to list fleet for broadcast", + "error", err) + // Still broadcast the single agent update as fallback c.broker.Broadcast("agent.status", updated) return } c.broker.Broadcast("fleet.update", allAgents) - c.logger.Debug("agent.config: fleet updated", "agentId", cfg.ID, "name", cfg.Name) + c.logger.Debug("agent.config: fleet updated", + "agentId", cfg.ID, + "name", cfg.Name) } \ No newline at end of file diff --git a/go-backend/internal/gateway/events_test.go b/go-backend/internal/gateway/events_test.go new file mode 100644 index 0000000..30b8098 --- /dev/null +++ b/go-backend/internal/gateway/events_test.go @@ -0,0 +1,516 @@ +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sync" + "testing" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler" + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" +) + +// ── Mock AgentRepo ──────────────────────────────────────────────────────── + +type mockAgentRepo struct { + mu sync.Mutex + agents map[string]models.AgentCardData + updateCalls []updateCall +} + +type updateCall struct { + id string + req models.UpdateAgentRequest +} + +func (m *mockAgentRepo) Get(_ context.Context, id string) (models.AgentCardData, error) { + m.mu.Lock() + defer m.mu.Unlock() + a, ok := m.agents[id] + if !ok { + return models.AgentCardData{}, errNotFound + } + return a, nil +} + +func (m *mockAgentRepo) Update(_ context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error) { + m.mu.Lock() + defer m.mu.Unlock() + + a, ok := m.agents[id] + if !ok { + return models.AgentCardData{}, errNotFound + } + + if req.Status != nil { + a.Status = *req.Status + } + if req.DisplayName != nil { + a.DisplayName = *req.DisplayName + } + if req.Role != nil { + a.Role = *req.Role + } + if req.Channel != nil { + a.Channel = *req.Channel + } + if req.CurrentTask != nil { + a.CurrentTask = req.CurrentTask + } + if req.TaskProgress != nil { + a.TaskProgress = req.TaskProgress + } + if req.TaskElapsed != nil { + a.TaskElapsed = req.TaskElapsed + } + if req.ErrorMessage != nil { + a.ErrorMessage = req.ErrorMessage + } + if req.LastActivityAt != nil { + a.LastActivity = *req.LastActivityAt + } + + m.agents[id] = a + m.updateCalls = append(m.updateCalls, updateCall{id, req}) + return a, nil +} + +func (m *mockAgentRepo) Create(_ context.Context, a models.AgentCardData) error { + m.mu.Lock() + defer m.mu.Unlock() + m.agents[a.ID] = a + return nil +} + +func (m *mockAgentRepo) List(_ context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error) { + m.mu.Lock() + defer m.mu.Unlock() + + var result []models.AgentCardData + for _, a := range m.agents { + if statusFilter == "" || a.Status == statusFilter { + result = append(result, a) + } + } + return result, nil +} + +func (m *mockAgentRepo) Delete(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.agents, id) + return nil +} + +func (m *mockAgentRepo) Count(_ context.Context) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.agents), nil +} + +// errNotFound is returned by the mock repo when an agent is not found. +var errNotFound = fmt.Errorf("not found") + +// ── Broadcast capture helper ─────────────────────────────────────────────── + +// broadcastCapture wraps a real Broker and captures all broadcasts +// via a subscribed channel. Use captured() to retrieve events that have +// been received so far. Call close() to unsubscribe when done. +type broadcastCapture struct { + broker *handler.Broker + ch chan handler.SSEEvent +} + +func newBroadcastCapture(broker *handler.Broker) *broadcastCapture { + return &broadcastCapture{ + broker: broker, + ch: broker.Subscribe(), + } +} + +// captured drains all pending events from the subscription channel +// and returns them. This is synchronous — it only returns events that +// have already been sent to the channel. +func (bc *broadcastCapture) captured() []handler.SSEEvent { + var events []handler.SSEEvent + for { + select { + case evt := <-bc.ch: + events = append(events, evt) + default: + return events + } + } +} + +func (bc *broadcastCapture) close() { + bc.broker.Unsubscribe(bc.ch) +} + +// ── Test helpers ────────────────────────────────────────────────────────── + +// newTestWSClient creates a WSClient wired to a mock repo and a real broker. +// Returns the client, the mock repo, and a broadcast capture. +func newTestWSClient() (*WSClient, *mockAgentRepo, *handler.Broker, *broadcastCapture) { + repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)} + broker := handler.NewBroker() + capture := newBroadcastCapture(broker) + client := NewWSClient(WSConfig{}, repo, broker, slog.Default()) + return client, repo, broker, capture +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +func TestHandleSessionsChanged_Active(t *testing.T) { + client, repo, _, capture := newTestWSClient() + defer capture.close() + + repo.agents["otto"] = models.AgentCardData{ + ID: "otto", + DisplayName: "Otto", + Status: models.AgentStatusIdle, + } + + payload := json.RawMessage(`{ + "sessionKey": "s1", + "agentId": "otto", + "status": "running", + "totalTokens": 500, + "currentTask": "Orchestrating tasks" + }`) + + client.handleSessionsChanged(payload) + + // Verify: agent status updated to active + repo.mu.Lock() + agent := repo.agents["otto"] + calls := make([]updateCall, len(repo.updateCalls)) + copy(calls, repo.updateCalls) + repo.mu.Unlock() + + if agent.Status != models.AgentStatusActive { + t.Errorf("agent status = %q, want %q", agent.Status, models.AgentStatusActive) + } + + // Verify: update was called + if len(calls) == 0 { + t.Fatal("expected at least one update call") + } + if calls[0].id != "otto" { + t.Errorf("update call agentId = %q, want %q", calls[0].id, "otto") + } + + // Verify: broker broadcast "agent.status" + events := capture.captured() + found := false + for _, evt := range events { + if evt.EventType == "agent.status" { + found = true + break + } + } + if !found { + t.Error("expected broker broadcast with event type 'agent.status'") + } +} + +func TestHandleSessionsChanged_Idle(t *testing.T) { + client, repo, _, capture := newTestWSClient() + defer capture.close() + + repo.agents["dex"] = models.AgentCardData{ + ID: "dex", + DisplayName: "Dex", + Status: models.AgentStatusActive, + CurrentTask: strPtr("Writing API"), + } + + payload := json.RawMessage(`{ + "sessionKey": "s2", + "agentId": "dex", + "status": "done", + "totalTokens": 1000 + }`) + + client.handleSessionsChanged(payload) + + repo.mu.Lock() + agent := repo.agents["dex"] + repo.mu.Unlock() + + // Verify: agent goes idle + if agent.Status != models.AgentStatusIdle { + t.Errorf("agent status = %q, want %q", agent.Status, models.AgentStatusIdle) + } + + // Verify: current task cleared (set to empty string) + if agent.CurrentTask != nil && *agent.CurrentTask != "" { + t.Errorf("current task = %q, want empty (cleared on idle)", *agent.CurrentTask) + } + + // Verify: broker fires "agent.status" + events := capture.captured() + found := false + for _, evt := range events { + if evt.EventType == "agent.status" { + found = true + break + } + } + if !found { + t.Error("expected broker broadcast with event type 'agent.status'") + } +} + +func TestHandleSessionsChanged_ArrayPayload(t *testing.T) { + client, repo, _, capture := newTestWSClient() + defer capture.close() + + repo.agents["otto"] = models.AgentCardData{ID: "otto", DisplayName: "Otto", Status: models.AgentStatusIdle} + repo.agents["dex"] = models.AgentCardData{ID: "dex", DisplayName: "Dex", Status: models.AgentStatusIdle} + + payload := json.RawMessage(`[ + {"sessionKey":"s1","agentId":"otto","status":"running","totalTokens":100}, + {"sessionKey":"s2","agentId":"dex","status":"streaming","totalTokens":200} + ]`) + + client.handleSessionsChanged(payload) + + repo.mu.Lock() + otto := repo.agents["otto"] + dex := repo.agents["dex"] + repo.mu.Unlock() + + if otto.Status != models.AgentStatusActive { + t.Errorf("otto status = %q, want active", otto.Status) + } + if dex.Status != models.AgentStatusActive { + t.Errorf("dex status = %q, want active", dex.Status) + } + + // Both should produce broadcasts + events := capture.captured() + statusCount := 0 + for _, evt := range events { + if evt.EventType == "agent.status" { + statusCount++ + } + } + if statusCount < 2 { + t.Errorf("expected at least 2 agent.status broadcasts, got %d", statusCount) + } +} + +func TestHandleSessionsChanged_SkipsEmptyAgentID(t *testing.T) { + client, _, _, capture := newTestWSClient() + defer capture.close() + + payload := json.RawMessage(`{"sessionKey":"s1","agentId":"","status":"running"}`) + client.handleSessionsChanged(payload) + + events := capture.captured() + if len(events) > 0 { + t.Errorf("expected no broadcasts for empty agentId, got %d", len(events)) + } +} + +func TestHandleSessionsChanged_UnparseablePayload(t *testing.T) { + client, _, _, capture := newTestWSClient() + defer capture.close() + + payload := json.RawMessage(`not json at all`) + client.handleSessionsChanged(payload) + + events := capture.captured() + if len(events) > 0 { + t.Errorf("expected no broadcasts for unparseable payload, got %d", len(events)) + } +} + +func TestHandlePresence(t *testing.T) { + client, repo, _, capture := newTestWSClient() + defer capture.close() + + repo.agents["pip"] = models.AgentCardData{ + ID: "pip", + DisplayName: "Pip", + Status: models.AgentStatusActive, + } + + payload := json.RawMessage(`{ + "agentId": "pip", + "connected": true, + "lastActivityAt": "2025-01-01T00:00:00Z" + }`) + + client.handlePresence(payload) + + repo.mu.Lock() + agent := repo.agents["pip"] + calls := make([]updateCall, len(repo.updateCalls)) + copy(calls, repo.updateCalls) + repo.mu.Unlock() + + // Agent should still be active (connected=true doesn't change status) + if agent.Status != models.AgentStatusActive { + t.Errorf("agent status = %q, want active", agent.Status) + } + + // Update should have been called (for lastActivityAt) + if len(calls) == 0 { + t.Fatal("expected at least one update call") + } + + // Verify broadcast + events := capture.captured() + found := false + for _, evt := range events { + if evt.EventType == "agent.status" { + found = true + break + } + } + if !found { + t.Error("expected broker broadcast with event type 'agent.status'") + } +} + +func TestHandlePresence_Disconnect(t *testing.T) { + client, repo, _, capture := newTestWSClient() + defer capture.close() + + repo.agents["pip"] = models.AgentCardData{ + ID: "pip", + DisplayName: "Pip", + Status: models.AgentStatusActive, + } + + payload := json.RawMessage(`{ + "agentId": "pip", + "connected": false + }`) + + client.handlePresence(payload) + + repo.mu.Lock() + agent := repo.agents["pip"] + repo.mu.Unlock() + + // Agent should go idle on disconnect + if agent.Status != models.AgentStatusIdle { + t.Errorf("agent status = %q, want idle after disconnect", agent.Status) + } + + events := capture.captured() + found := false + for _, evt := range events { + if evt.EventType == "agent.status" { + found = true + break + } + } + if !found { + t.Error("expected broker broadcast with event type 'agent.status' on disconnect") + } +} + +func TestHandlePresence_EmptyAgentID(t *testing.T) { + client, _, _, capture := newTestWSClient() + defer capture.close() + + payload := json.RawMessage(`{"agentId":"","connected":true}`) + client.handlePresence(payload) + + events := capture.captured() + if len(events) > 0 { + t.Errorf("expected no broadcasts for empty agentId, got %d", len(events)) + } +} + +func TestHandleAgentConfig(t *testing.T) { + client, repo, _, capture := newTestWSClient() + defer capture.close() + + repo.agents["rex"] = models.AgentCardData{ + ID: "rex", + DisplayName: "Rex", + Role: "Frontend Dev", + Status: models.AgentStatusIdle, + Channel: "discord", + } + + payload := json.RawMessage(`{ + "id": "rex", + "name": "Rex the Dev", + "role": "Senior Frontend", + "channel": "telegram" + }`) + + client.handleAgentConfig(payload) + + repo.mu.Lock() + agent := repo.agents["rex"] + calls := make([]updateCall, len(repo.updateCalls)) + copy(calls, repo.updateCalls) + repo.mu.Unlock() + + // Verify DisplayName and Role updated + if agent.DisplayName != "Rex the Dev" { + t.Errorf("displayName = %q, want %q", agent.DisplayName, "Rex the Dev") + } + if agent.Role != "Senior Frontend" { + t.Errorf("role = %q, want %q", agent.Role, "Senior Frontend") + } + if agent.Channel != "telegram" { + t.Errorf("channel = %q, want %q", agent.Channel, "telegram") + } + + // Verify update was called + if len(calls) == 0 { + t.Fatal("expected at least one update call") + } + + // Verify broker fires "fleet.update" + events := capture.captured() + found := false + for _, evt := range events { + if evt.EventType == "fleet.update" { + found = true + break + } + } + if !found { + t.Error("expected broker broadcast with event type 'fleet.update'") + } +} + +func TestHandleAgentConfig_EmptyID(t *testing.T) { + client, _, _, capture := newTestWSClient() + defer capture.close() + + payload := json.RawMessage(`{"id":"","name":"Ghost"}`) + client.handleAgentConfig(payload) + + events := capture.captured() + if len(events) > 0 { + t.Errorf("expected no broadcasts for empty id, got %d", len(events)) + } +} + +func TestHandleAgentConfig_NotFound(t *testing.T) { + client, _, _, capture := newTestWSClient() + defer capture.close() + + payload := json.RawMessage(`{"id":"unknown","name":"Ghost","role":"Phantom"}`) + client.handleAgentConfig(payload) + + // Agent doesn't exist in repo, so Update will fail → handler logs warning, returns early + events := capture.captured() + for _, evt := range events { + if evt.EventType == "fleet.update" { + t.Error("fleet.update should not be broadcast when agent update fails") + } + } +} \ No newline at end of file diff --git a/go-backend/internal/gateway/sync.go b/go-backend/internal/gateway/sync.go index dd5efe0..3352ed3 100644 --- a/go-backend/internal/gateway/sync.go +++ b/go-backend/internal/gateway/sync.go @@ -16,6 +16,8 @@ import ( // ── RPC response types ─────────────────────────────────────────────────── // agentListItem represents a single agent returned by the agents.list RPC. +// Fields are extracted gracefully from json.RawMessage so unknown fields +// from the gateway are silently ignored. type agentListItem struct { ID string `json:"id"` Name string `json:"name"` @@ -40,9 +42,14 @@ type sessionListItem struct { // persists them, merges session state into agent cards, and broadcasts // the merged fleet as a fleet.update event. func (c *WSClient) initialSync(ctx context.Context) error { + if c.agents == nil { + c.logger.Info("initial sync skipped (no repository)") + return nil + } + c.logger.Info("initial sync starting") - // 1. Fetch agents via RPC + // 1. Fetch agents agentsRaw, err := c.Send("agents.list", nil) if err != nil { return fmt.Errorf("agents.list RPC: %w", err) @@ -55,7 +62,7 @@ func (c *WSClient) initialSync(ctx context.Context) error { c.logger.Info("agents.list received", "count", len(agentItems)) - // 2. Persist each agent (create if not exists, update if changed) + // 2. Persist each agent for _, item := range agentItems { card := agentItemToCard(item) @@ -70,12 +77,13 @@ func (c *WSClient) initialSync(ctx context.Context) error { continue } - // Agent exists — update display name or role if changed + // Agent exists — update if display name or role changed if existing.DisplayName != card.DisplayName || existing.Role != card.Role { - // Update what we can via UpdateAgentRequest - channel := card.Channel + newName := card.DisplayName + newRole := card.Role _, updateErr := c.agents.Update(ctx, card.ID, models.UpdateAgentRequest{ - Channel: &channel, + DisplayName: &newName, + Role: &newRole, }) if updateErr != nil { c.logger.Warn("sync: agent update failed", "id", card.ID, "error", updateErr) @@ -83,7 +91,7 @@ func (c *WSClient) initialSync(ctx context.Context) error { } } - // 3. Fetch sessions via RPC + // 3. Fetch sessions sessionsRaw, err := c.Send("sessions.list", nil) if err != nil { return fmt.Errorf("sessions.list RPC: %w", err) @@ -96,7 +104,7 @@ func (c *WSClient) initialSync(ctx context.Context) error { c.logger.Info("sessions.list received", "count", len(sessionItems)) - // 4. Build agentId → session map for merge + // 4. Build a map of agentId → session for merge sessionByAgent := make(map[string]sessionListItem) for _, s := range sessionItems { if s.AgentID != "" { @@ -104,25 +112,26 @@ func (c *WSClient) initialSync(ctx context.Context) error { } } - // 5. Merge session state into agents, update DB, and collect for broadcast + // 5. Merge session state into agents and update + broadcast mergedAgents := make([]models.AgentCardData, 0, len(agentItems)) for _, item := range agentItems { card := agentItemToCard(item) if session, ok := sessionByAgent[item.ID]; ok { - // Merge session state into agent card + // Merge session state card.SessionKey = session.SessionKey card.Status = mapSessionStatus(session.Status) card.LastActivity = session.LastActivityAt + // Use totalTokens as a rough progress indicator if session.TotalTokens > 0 { - prog := min(session.TotalTokens/100, 100) + prog := min(session.TotalTokens/100, 100) // normalize to 0-100 card.TaskProgress = &prog } } - // Persist merged status change + // Persist merged state existing, err := c.agents.Get(ctx, card.ID) if err == nil && existing.Status != card.Status { status := card.Status @@ -146,8 +155,8 @@ func (c *WSClient) initialSync(ctx context.Context) error { // mapSessionStatus converts a gateway session status string to an AgentStatus. // - "running" / "streaming" → active -// - "error" → error -// - "done" / "" / other → idle +// - "error" → error +// - "done" / "" / other → idle func mapSessionStatus(status string) models.AgentStatus { switch status { case "running", "streaming": @@ -168,7 +177,7 @@ func agentItemToCard(item agentListItem) models.AgentCardData { } channel := item.Channel if channel == "" { - channel = "discord" + channel = "unknown" } name := item.Name if name == "" { @@ -176,12 +185,12 @@ func agentItemToCard(item agentListItem) models.AgentCardData { } return models.AgentCardData{ - ID: item.ID, - DisplayName: name, - Role: role, - Status: models.AgentStatusIdle, // default; overridden by session merge - SessionKey: "", - Channel: channel, + ID: item.ID, + DisplayName: name, + Role: role, + Status: models.AgentStatusIdle, // default; will be overridden by session merge + SessionKey: "", + Channel: channel, LastActivity: time.Now().UTC().Format(time.RFC3339), } } \ No newline at end of file diff --git a/go-backend/internal/gateway/sync_test.go b/go-backend/internal/gateway/sync_test.go new file mode 100644 index 0000000..3d2b5c4 --- /dev/null +++ b/go-backend/internal/gateway/sync_test.go @@ -0,0 +1,236 @@ +package gateway + +import ( + "context" + "testing" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler" + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" +) + +func TestInitialSync(t *testing.T) { + _ = &mockAgentRepo{agents: make(map[string]models.AgentCardData)} // verify mock compiles + broker := handler.NewBroker() + capture := newBroadcastCapture(broker) + defer capture.close() + + // --- Test agentItemToCard + session merge (the core of initialSync) --- + + agentItems := []agentListItem{ + {ID: "otto", Name: "Otto", Role: "Orchestrator", Channel: "discord"}, + {ID: "dex", Name: "Dex", Role: "Backend Dev", Channel: "telegram"}, + } + + sessionItems := []sessionListItem{ + {SessionKey: "s1", AgentID: "otto", Status: "running", TotalTokens: 500, LastActivityAt: "2025-05-20T12:00:00Z"}, + {SessionKey: "s2", AgentID: "dex", Status: "done", TotalTokens: 1000, LastActivityAt: "2025-05-20T11:00:00Z"}, + } + + // Build sessionByAgent map (mirrors initialSync logic) + sessionByAgent := make(map[string]sessionListItem) + for _, s := range sessionItems { + if s.AgentID != "" { + sessionByAgent[s.AgentID] = s + } + } + + // Merge and verify + merged := make([]models.AgentCardData, 0, len(agentItems)) + for _, item := range agentItems { + card := agentItemToCard(item) + + if session, ok := sessionByAgent[item.ID]; ok { + card.SessionKey = session.SessionKey + card.Status = mapSessionStatus(session.Status) + card.LastActivity = session.LastActivityAt + + if session.TotalTokens > 0 { + prog := min(session.TotalTokens/100, 100) + card.TaskProgress = &prog + } + } + + merged = append(merged, card) + } + + // Verify otto: running → active + if merged[0].ID != "otto" { + t.Errorf("merged[0].ID = %q, want %q", merged[0].ID, "otto") + } + if merged[0].Status != models.AgentStatusActive { + t.Errorf("otto status = %q, want %q (running → active)", merged[0].Status, models.AgentStatusActive) + } + if merged[0].SessionKey != "s1" { + t.Errorf("otto sessionKey = %q, want %q", merged[0].SessionKey, "s1") + } + if merged[0].TaskProgress == nil || *merged[0].TaskProgress != 5 { + t.Errorf("otto taskProgress = %v, want 5", merged[0].TaskProgress) + } + + // Verify dex: done → idle + if merged[1].ID != "dex" { + t.Errorf("merged[1].ID = %q, want %q", merged[1].ID, "dex") + } + if merged[1].Status != models.AgentStatusIdle { + t.Errorf("dex status = %q, want %q (done → idle)", merged[1].Status, models.AgentStatusIdle) + } + if merged[1].SessionKey != "s2" { + t.Errorf("dex sessionKey = %q, want %q", merged[1].SessionKey, "s2") + } +} + +func TestInitialSync_PersistCreatesNew(t *testing.T) { + repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)} + broker := handler.NewBroker() + capture := newBroadcastCapture(broker) + defer capture.close() + + // Simulate the persist logic from initialSync: + // new agents should be created + card := agentItemToCard(agentListItem{ID: "otto", Name: "Otto", Role: "Orchestrator", Channel: "discord"}) + + ctx := context.Background() + + // Agent doesn't exist → create + _, err := repo.Get(ctx, card.ID) + if err == nil { + t.Fatal("expected agent to not exist yet") + } + + if err := repo.Create(ctx, card); err != nil { + t.Fatalf("Create failed: %v", err) + } + + got, err := repo.Get(ctx, card.ID) + if err != nil { + t.Fatalf("Get after Create failed: %v", err) + } + + if got.ID != "otto" { + t.Errorf("got.ID = %q, want %q", got.ID, "otto") + } + if got.DisplayName != "Otto" { + t.Errorf("got.DisplayName = %q, want %q", got.DisplayName, "Otto") + } + if got.Role != "Orchestrator" { + t.Errorf("got.Role = %q, want %q", got.Role, "Orchestrator") + } +} + +func TestInitialSync_PersistUpdatesExisting(t *testing.T) { + repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)} + broker := handler.NewBroker() + capture := newBroadcastCapture(broker) + defer capture.close() + + ctx := context.Background() + + // Pre-populate with existing agent + repo.agents["otto"] = models.AgentCardData{ + ID: "otto", + DisplayName: "Otto", + Role: "Old Role", + Status: models.AgentStatusIdle, + } + + // Simulate initialSync: agent exists, name/role changed → update + newName := "Otto Prime" + newRole := "Super Orchestrator" + _, err := repo.Update(ctx, "otto", models.UpdateAgentRequest{ + DisplayName: &newName, + Role: &newRole, + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + got, err := repo.Get(ctx, "otto") + if err != nil { + t.Fatalf("Get after Update failed: %v", err) + } + + if got.DisplayName != "Otto Prime" { + t.Errorf("displayName = %q, want %q", got.DisplayName, "Otto Prime") + } + if got.Role != "Super Orchestrator" { + t.Errorf("role = %q, want %q", got.Role, "Super Orchestrator") + } +} + +func TestInitialSync_MergesSessionStatus(t *testing.T) { + // When initialSync merges session state, an agent whose existing status + // differs from the session-derived status should be updated. + repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)} + ctx := context.Background() + + repo.agents["otto"] = models.AgentCardData{ + ID: "otto", + DisplayName: "Otto", + Role: "Orchestrator", + Status: models.AgentStatusIdle, + } + + // Simulate session merge: session says "running" → agent should go active + activeStatus := mapSessionStatus("running") + if activeStatus != models.AgentStatusActive { + t.Fatalf("mapSessionStatus(running) = %q, want active", activeStatus) + } + + _, err := repo.Update(ctx, "otto", models.UpdateAgentRequest{ + Status: &activeStatus, + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + got, err := repo.Get(ctx, "otto") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if got.Status != models.AgentStatusActive { + t.Errorf("status after merge = %q, want %q", got.Status, models.AgentStatusActive) + } +} + +func TestInitialSync_BroadcastsFleet(t *testing.T) { + repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)} + broker := handler.NewBroker() + capture := newBroadcastCapture(broker) + defer capture.close() + + // Create some agents in the repo + repo.agents["otto"] = models.AgentCardData{ID: "otto", DisplayName: "Otto", Status: models.AgentStatusActive} + repo.agents["dex"] = models.AgentCardData{ID: "dex", DisplayName: "Dex", Status: models.AgentStatusIdle} + + // Simulate the final broadcast from initialSync + mergedAgents := []models.AgentCardData{ + repo.agents["otto"], + repo.agents["dex"], + } + broker.Broadcast("fleet.update", mergedAgents) + + events := capture.captured() + if len(events) == 0 { + t.Fatal("expected at least one broadcast event") + } + + found := false + for _, evt := range events { + if evt.EventType == "fleet.update" { + found = true + // Verify data is the merged agents list + agents, ok := evt.Data.([]models.AgentCardData) + if !ok { + t.Fatalf("fleet.update data type = %T, want []models.AgentCardData", evt.Data) + } + if len(agents) != 2 { + t.Errorf("fleet.update agents count = %d, want 2", len(agents)) + } + break + } + } + if !found { + t.Error("expected fleet.update broadcast event") + } +} \ No newline at end of file diff --git a/go-backend/internal/gateway/wsclient.go b/go-backend/internal/gateway/wsclient.go index 88db477..9500809 100644 --- a/go-backend/internal/gateway/wsclient.go +++ b/go-backend/internal/gateway/wsclient.go @@ -1,7 +1,7 @@ // Package gateway provides WebSocket client integration with the OpenClaw // gateway using WS protocol v3. The WSClient handles connection, handshake, // frame routing, request/response correlation, and automatic reconnection -// with exponential backoff (1s → 30s max). +// with exponential backoff. package gateway import ( @@ -15,8 +15,8 @@ import ( "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository" - "github.com/google/uuid" "github.com/gorilla/websocket" + "github.com/google/uuid" ) // WSConfig holds WebSocket client configuration, typically loaded from @@ -41,19 +41,21 @@ type eventHandler func(json.RawMessage) // WSClient connects to the OpenClaw gateway over WebSocket, completes the // v3 handshake, routes incoming frames, and automatically reconnects on -// disconnect with exponential backoff (1s → 30s max). +// disconnect with exponential backoff. type WSClient struct { - config WSConfig - conn *websocket.Conn - connMu sync.Mutex // protects conn for writes - pending map[string]chan<- json.RawMessage - mu sync.Mutex // protects pending and handlers - agents repository.AgentRepo - broker *handler.Broker - logger *slog.Logger - handlers map[string][]eventHandler - connID string // set after successful hello-ok - restClient *Client // optional REST client to notify on WS ready + config WSConfig + conn *websocket.Conn + connMu sync.Mutex // protects conn for writes + pending map[string]chan<- json.RawMessage + mu sync.Mutex // protects pending and handlers + agents repository.AgentRepo + broker *handler.Broker + logger *slog.Logger + + handlers map[string][]eventHandler + connId string // set after successful hello-ok + restClient *Client // optional REST client to notify on WS ready + wsReadyOnce sync.Once // ensures MarkWSReady close is one-shot } // NewWSClient returns a WSClient wired to the given repository and broker. @@ -79,7 +81,7 @@ func (c *WSClient) SetRESTClient(rest *Client) { // OnEvent registers a handler for the given event name. Handlers are called // when an incoming frame with type "event" and matching event name is -// received. Safe to call before Start. +// received. This is safe to call before Start. func (c *WSClient) OnEvent(event string, handler func(json.RawMessage)) { c.mu.Lock() defer c.mu.Unlock() @@ -90,10 +92,10 @@ func (c *WSClient) OnEvent(event string, handler func(json.RawMessage)) { // wsFrame represents a generic WebSocket frame in the OpenClaw v3 protocol. type wsFrame struct { - Type string `json:"type"` // "req", "res", "event" - ID string `json:"id,omitempty"` // request/response correlation - Method string `json:"method,omitempty"` // method name (req/res frames) - Event string `json:"event,omitempty"` // event name (event frames) + Type string `json:"type"` // "req", "res", "event" + ID string `json:"id,omitempty"` // request/response correlation + Method string `json:"method,omitempty"` // method name (req frames) + Event string `json:"event,omitempty"` // event name (event frames) Params json.RawMessage `json:"params,omitempty"` Result json.RawMessage `json:"result,omitempty"` Error *wsError `json:"error,omitempty"` @@ -128,7 +130,7 @@ type connectAuth struct { // helloOKResponse represents the expected response to a successful connect. type helloOKResponse struct { - ConnID string `json:"connId"` + ConnID string `json:"connId"` Features struct { Methods []string `json:"methods"` Events []string `json:"events"` @@ -138,11 +140,12 @@ type helloOKResponse struct { // ── Start loop ─────────────────────────────────────────────────────────── // Start connects to the gateway, completes the handshake, and begins the -// read loop. On disconnect it reconnects with exponential backoff (1s → 30s). -// On ctx cancellation it performs a clean shutdown. +// read loop. On disconnect it reconnects with exponential backoff. On +// ctx cancellation it performs a clean shutdown. func (c *WSClient) Start(ctx context.Context) { - backoff := 1 * time.Second + initialBackoff := 1 * time.Second maxBackoff := 30 * time.Second + backoff := initialBackoff for { err := c.connectAndRun(ctx) @@ -154,6 +157,9 @@ func (c *WSClient) Start(ctx context.Context) { c.logger.Warn("ws client disconnected, reconnecting", "error", err, "backoff", backoff) + } else { + // Reset backoff on successful connect+run completion + backoff = initialBackoff } select { @@ -188,14 +194,26 @@ func (c *WSClient) connectAndRun(ctx context.Context) error { c.conn = conn c.connMu.Unlock() - defer conn.Close() + // When context is cancelled, close the conn to unblock ReadJSON in readLoop. + go func() { + <-ctx.Done() + c.connMu.Lock() + if c.conn != nil { + c.conn.Close() + } + c.connMu.Unlock() + }() + + defer func() { + conn.Close() + }() // Step 1: Read the connect.challenge frame if err := c.readChallenge(conn); err != nil { return fmt.Errorf("handshake challenge: %w", err) } - // Step 2: Send connect request and read hello-ok response + // Step 2: Send connect request helloOK, err := c.sendConnect(conn) if err != nil { return fmt.Errorf("handshake connect: %w", err) @@ -206,8 +224,9 @@ func (c *WSClient) connectAndRun(ctx context.Context) error { "methods", helloOK.Features.Methods, "events", helloOK.Features.Events) + // Store connId for reference c.connMu.Lock() - c.connID = helloOK.ConnID + c.connId = helloOK.ConnID c.connMu.Unlock() // Notify REST client that WS is live so it stands down @@ -216,15 +235,18 @@ func (c *WSClient) connectAndRun(ctx context.Context) error { c.logger.Info("ws client notified REST fallback to stand down") } - // Step 3: Initial sync — fetch agents + sessions from gateway + // Reset wsReadyOnce so MarkWSReady can fire again after a reconnect + c.wsReadyOnce = sync.Once{} + + // Step 2b: Initial sync — fetch agents + sessions from gateway if err := c.initialSync(ctx); err != nil { - c.logger.Warn("initial sync failed, continuing with read loop", "error", err) + c.logger.Warn("initial sync failed, will continue with read loop", "error", err) } - // Step 4: Register live event handlers + // Step 2c: Register live event handlers c.registerEventHandlers() - // Step 5: Read loop — blocks until disconnect or ctx cancel + // Step 3: Read loop return c.readLoop(ctx, conn) } @@ -240,7 +262,7 @@ func (c *WSClient) readChallenge(conn *websocket.Conn) error { return fmt.Errorf("expected connect.challenge, got type=%s event=%s", frame.Type, frame.Event) } - c.logger.Debug("received connect.challenge") + c.logger.Debug("received connect.challenge", "params", string(frame.Params)) return nil } @@ -293,6 +315,8 @@ func (c *WSClient) sendConnect(conn *websocket.Conn) (*helloOKResponse, error) { return nil, fmt.Errorf("response id mismatch: expected %s, got %s", reqID, resFrame.ID) } + // Check for hello-ok method in the result + // The gateway responds with method "hello-ok" on success var helloOK helloOKResponse if err := json.Unmarshal(resFrame.Result, &helloOK); err != nil { return nil, fmt.Errorf("parse hello-ok: %w", err) @@ -302,25 +326,16 @@ func (c *WSClient) sendConnect(conn *websocket.Conn) (*helloOKResponse, error) { } // readLoop continuously reads frames from the connection and routes them. -// It returns on read error or context cancellation. +// It returns on read error or when the connection is closed by the ctx-done +// goroutine started in connectAndRun. func (c *WSClient) readLoop(ctx context.Context, conn *websocket.Conn) error { for { - select { - case <-ctx.Done(): - // Clean shutdown: send close frame - c.connMu.Lock() - c.conn.WriteControl( - websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "shutdown"), - time.Now().Add(5*time.Second), - ) - c.connMu.Unlock() - return ctx.Err() - default: - } - var frame wsFrame if err := conn.ReadJSON(&frame); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + // Check if it's a close error if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { c.logger.Info("ws connection closed by server") return nil @@ -344,7 +359,7 @@ func (c *WSClient) routeFrame(frame wsFrame) { case "event": c.handleEvent(frame) default: - c.logger.Debug("unknown frame type", "type", frame.Type, "id", frame.ID) + c.logger.Warn("unknown frame type", "type", frame.Type, "id", frame.ID) } } @@ -363,6 +378,7 @@ func (c *WSClient) handleResponse(frame wsFrame) { } if frame.Error != nil { + // Send nil to signal error; caller checks via Send return ch <- nil return } @@ -386,20 +402,17 @@ func (c *WSClient) handleEvent(frame wsFrame) { } } -// ── Send (RPC) ────────────────────────────────────────────────────────── +// ── Send ───────────────────────────────────────────────────────────────── -// Send sends a JSON-RPC request to the gateway and returns the response -// payload. It is safe for concurrent use. +// Send sends a JSON request to the gateway and returns the response payload. +// It is safe for concurrent use. Returns an error if the client is not +// connected. func (c *WSClient) Send(method string, params any) (json.RawMessage, error) { reqID := uuid.New().String() - var paramsJSON json.RawMessage - if params != nil { - var err error - paramsJSON, err = json.Marshal(params) - if err != nil { - return nil, fmt.Errorf("marshal params: %w", err) - } + paramsJSON, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("marshal params: %w", err) } // Register pending response channel @@ -423,7 +436,11 @@ func (c *WSClient) Send(method string, params any) (json.RawMessage, error) { } c.connMu.Lock() - err := c.conn.WriteJSON(frame) + if c.conn == nil { + c.connMu.Unlock() + return nil, fmt.Errorf("gateway: not connected") + } + err = c.conn.WriteJSON(frame) c.connMu.Unlock() if err != nil { @@ -434,10 +451,10 @@ func (c *WSClient) Send(method string, params any) (json.RawMessage, error) { select { case resp := <-respCh: if resp == nil { - return nil, fmt.Errorf("gateway returned error for request %s (%s)", reqID, method) + return nil, fmt.Errorf("gateway returned error for request %s", reqID) } return resp, nil case <-time.After(30 * time.Second): - return nil, fmt.Errorf("request %s (%s) timed out", reqID, method) + return nil, fmt.Errorf("request %s timed out", reqID) } } \ No newline at end of file diff --git a/go-backend/internal/gateway/wsclient_test.go b/go-backend/internal/gateway/wsclient_test.go new file mode 100644 index 0000000..92a1d66 --- /dev/null +++ b/go-backend/internal/gateway/wsclient_test.go @@ -0,0 +1,484 @@ +package gateway + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" + + "github.com/gorilla/websocket" +) + +// ── Mock WebSocket server helper ───────────────────────────────────────── + +// newTestWSServer creates an httptest.Server that upgrades to WebSocket and +// delegates each connection to handler. The server URL can be converted to +// a ws:// URL by replacing "http" with "ws". +func newTestWSServer(t *testing.T, handler func(conn *websocket.Conn)) *httptest.Server { + t.Helper() + upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + handler(conn) + })) + return srv +} + +// wsURL converts an httptest.Server http URL to a ws URL. +func wsURL(srv *httptest.Server) string { + return "ws" + strings.TrimPrefix(srv.URL, "http") +} + +// ── Handshake helper for mock server ───────────────────────────────────── + +// handleHandshake performs the server side of the v3 handshake: +// 1. Send connect.challenge +// 2. Read connect request +// 3. Send hello-ok response +// +// Returns the connect request frame for inspection. +func handleHandshake(t *testing.T, conn *websocket.Conn) map[string]any { + t.Helper() + + // 1. Send connect.challenge + challenge := map[string]any{ + "type": "event", + "event": "connect.challenge", + "params": map[string]any{"nonce": "test-nonce", "ts": 1716180000000}, + } + if err := conn.WriteJSON(challenge); err != nil { + t.Fatalf("server: write challenge: %v", err) + } + + // 2. Read connect request + var req map[string]any + if err := conn.ReadJSON(&req); err != nil { + t.Fatalf("server: read connect request: %v", err) + } + + if req["method"] != "connect" { + t.Fatalf("server: expected method=connect, got %v", req["method"]) + } + + // 3. Send hello-ok response + // Note: helloOKResponse expects ConnID at the top level of the result, + // matching the WSClient's JSON struct tags. + result := map[string]any{ + "type": "hello-ok", + "protocol": 3, + "connId": "test-conn-123", + "features": map[string]any{"methods": []string{}, "events": []string{}}, + "auth": map[string]any{"role": "operator", "scopes": []string{"operator.read"}}, + } + res := map[string]any{ + "type": "res", + "id": req["id"], + "ok": true, + "result": result, + } + if err := conn.WriteJSON(res); err != nil { + t.Fatalf("server: write hello-ok: %v", err) + } + + return req +} + +// keepAlive reads frames from the connection until an error occurs +// (e.g., the client disconnects). Used as the default "do nothing" +// server loop after handshake. +func keepAlive(conn *websocket.Conn) { + for { + var m map[string]any + if err := conn.ReadJSON(&m); err != nil { + break + } + } +} + +// ── 1. Test: Full handshake ────────────────────────────────────────────── + +func TestWSClient_Handshake(t *testing.T) { + srv := newTestWSServer(t, func(conn *websocket.Conn) { + handleHandshake(t, conn) + keepAlive(conn) + }) + defer srv.Close() + + client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + done := make(chan struct{}) + go func() { + client.Start(ctx) + close(done) + }() + + // Wait briefly for handshake to complete + time.Sleep(200 * time.Millisecond) + + // Verify connId was set + client.connMu.Lock() + connID := client.connId + client.connMu.Unlock() + + if connID != "test-conn-123" { + t.Errorf("expected connId 'test-conn-123', got %q", connID) + } + + cancel() + select { + case <-done: + // Client exited cleanly + case <-time.After(3 * time.Second): + t.Fatal("WSClient did not shut down after context cancellation") + } +} + +// ── 2. Test: Send() with response matching ─────────────────────────────── + +func TestWSClient_Send(t *testing.T) { + srv := newTestWSServer(t, func(conn *websocket.Conn) { + handleHandshake(t, conn) + + // Read RPC requests and respond to each + for { + var req map[string]any + if err := conn.ReadJSON(&req); err != nil { + break + } + reqID, _ := req["id"].(string) + method, _ := req["method"].(string) + + var result any + switch method { + case "agents.list": + result = map[string]any{ + "agents": []map[string]any{ + {"id": "otto", "name": "Otto"}, + }, + } + default: + result = map[string]any{} + } + + res := map[string]any{ + "type": "res", + "id": reqID, + "ok": true, + "result": result, + } + if err := conn.WriteJSON(res); err != nil { + break + } + } + }) + defer srv.Close() + + client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + go client.Start(ctx) + + // Give the client time to complete handshake + time.Sleep(300 * time.Millisecond) + + resp, err := client.Send("agents.list", nil) + if err != nil { + t.Fatalf("Send() returned error: %v", err) + } + + // Verify the response payload + var result map[string]any + if err := json.Unmarshal(resp, &result); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + agents, ok := result["agents"].([]any) + if !ok || len(agents) != 1 { + t.Errorf("expected 1 agent in response, got %v", result) + } + + cancel() +} + +// ── 3. Test: Event handler routing ─────────────────────────────────────── + +func TestWSClient_EventRouting(t *testing.T) { + eventReceived := make(chan json.RawMessage, 1) + + srv := newTestWSServer(t, func(conn *websocket.Conn) { + handleHandshake(t, conn) + + // After handshake, send a test event + evt := map[string]any{ + "type": "event", + "event": "test.event", + "params": map[string]any{"greeting": "hello from server"}, + } + if err := conn.WriteJSON(evt); err != nil { + t.Logf("server: write event: %v", err) + return + } + + keepAlive(conn) + }) + defer srv.Close() + + client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default()) + + // Register event handler BEFORE starting the client + client.OnEvent("test.event", func(payload json.RawMessage) { + eventReceived <- payload + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + go client.Start(ctx) + + // Wait for the event handler to fire + select { + case payload := <-eventReceived: + var data map[string]any + if err := json.Unmarshal(payload, &data); err != nil { + t.Fatalf("unmarshal event payload: %v", err) + } + if greeting, _ := data["greeting"].(string); greeting != "hello from server" { + t.Errorf("expected greeting 'hello from server', got %q", greeting) + } + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for event handler to fire") + } + + cancel() +} + +// ── 4. Test: Concurrent Send ───────────────────────────────────────────── + +func TestWSClient_ConcurrentSend(t *testing.T) { + var reqCount atomic.Int32 + + srv := newTestWSServer(t, func(conn *websocket.Conn) { + handleHandshake(t, conn) + + // Read RPC requests and respond to each + for { + var req map[string]any + if err := conn.ReadJSON(&req); err != nil { + break + } + reqID, _ := req["id"].(string) + n := reqCount.Add(1) + + res := map[string]any{ + "type": "res", + "id": reqID, + "ok": true, + "result": map[string]any{"index": n, "method": req["method"]}, + } + if err := conn.WriteJSON(res); err != nil { + break + } + } + }) + defer srv.Close() + + client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default()) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + go client.Start(ctx) + + // Give the client time to complete handshake + time.Sleep(300 * time.Millisecond) + + // Fire 3 concurrent Send() calls + type sendResult struct { + method string + payload json.RawMessage + err error + } + results := make(chan sendResult, 3) + + methods := []string{"agents.list", "sessions.list", "agents.config"} + for _, method := range methods { + go func(m string) { + resp, err := client.Send(m, nil) + results <- sendResult{method: m, payload: resp, err: err} + }(method) + } + + // Collect all results + for i := 0; i < 3; i++ { + select { + case r := <-results: + if r.err != nil { + t.Errorf("Send(%q) returned error: %v", r.method, r.err) + continue + } + var result map[string]any + if err := json.Unmarshal(r.payload, &result); err != nil { + t.Errorf("Send(%q) unmarshal error: %v", r.method, err) + continue + } + gotMethod, _ := result["method"].(string) + if gotMethod != r.method { + t.Errorf("Send(%q) got response for %q (mismatched)", r.method, gotMethod) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for concurrent Send results") + } + } + + cancel() +} + +// ── 5. Test: Clean shutdown ────────────────────────────────────────────── + +func TestWSClient_CleanShutdown(t *testing.T) { + srv := newTestWSServer(t, func(conn *websocket.Conn) { + handleHandshake(t, conn) + keepAlive(conn) + }) + defer srv.Close() + + client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + + done := make(chan struct{}) + go func() { + client.Start(ctx) + close(done) + }() + + // Let the client connect and complete handshake + time.Sleep(200 * time.Millisecond) + + // Cancel context — should trigger clean shutdown + cancel() + + select { + case <-done: + // Client exited cleanly — pass + case <-time.After(3 * time.Second): + t.Fatal("WSClient did not shut down cleanly within timeout") + } +} + +// ── Pure utility tests (from CUB-205) ───────────────────────────────────── + +func TestMapSessionStatus(t *testing.T) { + tests := []struct { + input string + expected models.AgentStatus + }{ + {"running", models.AgentStatusActive}, + {"streaming", models.AgentStatusActive}, + {"done", models.AgentStatusIdle}, + {"error", models.AgentStatusError}, + {"", models.AgentStatusIdle}, + {"garbage", models.AgentStatusIdle}, + } + for _, tt := range tests { + result := mapSessionStatus(tt.input) + if result != tt.expected { + t.Errorf("mapSessionStatus(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestAgentItemToCard(t *testing.T) { + t.Run("full fields", func(t *testing.T) { + item := agentListItem{ + ID: "dex", + Name: "Dex", + Role: "backend", + Channel: "telegram", + } + card := agentItemToCard(item) + if card.ID != "dex" { + t.Errorf("ID = %q, want %q", card.ID, "dex") + } + if card.DisplayName != "Dex" { + t.Errorf("DisplayName = %q, want %q", card.DisplayName, "Dex") + } + if card.Role != "backend" { + t.Errorf("Role = %q, want %q", card.Role, "backend") + } + if card.Channel != "telegram" { + t.Errorf("Channel = %q, want %q", card.Channel, "telegram") + } + if card.Status != models.AgentStatusIdle { + t.Errorf("Status = %q, want %q", card.Status, models.AgentStatusIdle) + } + }) + + t.Run("empty fields use defaults", func(t *testing.T) { + item := agentListItem{ + ID: "otto", + } + card := agentItemToCard(item) + if card.ID != "otto" { + t.Errorf("ID = %q, want %q", card.ID, "otto") + } + if card.DisplayName != "otto" { + t.Errorf("DisplayName = %q, want %q (should fallback to ID)", card.DisplayName, "otto") + } + if card.Role != "agent" { + t.Errorf("Role = %q, want %q (default)", card.Role, "agent") + } + if card.Channel != "unknown" { + t.Errorf("Channel = %q, want %q (per Grimm requirement)", card.Channel, "unknown") + } + if card.Status != models.AgentStatusIdle { + t.Errorf("Status = %q, want %q", card.Status, models.AgentStatusIdle) + } + }) + + t.Run("empty name falls back to ID", func(t *testing.T) { + item := agentListItem{ + ID: "hex", + Name: "", + Role: "database", + } + card := agentItemToCard(item) + if card.DisplayName != "hex" { + t.Errorf("DisplayName = %q, want %q (ID fallback)", card.DisplayName, "hex") + } + }) +} + +func TestStrPtr(t *testing.T) { + s := "hello" + p := strPtr(s) + if p == nil { + t.Fatal("strPtr returned nil") + } + if *p != s { + t.Errorf("strPtr(%q) = %q, want %q", s, *p, s) + } + + empty := "" + ep := strPtr(empty) + if *ep != empty { + t.Errorf("strPtr(empty) = %q, want %q", *ep, empty) + } +} \ No newline at end of file diff --git a/go-backend/internal/models/models.go b/go-backend/internal/models/models.go index 8480b41..2844cff 100644 --- a/go-backend/internal/models/models.go +++ b/go-backend/internal/models/models.go @@ -63,12 +63,15 @@ type CreateAgentRequest struct { // UpdateAgentRequest is the payload for PUT /api/agents/{id}. type UpdateAgentRequest struct { - Status *AgentStatus `json:"status,omitempty" validate:"omitempty,agentStatus"` - CurrentTask *string `json:"currentTask,omitempty"` - TaskProgress *int `json:"taskProgress,omitempty" validate:"omitempty,min=0,max=100"` - TaskElapsed *string `json:"taskElapsed,omitempty"` - Channel *string `json:"channel,omitempty" validate:"omitempty,min=1,max=32"` - ErrorMessage *string `json:"errorMessage,omitempty"` + Status *AgentStatus `json:"status,omitempty" validate:"omitempty,agentStatus"` + DisplayName *string `json:"displayName,omitempty"` + Role *string `json:"role,omitempty"` + LastActivityAt *string `json:"lastActivityAt,omitempty"` + CurrentTask *string `json:"currentTask,omitempty"` + TaskProgress *int `json:"taskProgress,omitempty" validate:"omitempty,min=0,max=100"` + TaskElapsed *string `json:"taskElapsed,omitempty"` + Channel *string `json:"channel,omitempty" validate:"omitempty,min=1,max=32"` + ErrorMessage *string `json:"errorMessage,omitempty"` } // AgentStatusHistoryEntry represents a point-in-time status change for an agent. diff --git a/reference/CONTROL_CENTER_CONTEXT.md b/reference/CONTROL_CENTER_CONTEXT.md new file mode 100644 index 0000000..81bee91 --- /dev/null +++ b/reference/CONTROL_CENTER_CONTEXT.md @@ -0,0 +1,46 @@ +# Control Center — Architecture Context + +## Current State + +The Control Center backend uses a **dual-path gateway client** architecture: + +- **Primary path**: WebSocket client (`gateway.WSClient`) connects to the OpenClaw gateway using WS protocol v3. It handles handshake, initial sync (agents.list + sessions.list RPCs), live event routing (sessions.changed, presence, agent.config), and automatic reconnection with exponential backoff. +- **Fallback path**: REST poller (`gateway.Client`) polls the gateway `/api/agents` endpoint on an interval. It only activates if the WS client fails to connect within 30 seconds of startup. + +## Live Gateway Connection + +### Startup Sequence +1. Both WS client and REST client start concurrently +2. REST client waits 30s for WS readiness signal (`wsReady` channel) +3. If WS connects successfully → REST client stands down (logs "using WS — REST poller standing down") +4. If WS fails within 30s → REST client falls back to polling (logs "WS not ready — falling back to REST polling") +5. If no WS client configured → REST client polls immediately + +### WebSocket Client (Primary) +- Config: `WS_GATEWAY_URL` (default: `ws://host.docker.internal:18789/`), `OPENCLAW_GATEWAY_TOKEN` +- Protocol: v3 handshake (challenge → connect → hello-ok) +- Initial sync: `agents.list` + `sessions.list` RPCs → persist → merge → broadcast `fleet.update` +- Live events: `sessions.changed`, `presence`, `agent.config` +- Reconnection: exponential backoff (1s → 2s → 4s → ... → 30s max) + +### REST Poller (Fallback) +- Config: `GATEWAY_URL` (default: `http://host.docker.internal:18789/api/agents`), `GATEWAY_POLL_INTERVAL` (default: 5s) +- Only used when WS is unavailable +- Polls the `/api/agents` endpoint and syncs agent status changes + +### Wiring +``` +main.go + ├── wsClient = NewWSClient(...) + ├── restClient = NewClient(...) + ├── wsClient.SetRESTClient(restClient) // WS notifies REST on ready + ├── restClient.SetWSClient(wsClient) // REST defers to WS + ├── go wsClient.Start(ctx) // primary + └── go restClient.Start(ctx) // fallback (waits for WS) +``` + +## Key Design Decisions +- **Push over poll**: WS is preferred for real-time updates; REST is a safety net +- **DB first, then SSE**: All event handlers persist to DB before broadcasting +- **Graceful degradation**: System works without WS; REST provides basic functionality +- **No hard dependency on REST /api/agents**: If WS is connected, REST endpoint is never called \ No newline at end of file