mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
Refactor Studio around domain control-plane architecture
This commit is contained in:
+1
-11
@@ -1,15 +1,8 @@
|
||||
# Optional overrides for local dev.
|
||||
# Default behavior uses ~/.openclaw and falls back to ~/.moltbot or ~/.clawdbot.
|
||||
# Default behavior uses ~/.openclaw.
|
||||
|
||||
# Point to a specific state directory.
|
||||
OPENCLAW_STATE_DIR=/Users/yourname/.openclaw
|
||||
# MOLTBOT_STATE_DIR=/Users/yourname/.moltbot
|
||||
# CLAWDBOT_STATE_DIR=/Users/yourname/.clawdbot
|
||||
|
||||
# Point to a specific config file.
|
||||
OPENCLAW_CONFIG_PATH=/Users/yourname/.openclaw/openclaw.json
|
||||
# MOLTBOT_CONFIG_PATH=/Users/yourname/.moltbot/moltbot.json
|
||||
# CLAWDBOT_CONFIG_PATH=/Users/yourname/.clawdbot/clawdbot.json
|
||||
|
||||
# Default upstream gateway URL for the Studio host (used when Studio settings are missing).
|
||||
# Defaults to ws://127.0.0.1:18789
|
||||
@@ -21,6 +14,3 @@ OPENCLAW_GATEWAY_SSH_TARGET=
|
||||
|
||||
# Optional: SSH user when SSH target is derived from the gateway URL (default: ubuntu).
|
||||
OPENCLAW_GATEWAY_SSH_USER=
|
||||
|
||||
# Optional: which existing agent to copy auth profiles from when creating tiles.
|
||||
CLAWDBOT_DEFAULT_AGENT_ID=main
|
||||
|
||||
@@ -60,10 +60,6 @@ test-results
|
||||
/.agent/*.local.md
|
||||
/.agent/future-plans
|
||||
/.openclaw
|
||||
/.clawdbot
|
||||
/.moltbot
|
||||
/agent-canvas
|
||||
/worktrees
|
||||
/.claude
|
||||
|
||||
# local issue tracker
|
||||
|
||||
+5
-5
@@ -51,7 +51,7 @@ This keeps feature cohesion high while preserving a clear client/server boundary
|
||||
|
||||
## Data flow & key boundaries
|
||||
### 1) Studio settings + focused preferences
|
||||
- **Source of truth**: JSON settings file at `~/.openclaw/openclaw-studio/settings.json` (resolved via `resolveStateDir`, with legacy fallbacks in `src/lib/clawdbot/paths.ts`). Settings store the gateway URL/token plus per-gateway focused preferences.
|
||||
- **Source of truth**: JSON settings file at `~/.openclaw/openclaw-studio/settings.json` (resolved via `resolveStateDir` in `src/lib/clawdbot/paths.ts`). Settings store the gateway URL/token plus per-gateway focused preferences.
|
||||
- **Server boundary**: `src/app/api/studio/route.ts` loads/saves settings by reading and writing `openclaw-studio/settings.json` under the resolved state dir.
|
||||
- **Client boundary**: `useGatewayConnection` and focused/session flows in `src/app/page.tsx` use a shared `StudioSettingsCoordinator` to load settings and coalesce debounced `/api/studio` patch writes.
|
||||
|
||||
@@ -98,7 +98,7 @@ Flow:
|
||||
- **Transport boundary**: `syncGatewaySessionSettings` in `src/lib/gateway/GatewayClient.ts` is the only client-side builder/invoker for `sessions.patch` payloads.
|
||||
|
||||
## Cross-cutting concerns
|
||||
- **Configuration**: environment variables are read directly from `process.env`. The browser uses `NEXT_PUBLIC_GATEWAY_URL` only as a default upstream URL when Studio settings are missing; the Studio server persists upstream URL/token in `<state dir>/openclaw-studio/settings.json` and the WS proxy loads them via `server/studio-settings.js`. State/config path resolution lives in `lib/clawdbot/paths.ts`, honoring `OPENCLAW_STATE_DIR`/`OPENCLAW_CONFIG_PATH` with legacy fallbacks. When Studio token is missing, settings loaders can fall back to token/port from `<state dir>/openclaw.json`. Loopback-IP gateway URLs are normalized to `localhost` in Studio settings, and the WS proxy rewrites loopback upstream origins to `localhost` for control-UI secure-context compatibility. The optional Studio access gate is enabled by `STUDIO_ACCESS_TOKEN` (`server/access-gate.js`).
|
||||
- **Configuration**: environment variables are read directly from `process.env`. The browser uses `NEXT_PUBLIC_GATEWAY_URL` only as a default upstream URL when Studio settings are missing; the Studio server persists upstream URL/token in `<state dir>/openclaw-studio/settings.json` and the WS proxy loads them via `server/studio-settings.js`. State path resolution lives in `lib/clawdbot/paths.ts`, honoring `OPENCLAW_STATE_DIR`. When Studio token is missing, settings loaders can fall back to token/port from `<state dir>/openclaw.json`. Loopback-IP gateway URLs are normalized to `localhost` in Studio settings, and the WS proxy rewrites loopback upstream origins to `localhost` for control-UI secure-context compatibility. The optional Studio access gate is enabled by `STUDIO_ACCESS_TOKEN` (`server/access-gate.js`).
|
||||
- **Testing**: Playwright e2e runs Studio with an isolated `OPENCLAW_STATE_DIR` so the Studio WS proxy does not read real upstream gateway settings from the developer machine.
|
||||
- **Logging**: API routes and the gateway client use built-in `console.*` logging.
|
||||
- **Error handling**:
|
||||
@@ -108,7 +108,7 @@ Flow:
|
||||
- Gateway connect failures with `INVALID_REQUEST: invalid config` surface a doctor hint in Studio (`npx openclaw doctor --fix` / `pnpm openclaw doctor --fix`).
|
||||
- Gateway connect failures that close with `connect failed: <CODE> ...` are preserved as `GatewayResponseError` codes so auto-retry gating can be code-driven (instead of message-driven).
|
||||
- Gateway browser client truncates close reasons to WebSocket protocol limits (123 UTF-8 bytes) to avoid client-side close exceptions on long error messages.
|
||||
- **Filesystem helpers**: server-only filesystem operations live at the API route boundaries. Home-scoped path autocomplete is implemented directly in `src/app/api/path-suggestions/route.ts`. These helpers are used for local settings and path suggestions, not for agent file edits.
|
||||
- **Filesystem helpers**: server-only filesystem operations live at the API route boundaries. These helpers are used for local settings and gateway-adjacent file operations, not for agent file edits.
|
||||
- **Remote gateway tools over SSH**: some server routes execute small scripts on the gateway host (for example agent-state operations and remote media reads). Shared helpers in `src/lib/ssh/gateway-host.ts` own SSH invocation and JSON parsing so routes do not hand-roll `spawnSync` error handling.
|
||||
- **Tracing**: `src/instrumentation.ts` registers `@vercel/otel` for telemetry.
|
||||
- **Validation**: request payload validation in API routes and typed client/server helpers in `src/lib/*`.
|
||||
@@ -159,12 +159,12 @@ C4Container
|
||||
|
||||
Container_Boundary(app, "Next.js App") {
|
||||
Container(client, "Client UI", "React", "Focused agent-management UI, state, gateway client")
|
||||
Container(api, "API Routes", "Next.js route handlers", "Studio settings, path suggestions, gateway-host state tools")
|
||||
Container(api, "API Routes", "Next.js route handlers", "Studio settings and gateway-host state tools")
|
||||
Container(proxy, "WS Proxy", "Custom Node server", "Bridges /api/gateway/ws to upstream gateway with token injection")
|
||||
}
|
||||
|
||||
Container_Ext(gateway, "Gateway", "WebSocket", "Agent runtime")
|
||||
Container_Ext(fs, "Filesystem", "Local", "settings.json and other local reads (e.g. path suggestions)")
|
||||
Container_Ext(fs, "Filesystem", "Local", "settings.json and other local reads")
|
||||
|
||||
Rel(user, client, "Uses")
|
||||
Rel(client, api, "HTTP JSON")
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Thanks for helping improve OpenClaw Studio.
|
||||
|
||||
## Before you start
|
||||
- Install OpenClaw and confirm the gateway runs locally.
|
||||
- This repo is UI-only and reads config from `~/.openclaw` with legacy fallback to `~/.moltbot` or `~/.clawdbot`.
|
||||
- This repo is UI-only and reads config from `~/.openclaw`.
|
||||
- It does not run or build the gateway from source.
|
||||
|
||||
## Local setup
|
||||
|
||||
@@ -97,7 +97,7 @@ npm run dev
|
||||
## Configuration
|
||||
|
||||
Paths and key settings:
|
||||
- OpenClaw config: `~/.openclaw/openclaw.json` (or `OPENCLAW_CONFIG_PATH` / `OPENCLAW_STATE_DIR`)
|
||||
- OpenClaw config: `~/.openclaw/openclaw.json` (or via `OPENCLAW_STATE_DIR`)
|
||||
- Studio settings: `~/.openclaw/openclaw-studio/settings.json`
|
||||
- Default gateway URL: `ws://localhost:18789` (override via Studio Settings or `NEXT_PUBLIC_GATEWAY_URL`)
|
||||
- `STUDIO_ACCESS_TOKEN`: required when binding Studio to a public host (`HOST=0.0.0.0`, `HOST=::`, or non-loopback hostnames/IPs); optional for loopback-only binds (`127.0.0.1`, `::1`, `localhost`)
|
||||
|
||||
@@ -27,10 +27,10 @@ Non-scope:
|
||||
Studio vendors the browser Gateway client used to speak the Gateway protocol:
|
||||
- Vendored client: `src/lib/gateway/openclaw/GatewayBrowserClient.ts`
|
||||
- Sync script: `scripts/sync-openclaw-gateway-client.ts`
|
||||
- Current sync source path used by that script: `~/clawdbot/ui/src/ui/gateway.ts`
|
||||
- Current sync source path used by that script: `~/openclaw/ui/src/ui/gateway.ts`
|
||||
|
||||
Important:
|
||||
- Studio does not currently sync `GatewayBrowserClient.ts` directly from `~/openclaw`.
|
||||
- Studio syncs `GatewayBrowserClient.ts` from `~/openclaw` via the sync script above.
|
||||
- If protocol mismatch is suspected, first verify the sync source file and the upstream Gateway runtime/protocol files are aligned.
|
||||
|
||||
If a protocol mismatch is suspected (missing event fields, renamed streams, different error codes), start by checking whether Studio’s vendored client is in sync with the Gateway version you’re running.
|
||||
|
||||
Generated
+441
-90
@@ -10,16 +10,14 @@
|
||||
"dependencies": {
|
||||
"@multiavatar/multiavatar": "github:multiavatar/Multiavatar",
|
||||
"@noble/ed25519": "^3.0.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@vercel/otel": "^2.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-mentions-ts": "^5.4.7",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"ws": "^8.18.3"
|
||||
@@ -29,6 +27,7 @@
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -37,9 +36,7 @@
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"jsdom": "^27.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
@@ -2085,39 +2082,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz",
|
||||
@@ -2868,6 +2832,16 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -4009,6 +3983,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.17",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz",
|
||||
@@ -4018,6 +4012,20 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.6.2",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
|
||||
"integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
@@ -4028,6 +4036,26 @@
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
@@ -4086,6 +4114,30 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
@@ -4243,6 +4295,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/cjs-module-lexer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
|
||||
@@ -4250,18 +4308,6 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
@@ -4511,6 +4557,30 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -4567,7 +4637,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -4636,6 +4705,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.18.4",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
||||
@@ -5372,6 +5450,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -5462,6 +5549,12 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -5529,11 +5622,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -5675,6 +5773,12 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -5940,6 +6044,26 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -6000,6 +6124,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inline-style-parser": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||
@@ -7951,6 +8087,18 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
@@ -7978,12 +8126,17 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/module-details-from-path": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
|
||||
@@ -8015,6 +8168,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/napi-postinstall": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
|
||||
@@ -8119,6 +8278,30 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.87.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
|
||||
"integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -8260,6 +8443,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -8503,6 +8695,33 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -8513,22 +8732,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
@@ -8589,6 +8792,16 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
||||
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -8620,6 +8833,30 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/rc/node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
@@ -8675,20 +8912,18 @@
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-mentions-ts": {
|
||||
"version": "5.4.7",
|
||||
"resolved": "https://registry.npmjs.org/react-mentions-ts/-/react-mentions-ts-5.4.7.tgz",
|
||||
"integrity": "sha512-bTK6joPmyvLckVf1v7vE2xSSeqvL4ZwuzFvGZpt+IrtdDOdFZjiwTUBo5920kiE6WbH/v1PP81xAo6pQ0NQ0Pg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"class-variance-authority": ">=0.6.0",
|
||||
"clsx": ">=2.0.0",
|
||||
"react": ">=19.0.0",
|
||||
"react-dom": ">=19.0.0",
|
||||
"tailwind-merge": ">=3.0.0"
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
@@ -8980,6 +9215,26 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-push-apply": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||
@@ -9257,6 +9512,51 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -9311,6 +9611,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string.prototype.includes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||
@@ -9579,6 +9888,34 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -9778,14 +10115,16 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tw-animate-css": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
|
||||
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Wombosvideo"
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
@@ -10106,6 +10445,12 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
@@ -10525,6 +10870,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
|
||||
+2
-6
@@ -10,7 +10,6 @@
|
||||
"lint": "eslint .",
|
||||
"cleanup:ux-artifacts": "node scripts/cleanup-ux-artifacts.mjs",
|
||||
"sync:gateway-client": "node scripts/sync-openclaw-gateway-client.ts",
|
||||
"migrate:architecture": "node scripts/migrate-architecture.ts",
|
||||
"studio:setup": "node scripts/studio-setup.js",
|
||||
"smoke:dev-server": "node scripts/smoke-dev-server.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -21,16 +20,14 @@
|
||||
"dependencies": {
|
||||
"@multiavatar/multiavatar": "github:multiavatar/Multiavatar",
|
||||
"@noble/ed25519": "^3.0.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@vercel/otel": "^2.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-mentions-ts": "^5.4.7",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"ws": "^8.18.3"
|
||||
@@ -40,6 +37,7 @@
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -48,9 +46,7 @@
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"jsdom": "^27.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const WORKSPACE_FILE_NAMES = [
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"IDENTITY.md",
|
||||
"USER.md",
|
||||
"HEARTBEAT.md",
|
||||
"TOOLS.md",
|
||||
"MEMORY.md",
|
||||
];
|
||||
const WORKSPACE_IGNORE_ENTRIES = [...WORKSPACE_FILE_NAMES, "memory/"];
|
||||
const STORE_VERSION = 3;
|
||||
const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moltbot"];
|
||||
const NEW_STATE_DIRNAME = ".openclaw";
|
||||
const CONFIG_FILENAME = "openclaw.json";
|
||||
const LEGACY_CONFIG_FILENAMES = ["clawdbot.json", "moltbot.json"];
|
||||
|
||||
const resolveUserPath = (input: string, homedir: () => string = os.homedir) => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
if (trimmed.startsWith("~")) {
|
||||
const expanded = trimmed.replace(/^~(?=$|[\\/])/, homedir());
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
};
|
||||
|
||||
const resolveStateDir = (env = process.env, homedir = os.homedir) => {
|
||||
const override =
|
||||
env.OPENCLAW_STATE_DIR?.trim() ||
|
||||
env.MOLTBOT_STATE_DIR?.trim() ||
|
||||
env.CLAWDBOT_STATE_DIR?.trim();
|
||||
if (override) return resolveUserPath(override, homedir);
|
||||
const newDir = path.join(homedir(), NEW_STATE_DIRNAME);
|
||||
const legacyDirs = LEGACY_STATE_DIRNAMES.map((dir) => path.join(homedir(), dir));
|
||||
if (fs.existsSync(newDir)) return newDir;
|
||||
const existingLegacy = legacyDirs.find((dir) => {
|
||||
try {
|
||||
return fs.existsSync(dir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return existingLegacy ?? newDir;
|
||||
};
|
||||
|
||||
const resolveConfigPathCandidates = (env = process.env, homedir = os.homedir) => {
|
||||
const explicit =
|
||||
env.OPENCLAW_CONFIG_PATH?.trim() ||
|
||||
env.MOLTBOT_CONFIG_PATH?.trim() ||
|
||||
env.CLAWDBOT_CONFIG_PATH?.trim();
|
||||
if (explicit) return [resolveUserPath(explicit, homedir)];
|
||||
|
||||
const candidates = [];
|
||||
const stateDir =
|
||||
env.OPENCLAW_STATE_DIR?.trim() ||
|
||||
env.MOLTBOT_STATE_DIR?.trim() ||
|
||||
env.CLAWDBOT_STATE_DIR?.trim();
|
||||
if (stateDir) {
|
||||
const resolved = resolveUserPath(stateDir, homedir);
|
||||
candidates.push(path.join(resolved, CONFIG_FILENAME));
|
||||
candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(resolved, name)));
|
||||
}
|
||||
|
||||
const defaultDirs = [
|
||||
path.join(homedir(), NEW_STATE_DIRNAME),
|
||||
...LEGACY_STATE_DIRNAMES.map((dir) => path.join(homedir(), dir)),
|
||||
];
|
||||
for (const dir of defaultDirs) {
|
||||
candidates.push(path.join(dir, CONFIG_FILENAME));
|
||||
candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(dir, name)));
|
||||
}
|
||||
return candidates;
|
||||
};
|
||||
|
||||
const resolveAgentCanvasDir = () => path.join(resolveStateDir(), "openclaw-studio");
|
||||
|
||||
const resolveAgentWorktreeDir = (projectId: string, agentId: string) =>
|
||||
path.join(resolveAgentCanvasDir(), "worktrees", projectId, agentId);
|
||||
|
||||
const parseAgentIdFromSessionKey = (sessionKey: string, fallback = "main") => {
|
||||
const match = sessionKey.match(/^agent:([^:]+):/);
|
||||
return match ? match[1] : fallback;
|
||||
};
|
||||
|
||||
const parseJsonLoose = (raw: string) => {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
const cleaned = raw.replace(/,(\s*[}\]])/g, "$1");
|
||||
return JSON.parse(cleaned);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStore = (storePath: string) => {
|
||||
const raw = fs.readFileSync(storePath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || !Array.isArray(parsed.projects)) {
|
||||
throw new Error(`Workspaces store is invalid at ${storePath}.`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const normalizeStore = (store: { projects?: unknown[]; activeProjectId?: unknown }) => {
|
||||
const projects = Array.isArray(store.projects)
|
||||
? (store.projects as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const normalizedProjects = projects.map((project) => {
|
||||
const projectId = typeof project.id === "string" ? project.id : "";
|
||||
const tiles = Array.isArray(project.tiles) ? project.tiles : [];
|
||||
return {
|
||||
id: projectId,
|
||||
name: typeof project.name === "string" ? project.name : "",
|
||||
repoPath: typeof project.repoPath === "string" ? project.repoPath : "",
|
||||
createdAt: typeof project.createdAt === "number" ? project.createdAt : Date.now(),
|
||||
updatedAt: typeof project.updatedAt === "number" ? project.updatedAt : Date.now(),
|
||||
archivedAt: typeof project.archivedAt === "number" ? project.archivedAt : null,
|
||||
tiles: tiles.map((tile) => {
|
||||
const agentId =
|
||||
typeof tile.agentId === "string" && tile.agentId.trim()
|
||||
? tile.agentId.trim()
|
||||
: parseAgentIdFromSessionKey(
|
||||
typeof tile.sessionKey === "string" ? tile.sessionKey : ""
|
||||
);
|
||||
return {
|
||||
...tile,
|
||||
agentId,
|
||||
role: typeof tile.role === "string" ? tile.role : "coding",
|
||||
workspacePath:
|
||||
typeof tile.workspacePath === "string" && tile.workspacePath.trim()
|
||||
? tile.workspacePath
|
||||
: resolveAgentWorktreeDir(projectId, agentId),
|
||||
archivedAt: typeof tile.archivedAt === "number" ? tile.archivedAt : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
const activeProjectId =
|
||||
typeof store.activeProjectId === "string" &&
|
||||
normalizedProjects.some(
|
||||
(project) => project.id === store.activeProjectId && !project.archivedAt
|
||||
)
|
||||
? store.activeProjectId
|
||||
: normalizedProjects.find((project) => !project.archivedAt)?.id ?? null;
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
activeProjectId,
|
||||
projects: normalizedProjects,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveGitDir = (worktreeDir: string) => {
|
||||
const gitPath = path.join(worktreeDir, ".git");
|
||||
const stat = fs.statSync(gitPath);
|
||||
if (stat.isDirectory()) {
|
||||
return gitPath;
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`.git is not a file or directory at ${gitPath}`);
|
||||
}
|
||||
const raw = fs.readFileSync(gitPath, "utf8");
|
||||
const match = raw.trim().match(/^gitdir:\s*(.+)$/i);
|
||||
if (!match || !match[1]) {
|
||||
throw new Error(`Unable to resolve gitdir from ${gitPath}`);
|
||||
}
|
||||
return path.resolve(worktreeDir, match[1].trim());
|
||||
};
|
||||
|
||||
const ensureWorktreeIgnores = (worktreeDir: string, files: string[]) => {
|
||||
if (files.length === 0) return;
|
||||
const gitDir = resolveGitDir(worktreeDir);
|
||||
const infoDir = path.join(gitDir, "info");
|
||||
fs.mkdirSync(infoDir, { recursive: true });
|
||||
const excludePath = path.join(infoDir, "exclude");
|
||||
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
|
||||
const lines = existing.split(/\r?\n/);
|
||||
const additions = files.filter((entry) => !lines.includes(entry));
|
||||
if (additions.length === 0) return;
|
||||
let next = existing;
|
||||
if (next.length > 0 && !next.endsWith("\n")) {
|
||||
next += "\n";
|
||||
}
|
||||
next += `${additions.join("\n")}\n`;
|
||||
fs.writeFileSync(excludePath, next, "utf8");
|
||||
};
|
||||
|
||||
const ensureAgentWorktree = (repoPath: string, worktreeDir: string, branchName: string) => {
|
||||
const trimmedRepo = repoPath.trim();
|
||||
if (!trimmedRepo) {
|
||||
throw new Error("Repository path is required.");
|
||||
}
|
||||
if (!fs.existsSync(trimmedRepo)) {
|
||||
throw new Error(`Repository path does not exist: ${trimmedRepo}`);
|
||||
}
|
||||
const repoStat = fs.statSync(trimmedRepo);
|
||||
if (!repoStat.isDirectory()) {
|
||||
throw new Error(`Repository path is not a directory: ${trimmedRepo}`);
|
||||
}
|
||||
if (!fs.existsSync(path.join(trimmedRepo, ".git"))) {
|
||||
throw new Error(`Repository is missing a .git directory: ${trimmedRepo}`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(worktreeDir)) {
|
||||
const stat = fs.statSync(worktreeDir);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Worktree path is not a directory: ${worktreeDir}`);
|
||||
}
|
||||
if (!fs.existsSync(path.join(worktreeDir, ".git"))) {
|
||||
throw new Error(`Existing worktree is missing .git at ${worktreeDir}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(worktreeDir), { recursive: true });
|
||||
const branchCheck = spawnSync("git", ["rev-parse", "--verify", branchName], {
|
||||
cwd: trimmedRepo,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const args =
|
||||
branchCheck.status === 0
|
||||
? ["worktree", "add", worktreeDir, branchName]
|
||||
: ["worktree", "add", "-b", branchName, worktreeDir];
|
||||
const result = spawnSync("git", args, { cwd: trimmedRepo, encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.trim();
|
||||
throw new Error(
|
||||
stderr
|
||||
? `git worktree add failed for ${worktreeDir}: ${stderr}`
|
||||
: `git worktree add failed for ${worktreeDir}.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureWorkspaceFiles = (workspaceDir: string) => {
|
||||
fs.mkdirSync(workspaceDir, { recursive: true });
|
||||
for (const name of WORKSPACE_FILE_NAMES) {
|
||||
const filePath = path.join(workspaceDir, name);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, "", "utf8");
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(path.join(workspaceDir, "memory"), { recursive: true });
|
||||
};
|
||||
|
||||
const copyWorkspaceFile = (fromPath: string, toPath: string) => {
|
||||
if (!fs.existsSync(fromPath)) return false;
|
||||
if (fs.existsSync(toPath)) {
|
||||
const current = fs.readFileSync(toPath, "utf8");
|
||||
if (current.trim()) return false;
|
||||
}
|
||||
fs.copyFileSync(fromPath, toPath);
|
||||
return true;
|
||||
};
|
||||
|
||||
const copyWorkspaceMemory = (fromDir: string, toDir: string) => {
|
||||
if (!fs.existsSync(fromDir)) return 0;
|
||||
fs.mkdirSync(toDir, { recursive: true });
|
||||
let copied = 0;
|
||||
for (const entry of fs.readdirSync(fromDir, { withFileTypes: true })) {
|
||||
const fromPath = path.join(fromDir, entry.name);
|
||||
const toPath = path.join(toDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
copied += copyWorkspaceMemory(fromPath, toPath);
|
||||
} else if (!fs.existsSync(toPath)) {
|
||||
fs.copyFileSync(fromPath, toPath);
|
||||
copied += 1;
|
||||
}
|
||||
}
|
||||
return copied;
|
||||
};
|
||||
|
||||
const reserveLegacyPath = (targetPath: string) => {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
let candidate = `${targetPath}.legacy-${stamp}`;
|
||||
let suffix = 1;
|
||||
while (fs.existsSync(candidate)) {
|
||||
candidate = `${targetPath}.legacy-${stamp}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const ensureLegacyWorktreeSlot = (worktreeDir: string) => {
|
||||
if (!fs.existsSync(worktreeDir)) return null;
|
||||
if (fs.existsSync(path.join(worktreeDir, ".git"))) return null;
|
||||
const legacyPath = reserveLegacyPath(worktreeDir);
|
||||
fs.renameSync(worktreeDir, legacyPath);
|
||||
return legacyPath;
|
||||
};
|
||||
|
||||
const readAgentList = (config: {
|
||||
agents?: { list?: Array<{ id?: string; name?: string; workspace?: string }> };
|
||||
}) => {
|
||||
const agents = config.agents ?? {};
|
||||
const list = Array.isArray(agents.list) ? agents.list : [];
|
||||
return list.filter((entry) => Boolean(entry && typeof entry === "object"));
|
||||
};
|
||||
|
||||
const writeAgentList = (
|
||||
config: { agents?: { list?: Array<{ id?: string; name?: string; workspace?: string }> } },
|
||||
list: Array<{ id?: string; name?: string; workspace?: string }>
|
||||
) => {
|
||||
const agents = config.agents ?? {};
|
||||
agents.list = list;
|
||||
config.agents = agents;
|
||||
};
|
||||
|
||||
const upsertAgentEntry = (
|
||||
config: { agents?: { list?: Array<{ id?: string; name?: string; workspace?: string }> } },
|
||||
entry: { agentId: string; agentName: string; workspaceDir: string }
|
||||
) => {
|
||||
const list = readAgentList(config);
|
||||
let changed = false;
|
||||
let found = false;
|
||||
const next = list.map((item) => {
|
||||
if (item.id !== entry.agentId) return item;
|
||||
found = true;
|
||||
const nextItem = { ...item };
|
||||
if (entry.agentName && entry.agentName !== item.name) {
|
||||
nextItem.name = entry.agentName;
|
||||
changed = true;
|
||||
}
|
||||
if (entry.workspaceDir !== item.workspace) {
|
||||
nextItem.workspace = entry.workspaceDir;
|
||||
changed = true;
|
||||
}
|
||||
return nextItem;
|
||||
});
|
||||
if (!found) {
|
||||
next.push({ id: entry.agentId, name: entry.agentName, workspace: entry.workspaceDir });
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
writeAgentList(config, next);
|
||||
}
|
||||
return changed;
|
||||
};
|
||||
|
||||
const loadClawdbotConfig = () => {
|
||||
const candidates = resolveConfigPathCandidates();
|
||||
const fallbackPath = path.join(resolveStateDir(), CONFIG_FILENAME);
|
||||
const configPath = candidates.find((candidate) => fs.existsSync(candidate)) ?? fallbackPath;
|
||||
if (!fs.existsSync(configPath)) {
|
||||
throw new Error(`Missing config at ${configPath}.`);
|
||||
}
|
||||
const raw = fs.readFileSync(configPath, "utf8");
|
||||
return { config: parseJsonLoose(raw), configPath };
|
||||
};
|
||||
|
||||
const saveClawdbotConfig = (configPath: string, config: unknown) => {
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
|
||||
};
|
||||
|
||||
const migrate = () => {
|
||||
const stateDir = resolveStateDir();
|
||||
const storePath = path.join(stateDir, "openclaw-studio", "projects.json");
|
||||
if (!fs.existsSync(storePath)) {
|
||||
console.error(`Missing projects store at ${storePath}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rawStore = loadStore(storePath);
|
||||
const store = normalizeStore(rawStore);
|
||||
|
||||
const backupPath = `${storePath}.backup-${Date.now()}`;
|
||||
fs.copyFileSync(storePath, backupPath);
|
||||
|
||||
const warnings = [];
|
||||
const errors = [];
|
||||
const legacyMoves = [];
|
||||
|
||||
let config = null;
|
||||
let configPath = "";
|
||||
let configLoaded = false;
|
||||
try {
|
||||
const loaded = loadClawdbotConfig();
|
||||
config = loaded.config;
|
||||
configPath = loaded.configPath;
|
||||
configLoaded = true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load config.";
|
||||
warnings.push(`Agent config not updated: ${message}`);
|
||||
}
|
||||
|
||||
for (const project of store.projects) {
|
||||
const repoPath = typeof project.repoPath === "string" ? project.repoPath : "";
|
||||
for (const tile of project.tiles) {
|
||||
const agentId =
|
||||
typeof tile.agentId === "string" && tile.agentId.trim()
|
||||
? tile.agentId.trim()
|
||||
: parseAgentIdFromSessionKey(tile.sessionKey ?? "");
|
||||
const worktreeDir = resolveAgentWorktreeDir(project.id, agentId);
|
||||
if (tile.workspacePath !== worktreeDir) {
|
||||
tile.workspacePath = worktreeDir;
|
||||
}
|
||||
const branchName = `agent/${agentId}`;
|
||||
let legacyPath = null;
|
||||
try {
|
||||
legacyPath = ensureLegacyWorktreeSlot(worktreeDir);
|
||||
if (legacyPath) {
|
||||
legacyMoves.push({ from: legacyPath, to: worktreeDir });
|
||||
}
|
||||
ensureAgentWorktree(repoPath, worktreeDir, branchName);
|
||||
ensureWorktreeIgnores(worktreeDir, WORKSPACE_IGNORE_ENTRIES);
|
||||
ensureWorkspaceFiles(worktreeDir);
|
||||
if (legacyPath) {
|
||||
for (const name of WORKSPACE_FILE_NAMES) {
|
||||
const fromPath = path.join(legacyPath, name);
|
||||
const toPath = path.join(worktreeDir, name);
|
||||
copyWorkspaceFile(fromPath, toPath);
|
||||
}
|
||||
copyWorkspaceMemory(path.join(legacyPath, "memory"), path.join(worktreeDir, "memory"));
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error.";
|
||||
errors.push(`Worktree migration failed for ${project.id}/${agentId}: ${message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (configLoaded && config) {
|
||||
try {
|
||||
upsertAgentEntry(config, {
|
||||
agentId,
|
||||
agentName: tile.name ?? agentId,
|
||||
workspaceDir: worktreeDir,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error.";
|
||||
warnings.push(`Failed to update agent config for ${agentId}: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf8");
|
||||
|
||||
if (configLoaded && config && configPath) {
|
||||
saveClawdbotConfig(configPath, config);
|
||||
}
|
||||
|
||||
if (legacyMoves.length > 0) {
|
||||
console.log("Legacy workspace directories renamed:");
|
||||
for (const move of legacyMoves) {
|
||||
console.log(` ${move.from} -> ${move.to}`);
|
||||
}
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
console.log("Warnings:");
|
||||
for (const warning of warnings) {
|
||||
console.log(` - ${warning}`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
console.error("Migration errors:");
|
||||
for (const error of errors) {
|
||||
console.error(` - ${error}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
console.log(`Migration complete. Store backup: ${backupPath}`);
|
||||
};
|
||||
|
||||
migrate();
|
||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const sourcePath = path.join(os.homedir(), "clawdbot", "ui", "src", "ui", "gateway.ts");
|
||||
const sourcePath = path.join(os.homedir(), "openclaw", "ui", "src", "ui", "gateway.ts");
|
||||
const destPath = path.join(
|
||||
repoRoot,
|
||||
"src",
|
||||
|
||||
@@ -2,7 +2,6 @@ const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moltbot"];
|
||||
const NEW_STATE_DIRNAME = ".openclaw";
|
||||
|
||||
const resolveUserPath = (input) => {
|
||||
@@ -26,24 +25,11 @@ const resolveDefaultHomeDir = () => {
|
||||
};
|
||||
|
||||
const resolveStateDir = (env = process.env) => {
|
||||
const override =
|
||||
env.OPENCLAW_STATE_DIR?.trim() ||
|
||||
env.MOLTBOT_STATE_DIR?.trim() ||
|
||||
env.CLAWDBOT_STATE_DIR?.trim();
|
||||
const override = env.OPENCLAW_STATE_DIR?.trim();
|
||||
if (override) return resolveUserPath(override);
|
||||
|
||||
const home = resolveDefaultHomeDir();
|
||||
const newDir = path.join(home, NEW_STATE_DIRNAME);
|
||||
const legacyDirs = LEGACY_STATE_DIRNAMES.map((dir) => path.join(home, dir));
|
||||
try {
|
||||
if (fs.existsSync(newDir)) return newDir;
|
||||
} catch {}
|
||||
for (const dir of legacyDirs) {
|
||||
try {
|
||||
if (fs.existsSync(dir)) return dir;
|
||||
} catch {}
|
||||
}
|
||||
return newDir;
|
||||
return path.join(home, NEW_STATE_DIRNAME);
|
||||
};
|
||||
|
||||
const resolveStudioSettingsPath = (env = process.env) => {
|
||||
@@ -83,8 +69,7 @@ const readOpenclawGatewayDefaults = (env = process.env) => {
|
||||
};
|
||||
|
||||
const loadUpstreamGatewaySettings = (env = process.env) => {
|
||||
const settingsPath = resolveStudioSettingsPath(env);
|
||||
const parsed = readJsonFile(settingsPath);
|
||||
const parsed = readJsonFile(resolveStudioSettingsPath(env));
|
||||
const gateway = parsed && typeof parsed === "object" ? parsed.gateway : null;
|
||||
const url = typeof gateway?.url === "string" ? gateway.url.trim() : "";
|
||||
const token = typeof gateway?.token === "string" ? gateway.token.trim() : "";
|
||||
@@ -94,19 +79,16 @@ const loadUpstreamGatewaySettings = (env = process.env) => {
|
||||
return {
|
||||
url: url || defaults.url,
|
||||
token: defaults.token,
|
||||
settingsPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
url: url || DEFAULT_GATEWAY_URL,
|
||||
token,
|
||||
settingsPath,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
resolveStateDir,
|
||||
resolveStudioSettingsPath,
|
||||
loadUpstreamGatewaySettings,
|
||||
};
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { resolveStateDir } from "@/lib/clawdbot/paths";
|
||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||
import {
|
||||
resolveConfiguredSshTarget,
|
||||
resolveGatewaySshTargetFromGatewayUrl,
|
||||
runSshJson,
|
||||
} from "@/lib/ssh/gateway-host";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type DotenvKeysResponse = { keys: string[] };
|
||||
|
||||
const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
||||
|
||||
const parseDotEnvKeys = (raw: string): string[] => {
|
||||
const keys: string[] = [];
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const withoutExport = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trim() : trimmed;
|
||||
const idx = withoutExport.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const key = withoutExport.slice(0, idx).trim();
|
||||
if (!ENV_KEY_PATTERN.test(key)) continue;
|
||||
const value = withoutExport.slice(idx + 1).trim();
|
||||
if (!value) continue;
|
||||
keys.push(key);
|
||||
}
|
||||
return Array.from(new Set(keys)).sort();
|
||||
};
|
||||
|
||||
const readLocalDotEnvKeys = (): string[] => {
|
||||
const envPath = path.join(resolveStateDir(), ".env");
|
||||
if (!fs.existsSync(envPath)) return [];
|
||||
const raw = fs.readFileSync(envPath, "utf8");
|
||||
return parseDotEnvKeys(raw);
|
||||
};
|
||||
|
||||
const DOTENV_KEYS_SCRIPT = `
|
||||
set -euo pipefail
|
||||
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
pattern = re.compile(r"^[A-Z_][A-Z0-9_]*$")
|
||||
env_path = pathlib.Path.home() / ".openclaw" / ".env"
|
||||
keys = []
|
||||
|
||||
try:
|
||||
raw = env_path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
raw = ""
|
||||
|
||||
for line in raw.splitlines():
|
||||
trimmed = line.strip()
|
||||
if not trimmed or trimmed.startswith("#"):
|
||||
continue
|
||||
if trimmed.startswith("export "):
|
||||
trimmed = trimmed[len("export "):].strip()
|
||||
if "=" not in trimmed:
|
||||
continue
|
||||
key, value = trimmed.split("=", 1)
|
||||
key = key.strip()
|
||||
if not pattern.fullmatch(key):
|
||||
continue
|
||||
value = value.strip()
|
||||
if not value:
|
||||
continue
|
||||
keys.append(key)
|
||||
|
||||
print(json.dumps({"keys": sorted(set(keys))}))
|
||||
PY
|
||||
`;
|
||||
|
||||
const readRemoteDotEnvKeys = (sshTarget: string): string[] => {
|
||||
const result = runSshJson({
|
||||
sshTarget,
|
||||
argv: ["bash", "-s"],
|
||||
input: DOTENV_KEYS_SCRIPT,
|
||||
label: "read dotenv keys",
|
||||
fallbackMessage: "Failed to read remote ~/.openclaw/.env.",
|
||||
}) as DotenvKeysResponse;
|
||||
return Array.isArray(result?.keys) ? result.keys.filter((key) => typeof key === "string") : [];
|
||||
};
|
||||
|
||||
const resolveDotEnvSshTarget = (): string | null => {
|
||||
const configured = resolveConfiguredSshTarget(process.env);
|
||||
if (configured) return configured;
|
||||
const settings = loadStudioSettings();
|
||||
const gatewayUrl = settings.gateway?.url ?? "";
|
||||
if (!gatewayUrl.trim()) return null;
|
||||
if (isLocalGatewayUrl(gatewayUrl)) return null;
|
||||
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = loadStudioSettings();
|
||||
const gatewayUrl = settings.gateway?.url ?? "";
|
||||
|
||||
const sshTarget = resolveDotEnvSshTarget();
|
||||
const keys = sshTarget ? readRemoteDotEnvKeys(sshTarget) : readLocalDotEnvKeys();
|
||||
|
||||
if (!isLocalGatewayUrl(gatewayUrl) && !sshTarget) {
|
||||
return NextResponse.json(
|
||||
{ error: "Gateway is remote but no SSH target is configured." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ keys });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to read dotenv keys.";
|
||||
console.error(message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const bodyOrError = await parseIntentBody(request);
|
||||
if (bodyOrError instanceof Response) {
|
||||
return bodyOrError as NextResponse;
|
||||
}
|
||||
const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : "";
|
||||
if (!agentId) {
|
||||
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
|
||||
}
|
||||
return await executeGatewayIntent("agents.delete", { agentId });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const bodyOrError = await parseIntentBody(request);
|
||||
if (bodyOrError instanceof Response) {
|
||||
return bodyOrError as NextResponse;
|
||||
}
|
||||
const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : "";
|
||||
const name = typeof bodyOrError.name === "string" ? bodyOrError.name.trim() : "";
|
||||
if (!agentId || !name) {
|
||||
return NextResponse.json({ error: "agentId and name are required." }, { status: 400 });
|
||||
}
|
||||
return await executeGatewayIntent("agents.update", { agentId, name });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { parseIntentBody, executeGatewayIntent } from "@/lib/controlplane/intent-route";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const parsed = await parseIntentBody(request);
|
||||
if (parsed instanceof Response) return parsed;
|
||||
|
||||
const runId = typeof parsed.runId === "string" ? parsed.runId.trim() : "";
|
||||
if (!runId) {
|
||||
return Response.json({ error: "runId is required." }, { status: 400 });
|
||||
}
|
||||
const timeoutMs =
|
||||
typeof parsed.timeoutMs === "number" && Number.isFinite(parsed.timeoutMs)
|
||||
? Math.max(1, Math.floor(parsed.timeoutMs))
|
||||
: undefined;
|
||||
|
||||
return executeGatewayIntent("agent.wait", {
|
||||
runId,
|
||||
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const bodyOrError = await parseIntentBody(request);
|
||||
if (bodyOrError instanceof Response) {
|
||||
return bodyOrError as NextResponse;
|
||||
}
|
||||
const sessionKey = typeof bodyOrError.sessionKey === "string" ? bodyOrError.sessionKey.trim() : "";
|
||||
if (!sessionKey) {
|
||||
return NextResponse.json({ error: "sessionKey is required." }, { status: 400 });
|
||||
}
|
||||
return await executeGatewayIntent("chat.abort", { sessionKey });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const bodyOrError = await parseIntentBody(request);
|
||||
if (bodyOrError instanceof Response) {
|
||||
return bodyOrError as NextResponse;
|
||||
}
|
||||
|
||||
const sessionKey = typeof bodyOrError.sessionKey === "string" ? bodyOrError.sessionKey.trim() : "";
|
||||
const message = typeof bodyOrError.message === "string" ? bodyOrError.message : "";
|
||||
const idempotencyKey =
|
||||
typeof bodyOrError.idempotencyKey === "string" ? bodyOrError.idempotencyKey.trim() : "";
|
||||
const deliver = Boolean(bodyOrError.deliver);
|
||||
|
||||
if (!sessionKey || !message.trim() || !idempotencyKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "sessionKey, message, and idempotencyKey are required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return await executeGatewayIntent("chat.send", {
|
||||
sessionKey,
|
||||
message,
|
||||
idempotencyKey,
|
||||
deliver,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const VALID_DECISIONS = new Set(["allow-once", "allow-always", "deny"]);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const bodyOrError = await parseIntentBody(request);
|
||||
if (bodyOrError instanceof Response) {
|
||||
return bodyOrError as NextResponse;
|
||||
}
|
||||
const id = typeof bodyOrError.id === "string" ? bodyOrError.id.trim() : "";
|
||||
const decision = typeof bodyOrError.decision === "string" ? bodyOrError.decision.trim() : "";
|
||||
if (!id || !VALID_DECISIONS.has(decision)) {
|
||||
return NextResponse.json({ error: "id and valid decision are required." }, { status: 400 });
|
||||
}
|
||||
return await executeGatewayIntent("exec.approval.resolve", { id, decision });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
ensureDomainIntentRuntime,
|
||||
executeGatewayIntent,
|
||||
parseIntentBody,
|
||||
} from "@/lib/controlplane/intent-route";
|
||||
import {
|
||||
upsertAgentExecApprovalsPolicyViaRuntime,
|
||||
type ExecutionRoleId,
|
||||
} from "@/lib/controlplane/exec-approvals";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const VALID_ROLES = new Set<ExecutionRoleId>(["conservative", "collaborative", "autonomous"]);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const bodyOrError = await parseIntentBody(request);
|
||||
if (bodyOrError instanceof Response) {
|
||||
return bodyOrError as NextResponse;
|
||||
}
|
||||
|
||||
const hasFilePayload = "file" in bodyOrError;
|
||||
if (hasFilePayload) {
|
||||
const baseHash = typeof bodyOrError.baseHash === "string" ? bodyOrError.baseHash.trim() : "";
|
||||
return await executeGatewayIntent("exec.approvals.set", {
|
||||
file: bodyOrError.file,
|
||||
...(baseHash ? { baseHash } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : "";
|
||||
const role = typeof bodyOrError.role === "string" ? bodyOrError.role.trim() : "";
|
||||
if (!agentId || !VALID_ROLES.has(role as ExecutionRoleId)) {
|
||||
return NextResponse.json({ error: "agentId and valid role are required." }, { status: 400 });
|
||||
}
|
||||
|
||||
const runtimeOrError = await ensureDomainIntentRuntime();
|
||||
if (runtimeOrError instanceof Response) {
|
||||
return runtimeOrError as NextResponse;
|
||||
}
|
||||
try {
|
||||
await upsertAgentExecApprovalsPolicyViaRuntime({
|
||||
runtime: runtimeOrError,
|
||||
agentId,
|
||||
role: role as ExecutionRoleId,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "exec_approvals_set_failed";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { parseIntentBody, executeGatewayIntent } from "@/lib/controlplane/intent-route";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const parsed = await parseIntentBody(request);
|
||||
if (parsed instanceof Response) return parsed;
|
||||
|
||||
const key = typeof parsed.key === "string" ? parsed.key.trim() : "";
|
||||
if (!key) {
|
||||
return Response.json({ error: "key is required." }, { status: 400 });
|
||||
}
|
||||
|
||||
return executeGatewayIntent("sessions.reset", { key });
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveUserPath } from "@/lib/clawdbot/paths";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type PathAutocompleteEntry = {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
displayPath: string;
|
||||
isDirectory: boolean;
|
||||
};
|
||||
|
||||
type PathAutocompleteResult = {
|
||||
query: string;
|
||||
directory: string;
|
||||
entries: PathAutocompleteEntry[];
|
||||
};
|
||||
|
||||
type PathAutocompleteOptions = {
|
||||
query: string;
|
||||
maxResults?: number;
|
||||
homedir?: () => string;
|
||||
};
|
||||
|
||||
const normalizeQuery = (query: string): string => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Query is required.");
|
||||
}
|
||||
if (trimmed === "~") {
|
||||
return "~/";
|
||||
}
|
||||
if (trimmed.startsWith("~")) {
|
||||
return trimmed;
|
||||
}
|
||||
const withoutLeading = trimmed.replace(/^[\\/]+/, "");
|
||||
return `~/${withoutLeading}`;
|
||||
};
|
||||
|
||||
const isWithinHome = (target: string, home: string): boolean => {
|
||||
const relative = path.relative(home, target);
|
||||
if (!relative) return true;
|
||||
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||
};
|
||||
|
||||
const listPathAutocompleteEntries = ({
|
||||
query,
|
||||
maxResults = 10,
|
||||
homedir = os.homedir,
|
||||
}: PathAutocompleteOptions): PathAutocompleteResult => {
|
||||
const normalized = normalizeQuery(query);
|
||||
const resolvedHome = path.resolve(homedir());
|
||||
const resolvedQuery = resolveUserPath(normalized, homedir);
|
||||
if (!isWithinHome(resolvedQuery, resolvedHome)) {
|
||||
throw new Error("Path must stay within the home directory.");
|
||||
}
|
||||
|
||||
const endsWithSlash = normalized.endsWith("/") || normalized.endsWith(path.sep);
|
||||
const directoryPath = endsWithSlash ? resolvedQuery : path.dirname(resolvedQuery);
|
||||
const prefix = endsWithSlash ? "" : path.basename(resolvedQuery);
|
||||
|
||||
if (!isWithinHome(directoryPath, resolvedHome)) {
|
||||
throw new Error("Path must stay within the home directory.");
|
||||
}
|
||||
if (!fs.existsSync(directoryPath)) {
|
||||
throw new Error(`Directory does not exist: ${directoryPath}`);
|
||||
}
|
||||
const stat = fs.statSync(directoryPath);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${directoryPath}`);
|
||||
}
|
||||
|
||||
const limit = Number.isFinite(maxResults) && maxResults > 0 ? Math.floor(maxResults) : 10;
|
||||
|
||||
const entries = fs
|
||||
.readdirSync(directoryPath, { withFileTypes: true })
|
||||
.filter((entry) => !entry.name.startsWith("."))
|
||||
.filter((entry) => entry.name.startsWith(prefix))
|
||||
.map((entry) => {
|
||||
const fullPath = path.join(directoryPath, entry.name);
|
||||
const relative = path.relative(resolvedHome, fullPath);
|
||||
const normalizedRelative = relative.split(path.sep).join("/");
|
||||
const displayBase = `~/${normalizedRelative}`;
|
||||
return {
|
||||
name: entry.name,
|
||||
fullPath,
|
||||
displayPath: entry.isDirectory() ? `${displayBase}/` : displayBase,
|
||||
isDirectory: entry.isDirectory(),
|
||||
} satisfies PathAutocompleteEntry;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return { query: normalized, directory: directoryPath, entries };
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const rawQuery = searchParams.get("q");
|
||||
const query = rawQuery && rawQuery.trim() ? rawQuery.trim() : "~/";
|
||||
const result = listPathAutocompleteEntries({ query, maxResults: 10 });
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to list path suggestions.";
|
||||
console.error(message);
|
||||
const status = message.includes("does not exist") ? 404 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { deriveRuntimeFreshness, probeOpenClawLocalState } from "@/lib/controlplane/degraded-read";
|
||||
import { selectAgentHistoryEntries } from "@/lib/controlplane/read-model";
|
||||
import { getControlPlaneRuntime, isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 1000;
|
||||
|
||||
const resolveLimit = (raw: string | null): number => {
|
||||
if (!raw) return DEFAULT_LIMIT;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_LIMIT;
|
||||
if (parsed <= 0) return DEFAULT_LIMIT;
|
||||
return Math.min(Math.floor(parsed), MAX_LIMIT);
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
if (!isStudioDomainApiModeEnabled()) {
|
||||
return NextResponse.json({ enabled: false, error: "domain_api_mode_disabled" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { agentId } = await context.params;
|
||||
const normalizedAgentId = agentId.trim();
|
||||
if (!normalizedAgentId) {
|
||||
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
|
||||
}
|
||||
|
||||
const controlPlane = getControlPlaneRuntime();
|
||||
let startError: string | null = null;
|
||||
try {
|
||||
await controlPlane.ensureStarted();
|
||||
} catch (err) {
|
||||
startError = err instanceof Error ? err.message : "controlplane_start_failed";
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = resolveLimit(url.searchParams.get("limit"));
|
||||
const snapshot = controlPlane.snapshot();
|
||||
const probe = snapshot.status === "connected" ? null : await probeOpenClawLocalState();
|
||||
const allEntries = controlPlane.eventsAfter(0, MAX_LIMIT * 5);
|
||||
const entries = selectAgentHistoryEntries(allEntries, normalizedAgentId, limit);
|
||||
|
||||
return NextResponse.json({
|
||||
enabled: true,
|
||||
agentId: normalizedAgentId,
|
||||
...(startError ? { error: startError } : {}),
|
||||
entries,
|
||||
freshness: deriveRuntimeFreshness(snapshot, probe),
|
||||
...(probe ? { probe } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration";
|
||||
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
|
||||
import { getControlPlaneRuntime, isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isStudioDomainApiModeEnabled()) {
|
||||
return NextResponse.json({ enabled: false, error: "domain_api_mode_disabled" }, { status: 404 });
|
||||
}
|
||||
|
||||
let cachedConfigSnapshot: GatewayModelPolicySnapshot | null = null;
|
||||
try {
|
||||
const body = (await request.json()) as unknown;
|
||||
if (body && typeof body === "object" && !Array.isArray(body)) {
|
||||
const record = body as { cachedConfigSnapshot?: unknown };
|
||||
if (record.cachedConfigSnapshot && typeof record.cachedConfigSnapshot === "object") {
|
||||
cachedConfigSnapshot = record.cachedConfigSnapshot as GatewayModelPolicySnapshot;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const controlPlane = getControlPlaneRuntime();
|
||||
try {
|
||||
await controlPlane.ensureStarted();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "controlplane_start_failed";
|
||||
return NextResponse.json(
|
||||
{ enabled: true, error: message, code: "GATEWAY_UNAVAILABLE", reason: "gateway_unavailable" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = loadStudioSettings();
|
||||
const gatewayUrl = settings.gateway?.url?.trim() ?? "";
|
||||
if (!gatewayUrl) {
|
||||
return NextResponse.json({ enabled: true, error: "gateway_url_not_configured" }, { status: 503 });
|
||||
}
|
||||
const result = await hydrateAgentFleetFromGateway({
|
||||
client: {
|
||||
call: (method, params) => controlPlane.callGateway(method, params),
|
||||
},
|
||||
gatewayUrl,
|
||||
cachedConfigSnapshot,
|
||||
loadStudioSettings: async () => settings,
|
||||
isDisconnectLikeError: () => false,
|
||||
logError: (message, error) => console.error(message, error),
|
||||
});
|
||||
return NextResponse.json({ enabled: true, result });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "fleet_load_failed";
|
||||
return NextResponse.json({ enabled: true, error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ControlPlaneOutboxEntry } from "@/lib/controlplane/contracts";
|
||||
import { getControlPlaneRuntime, isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const REPLAY_LIMIT = 2000;
|
||||
const HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const parseLastEventId = (request: Request): number => {
|
||||
const headerValue = request.headers.get("last-event-id");
|
||||
if (!headerValue) return 0;
|
||||
const parsed = Number(headerValue.trim());
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
||||
return Math.floor(parsed);
|
||||
};
|
||||
|
||||
const toSseFrame = (entry: ControlPlaneOutboxEntry): Uint8Array => {
|
||||
const eventName = entry.event.type === "runtime.status" ? "runtime.status" : "gateway.event";
|
||||
return encoder.encode(
|
||||
`id: ${entry.id}\nevent: ${eventName}\ndata: ${JSON.stringify(entry.event)}\n\n`
|
||||
);
|
||||
};
|
||||
|
||||
const heartbeatFrame = (): Uint8Array => encoder.encode(": heartbeat\n\n");
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isStudioDomainApiModeEnabled()) {
|
||||
return new Response(
|
||||
JSON.stringify({ enabled: false, error: "domain_api_mode_disabled" }),
|
||||
{ status: 404, headers: { "content-type": "application/json; charset=utf-8" } }
|
||||
);
|
||||
}
|
||||
|
||||
const controlPlane = getControlPlaneRuntime();
|
||||
try {
|
||||
await controlPlane.ensureStarted();
|
||||
} catch (err) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
enabled: true,
|
||||
error: err instanceof Error ? err.message : "controlplane_start_failed",
|
||||
}),
|
||||
{ status: 503, headers: { "content-type": "application/json; charset=utf-8" } }
|
||||
);
|
||||
}
|
||||
|
||||
const lastSeenId = parseLastEventId(request);
|
||||
const replayEntries = controlPlane.eventsAfter(lastSeenId, REPLAY_LIMIT);
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
let closed = false;
|
||||
let unsubscribe: () => void = () => {};
|
||||
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
unsubscribe();
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
heartbeat = null;
|
||||
}
|
||||
try {
|
||||
controller.close();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
for (const entry of replayEntries) {
|
||||
controller.enqueue(toSseFrame(entry));
|
||||
}
|
||||
|
||||
unsubscribe = controlPlane.subscribe((entry) => {
|
||||
if (closed) return;
|
||||
controller.enqueue(toSseFrame(entry));
|
||||
});
|
||||
|
||||
heartbeat = setInterval(() => {
|
||||
if (closed) return;
|
||||
controller.enqueue(heartbeatFrame());
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
request.signal.addEventListener("abort", close, { once: true });
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache, no-transform",
|
||||
connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { deriveRuntimeFreshness, probeOpenClawLocalState } from "@/lib/controlplane/degraded-read";
|
||||
import { getControlPlaneRuntime, isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
if (!isStudioDomainApiModeEnabled()) {
|
||||
return NextResponse.json({ enabled: false, error: "domain_api_mode_disabled" }, { status: 404 });
|
||||
}
|
||||
|
||||
const controlPlane = getControlPlaneRuntime();
|
||||
let startError: string | null = null;
|
||||
try {
|
||||
await controlPlane.ensureStarted();
|
||||
} catch (err) {
|
||||
startError = err instanceof Error ? err.message : "controlplane_start_failed";
|
||||
}
|
||||
|
||||
const snapshot = controlPlane.snapshot();
|
||||
const probe = snapshot.status === "connected" ? null : await probeOpenClawLocalState();
|
||||
return NextResponse.json({
|
||||
enabled: true,
|
||||
...(startError ? { error: startError } : {}),
|
||||
summary: snapshot,
|
||||
freshness: deriveRuntimeFreshness(snapshot, probe),
|
||||
...(probe ? { probe } : {}),
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { type StudioSettingsPatch } from "@/lib/studio/settings";
|
||||
import { isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime";
|
||||
import {
|
||||
applyStudioSettingsPatch,
|
||||
loadLocalGatewayDefaults,
|
||||
loadStudioSettings,
|
||||
redactLocalGatewayDefaultsSecrets,
|
||||
redactStudioSettingsSecrets,
|
||||
} from "@/lib/studio/settings-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -12,11 +15,19 @@ export const runtime = "nodejs";
|
||||
const isPatch = (value: unknown): value is StudioSettingsPatch =>
|
||||
Boolean(value && typeof value === "object");
|
||||
|
||||
const buildSettingsResponseBody = () => {
|
||||
const settings = loadStudioSettings();
|
||||
const localGatewayDefaults = loadLocalGatewayDefaults();
|
||||
return {
|
||||
settings: redactStudioSettingsSecrets(settings),
|
||||
localGatewayDefaults: redactLocalGatewayDefaultsSecrets(localGatewayDefaults),
|
||||
domainApiModeEnabled: isStudioDomainApiModeEnabled(),
|
||||
};
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = loadStudioSettings();
|
||||
const localGatewayDefaults = loadLocalGatewayDefaults();
|
||||
return NextResponse.json({ settings, localGatewayDefaults });
|
||||
return NextResponse.json(buildSettingsResponseBody());
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load studio settings.";
|
||||
console.error(message);
|
||||
@@ -30,8 +41,8 @@ export async function PUT(request: Request) {
|
||||
if (!isPatch(body)) {
|
||||
return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 });
|
||||
}
|
||||
const settings = applyStudioSettingsPatch(body);
|
||||
return NextResponse.json({ settings });
|
||||
applyStudioSettingsPatch(body);
|
||||
return NextResponse.json(buildSettingsResponseBody());
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to save studio settings.";
|
||||
console.error(message);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "../../node_modules/react-mentions-ts/styles/tailwind.css";
|
||||
@import "./styles/markdown.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
+62
-29
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/lib/text/message-extract";
|
||||
import {
|
||||
useGatewayConnection,
|
||||
type GatewayStatus,
|
||||
} from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
type GatewayModelChoice,
|
||||
@@ -113,6 +114,7 @@ import {
|
||||
type SettingsRouteTab,
|
||||
} from "@/features/agents/operations/settingsRouteWorkflow";
|
||||
import { useSettingsRouteController } from "@/features/agents/operations/useSettingsRouteController";
|
||||
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
|
||||
const PENDING_EXEC_APPROVAL_PRUNE_GRACE_MS = 500;
|
||||
|
||||
type MobilePane = "fleet" | "chat";
|
||||
@@ -218,6 +220,7 @@ const AgentStudioPage = () => {
|
||||
gatewayUrl,
|
||||
token,
|
||||
localGatewayDefaults,
|
||||
domainApiModeEnabled,
|
||||
error: gatewayError,
|
||||
connect,
|
||||
disconnect,
|
||||
@@ -225,6 +228,9 @@ const AgentStudioPage = () => {
|
||||
setGatewayUrl,
|
||||
setToken,
|
||||
} = useGatewayConnection(settingsCoordinator);
|
||||
const useDomainApiMode = domainApiModeEnabled ?? isStudioDomainIntentModeEnabled();
|
||||
const coreConnected = useDomainApiMode ? true : status === "connected";
|
||||
const coreStatus: GatewayStatus = coreConnected ? "connected" : status;
|
||||
|
||||
const { state, dispatch, hydrateAgents, setError, setLoading } = useAgentStore();
|
||||
const [showConnectionPanel, setShowConnectionPanel] = useState(false);
|
||||
@@ -455,7 +461,7 @@ const AgentStudioPage = () => {
|
||||
}, [specialLatestUpdate]);
|
||||
|
||||
const loadAgents = useCallback(async () => {
|
||||
if (status !== "connected") return;
|
||||
if (!coreConnected) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const commands = await runStudioBootstrapLoadOperation({
|
||||
@@ -490,7 +496,7 @@ const AgentStudioPage = () => {
|
||||
gatewayUrl,
|
||||
gatewayConfigSnapshot,
|
||||
settingsCoordinator,
|
||||
status,
|
||||
coreConnected,
|
||||
]);
|
||||
|
||||
const enqueueConfigMutationFromRef = useCallback(
|
||||
@@ -563,7 +569,7 @@ const AgentStudioPage = () => {
|
||||
queuedBlockedByRunningAgents,
|
||||
activeConfigMutation,
|
||||
} = useConfigMutationQueue({
|
||||
status,
|
||||
status: coreStatus,
|
||||
hasRunningAgents,
|
||||
hasRestartBlockInProgress,
|
||||
});
|
||||
@@ -582,9 +588,9 @@ const AgentStudioPage = () => {
|
||||
}, [unscopedPendingExecApprovals]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "connected") return;
|
||||
if (coreConnected) return;
|
||||
setAgentsLoadedOnce(false);
|
||||
}, [gatewayUrl, status]);
|
||||
}, [coreConnected, gatewayUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -660,24 +666,24 @@ const AgentStudioPage = () => {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "connected" || !focusedPreferencesLoaded) return;
|
||||
if (!coreConnected || !focusedPreferencesLoaded) return;
|
||||
if (restartingMutationBlock && restartingMutationBlock.phase !== "queued") return;
|
||||
if (createAgentBlock && createAgentBlock.phase !== "queued") return;
|
||||
void loadAgents();
|
||||
}, [
|
||||
coreConnected,
|
||||
createAgentBlock,
|
||||
focusedPreferencesLoaded,
|
||||
gatewayUrl,
|
||||
loadAgents,
|
||||
restartingMutationBlock,
|
||||
status,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "disconnected") {
|
||||
if (!coreConnected) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setLoading, status]);
|
||||
}, [coreConnected, setLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const nowMs = Date.now();
|
||||
@@ -743,7 +749,7 @@ const AgentStudioPage = () => {
|
||||
clearHistoryInFlight,
|
||||
} = useRuntimeSyncController({
|
||||
client,
|
||||
status,
|
||||
status: coreStatus,
|
||||
agents,
|
||||
focusedAgentId,
|
||||
focusedAgentRunning,
|
||||
@@ -766,7 +772,7 @@ const AgentStudioPage = () => {
|
||||
clearPendingLivePatch,
|
||||
} = useChatInteractionController({
|
||||
client,
|
||||
status,
|
||||
status: coreStatus,
|
||||
agents,
|
||||
dispatch,
|
||||
setError,
|
||||
@@ -806,7 +812,7 @@ const AgentStudioPage = () => {
|
||||
} = useSettingsRouteController({
|
||||
settingsRouteActive,
|
||||
settingsRouteAgentId,
|
||||
status,
|
||||
status: coreStatus,
|
||||
agentsLoadedOnce,
|
||||
selectedAgentId: state.selectedAgentId,
|
||||
focusedAgentId: focusedAgent?.agentId ?? null,
|
||||
@@ -1185,14 +1191,46 @@ const AgentStudioPage = () => {
|
||||
},
|
||||
});
|
||||
runtimeEventHandlerRef.current = handler;
|
||||
const unsubscribe = client.onEvent((event: EventFrame) => {
|
||||
handler.handleEvent(event);
|
||||
handleGatewayEventIngress(event);
|
||||
});
|
||||
let unsubscribeGatewayEvents: (() => void) | null = null;
|
||||
let stream: EventSource | null = null;
|
||||
if (useDomainApiMode) {
|
||||
stream = new EventSource("/api/runtime/stream");
|
||||
stream.addEventListener("gateway.event", (raw) => {
|
||||
const message = raw as MessageEvent<string>;
|
||||
try {
|
||||
const parsed = JSON.parse(message.data) as {
|
||||
event?: string;
|
||||
payload?: unknown;
|
||||
seq?: number;
|
||||
};
|
||||
if (typeof parsed.event !== "string") return;
|
||||
const frame: EventFrame = {
|
||||
type: "event",
|
||||
event: parsed.event,
|
||||
payload: parsed.payload,
|
||||
...(typeof parsed.seq === "number" ? { seq: parsed.seq } : {}),
|
||||
};
|
||||
handler.handleEvent(frame);
|
||||
handleGatewayEventIngress(frame);
|
||||
} catch {}
|
||||
});
|
||||
stream.addEventListener("runtime.status", () => {
|
||||
void loadSummarySnapshot();
|
||||
});
|
||||
stream.onerror = () => {
|
||||
// EventSource performs automatic reconnect; keep warning low-noise.
|
||||
};
|
||||
} else {
|
||||
unsubscribeGatewayEvents = client.onEvent((event: EventFrame) => {
|
||||
handler.handleEvent(event);
|
||||
handleGatewayEventIngress(event);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
runtimeEventHandlerRef.current = null;
|
||||
handler.dispose();
|
||||
unsubscribe();
|
||||
unsubscribeGatewayEvents?.();
|
||||
stream?.close();
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
@@ -1204,6 +1242,7 @@ const AgentStudioPage = () => {
|
||||
refreshHeartbeatLatestUpdate,
|
||||
specialLatestUpdate,
|
||||
handleGatewayEventIngress,
|
||||
useDomainApiMode,
|
||||
status,
|
||||
]);
|
||||
|
||||
@@ -1227,7 +1266,7 @@ const AgentStudioPage = () => {
|
||||
: queuedConfigMutationCount > 0
|
||||
? queuedBlockedByRunningAgents
|
||||
? `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}; waiting for ${runningAgentCount} running agent${runningAgentCount === 1 ? "" : "s"} to finish`
|
||||
: status !== "connected"
|
||||
: !coreConnected
|
||||
? `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}; waiting for gateway connection`
|
||||
: `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}`
|
||||
: null;
|
||||
@@ -1292,7 +1331,7 @@ const AgentStudioPage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "disconnected" && !agentsLoadedOnce && didAttemptGatewayConnect) {
|
||||
if (!coreConnected && status === "disconnected" && !agentsLoadedOnce && didAttemptGatewayConnect) {
|
||||
return (
|
||||
<div className="relative min-h-screen w-screen overflow-hidden bg-background">
|
||||
<div className="relative z-10 flex h-screen flex-col">
|
||||
@@ -1329,7 +1368,7 @@ const AgentStudioPage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "connected" && !agentsLoadedOnce) {
|
||||
if (coreConnected && !agentsLoadedOnce) {
|
||||
return (
|
||||
<div className="relative min-h-screen w-screen overflow-hidden bg-background">
|
||||
<div className="flex min-h-screen items-center justify-center px-6">
|
||||
@@ -1500,12 +1539,6 @@ const AgentStudioPage = () => {
|
||||
settingsMutationController.handleDeleteAgent(inspectSidebarAgent.agentId)
|
||||
}
|
||||
canDelete={inspectSidebarAgent.agentId !== RESERVED_MAIN_AGENT_ID}
|
||||
onToolCallingToggle={(enabled) =>
|
||||
handleToolCallingToggle(inspectSidebarAgent.agentId, enabled)
|
||||
}
|
||||
onThinkingTracesToggle={(enabled) =>
|
||||
handleThinkingTracesToggle(inspectSidebarAgent.agentId, enabled)
|
||||
}
|
||||
skillsReport={settingsMutationController.settingsSkillsReport}
|
||||
skillsLoading={settingsMutationController.settingsSkillsLoading}
|
||||
skillsError={settingsMutationController.settingsSkillsError}
|
||||
@@ -1621,7 +1654,7 @@ const AgentStudioPage = () => {
|
||||
onCreateAgent={() => {
|
||||
handleOpenCreateAgentModal();
|
||||
}}
|
||||
createDisabled={status !== "connected" || createAgentBusy || state.loading}
|
||||
createDisabled={!coreConnected || createAgentBusy || state.loading}
|
||||
createBusy={createAgentBusy}
|
||||
onSelectAgent={handleFleetSelectAgent}
|
||||
/>
|
||||
@@ -1636,7 +1669,7 @@ const AgentStudioPage = () => {
|
||||
<AgentChatPanel
|
||||
agent={focusedAgent}
|
||||
isSelected={false}
|
||||
canSend={status === "connected"}
|
||||
canSend={coreConnected}
|
||||
models={gatewayModels}
|
||||
stopBusy={stopBusyAgentId === focusedAgent.agentId}
|
||||
stopDisabledReason={focusedAgentStopDisabledReason}
|
||||
@@ -1680,7 +1713,7 @@ const AgentStudioPage = () => {
|
||||
description={
|
||||
hasAnyAgents
|
||||
? undefined
|
||||
: status === "connected"
|
||||
: coreConnected
|
||||
? "Use New Agent in the sidebar to add your first agent."
|
||||
: "Connect to your gateway to load agents into the studio."
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
updatePendingApprovalById,
|
||||
} from "@/features/agents/approvals/pendingStore";
|
||||
import { shouldTreatExecApprovalResolveErrorAsUnknownId } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
|
||||
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
|
||||
import { postStudioIntent } from "@/lib/controlplane/intents-client";
|
||||
|
||||
type GatewayClientLike = {
|
||||
call: (method: string, params: unknown) => Promise<unknown>;
|
||||
@@ -33,9 +35,11 @@ export const resolveExecApprovalViaStudio = async (params: {
|
||||
isDisconnectLikeError: (error: unknown) => boolean;
|
||||
shouldTreatUnknownId?: (error: unknown) => boolean;
|
||||
logWarn?: (message: string, error: unknown) => void;
|
||||
useDomainIntents?: boolean;
|
||||
}): Promise<void> => {
|
||||
const id = params.approvalId.trim();
|
||||
if (!id) return;
|
||||
const useDomainIntents = params.useDomainIntents ?? isStudioDomainIntentModeEnabled();
|
||||
|
||||
const resolvePendingApproval = (
|
||||
approvalId: string,
|
||||
@@ -114,7 +118,11 @@ export const resolveExecApprovalViaStudio = async (params: {
|
||||
setLocalApprovalState(true, null);
|
||||
|
||||
try {
|
||||
await params.client.call("exec.approval.resolve", { id, decision: params.decision });
|
||||
if (useDomainIntents) {
|
||||
await postStudioIntent("/api/intents/exec-approval-resolve", { id, decision: params.decision });
|
||||
} else {
|
||||
await params.client.call("exec.approval.resolve", { id, decision: params.decision });
|
||||
}
|
||||
removeLocalApproval(id);
|
||||
|
||||
if (params.decision !== "allow-once" && params.decision !== "allow-always") {
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from "@/features/agents/approvals/execApprovalRunControlWorkflow";
|
||||
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
|
||||
import { postStudioIntent } from "@/lib/controlplane/intents-client";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
import { EXEC_APPROVAL_AUTO_RESUME_MARKER } from "@/lib/text/message-extract";
|
||||
|
||||
@@ -44,6 +46,7 @@ export async function runPauseRunForExecApprovalOperation(params: {
|
||||
logWarn?: (message: string, error: unknown) => void;
|
||||
}): Promise<void> {
|
||||
if (params.status !== "connected") return;
|
||||
const useDomainIntents = isStudioDomainIntentModeEnabled();
|
||||
|
||||
const plan = planPauseRunControl({
|
||||
approval: params.approval,
|
||||
@@ -60,9 +63,15 @@ export async function runPauseRunForExecApprovalOperation(params: {
|
||||
|
||||
params.pausedRunIdByAgentId.set(plan.pauseIntent.agentId, plan.pauseIntent.runId);
|
||||
try {
|
||||
await params.client.call("chat.abort", {
|
||||
sessionKey: plan.pauseIntent.sessionKey,
|
||||
});
|
||||
if (useDomainIntents) {
|
||||
await postStudioIntent("/api/intents/chat-abort", {
|
||||
sessionKey: plan.pauseIntent.sessionKey,
|
||||
});
|
||||
} else {
|
||||
await params.client.call("chat.abort", {
|
||||
sessionKey: plan.pauseIntent.sessionKey,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
params.pausedRunIdByAgentId.delete(plan.pauseIntent.agentId);
|
||||
if (!params.isDisconnectLikeError(error)) {
|
||||
@@ -88,6 +97,7 @@ export async function runExecApprovalAutoResumeOperation(params: {
|
||||
sendChatMessage?: typeof sendChatMessageViaStudio;
|
||||
now?: () => number;
|
||||
}): Promise<void> {
|
||||
const useDomainIntents = isStudioDomainIntentModeEnabled();
|
||||
const sendChatMessage = params.sendChatMessage ?? sendChatMessageViaStudio;
|
||||
const pendingState = params.getPendingState();
|
||||
const prePlan = planAutoResumeRunControl({
|
||||
@@ -114,10 +124,17 @@ export async function runExecApprovalAutoResumeOperation(params: {
|
||||
});
|
||||
|
||||
try {
|
||||
await params.client.call("agent.wait", {
|
||||
runId: preWaitIntent.pausedRunId,
|
||||
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
if (useDomainIntents) {
|
||||
await postStudioIntent("/api/intents/agent-wait", {
|
||||
runId: preWaitIntent.pausedRunId,
|
||||
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
} else {
|
||||
await params.client.call("agent.wait", {
|
||||
runId: preWaitIntent.pausedRunId,
|
||||
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!params.isDisconnectLikeError(error)) {
|
||||
(params.logWarn ?? ((message, err) => console.warn(message, err)))(
|
||||
@@ -200,7 +217,7 @@ export async function runResolveExecApprovalOperation(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export function executeExecApprovalIngressCommands(params: {
|
||||
function executeExecApprovalIngressCommands(params: {
|
||||
commands: ExecApprovalIngressCommand[];
|
||||
replacePendingState: (nextPendingState: ExecApprovalPendingSnapshot) => void;
|
||||
pauseRunForApproval: (
|
||||
|
||||
@@ -98,8 +98,6 @@ type AgentSettingsPanelProps = {
|
||||
onUpdateAgentPermissions?: (draft: AgentPermissionsDraft) => Promise<void> | void;
|
||||
onDelete: () => void;
|
||||
canDelete?: boolean;
|
||||
onToolCallingToggle: (enabled: boolean) => void;
|
||||
onThinkingTracesToggle: (enabled: boolean) => void;
|
||||
cronJobs: CronJobSummary[];
|
||||
cronLoading: boolean;
|
||||
cronError: string | null;
|
||||
@@ -301,8 +299,6 @@ export const AgentSettingsPanel = ({
|
||||
onUpdateAgentPermissions = () => {},
|
||||
onDelete,
|
||||
canDelete = true,
|
||||
onToolCallingToggle,
|
||||
onThinkingTracesToggle,
|
||||
cronJobs,
|
||||
cronLoading,
|
||||
cronError,
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
upsertGatewayAgentExecApprovals,
|
||||
} from "@/lib/gateway/execApprovals";
|
||||
import { readConfigAgentList, updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig";
|
||||
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
|
||||
import { postStudioIntent } from "@/lib/controlplane/intents-client";
|
||||
|
||||
export type ExecutionRoleId = "conservative" | "collaborative" | "autonomous";
|
||||
export type CommandModeId = "off" | "ask" | "auto";
|
||||
@@ -78,18 +80,6 @@ export const resolvePresetDefaultsForRole = (role: ExecutionRoleId): AgentPermis
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveEffectivePermissionsSummary = (draft: AgentPermissionsDraft): string => {
|
||||
const commandLabel =
|
||||
draft.commandMode === "auto"
|
||||
? "Commands: Auto"
|
||||
: draft.commandMode === "ask"
|
||||
? "Commands: Ask"
|
||||
: "Commands: Off";
|
||||
const webLabel = draft.webAccess ? "Web: On" : "Web: Off";
|
||||
const fileLabel = draft.fileTools ? "File tools: On" : "File tools: Off";
|
||||
return `${commandLabel} | ${webLabel} | ${fileLabel}`;
|
||||
};
|
||||
|
||||
export const isPermissionsCustom = (params: {
|
||||
role: ExecutionRoleId;
|
||||
draft: AgentPermissionsDraft;
|
||||
@@ -303,7 +293,16 @@ const upsertExecApprovalsPolicyForRole = async (params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
role: ExecutionRoleId;
|
||||
useDomainIntents?: boolean;
|
||||
}) => {
|
||||
const useDomainIntents = params.useDomainIntents ?? isStudioDomainIntentModeEnabled();
|
||||
if (useDomainIntents) {
|
||||
await postStudioIntent("/api/intents/exec-approvals-set", {
|
||||
agentId: params.agentId,
|
||||
role: params.role,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const existingPolicy = await readGatewayAgentExecApprovals({
|
||||
client: params.client,
|
||||
agentId: params.agentId,
|
||||
@@ -324,6 +323,7 @@ export async function updateAgentPermissionsViaStudio(params: {
|
||||
sessionKey: string;
|
||||
draft: AgentPermissionsDraft;
|
||||
loadAgents?: () => Promise<void>;
|
||||
useDomainIntents?: boolean;
|
||||
}): Promise<void> {
|
||||
const agentId = params.agentId.trim();
|
||||
if (!agentId) {
|
||||
@@ -335,6 +335,7 @@ export async function updateAgentPermissionsViaStudio(params: {
|
||||
client: params.client,
|
||||
agentId,
|
||||
role,
|
||||
useDomainIntents: params.useDomainIntents,
|
||||
});
|
||||
const runtimeConfigContext = await resolveAgentRuntimeConfigContext({
|
||||
client: params.client,
|
||||
@@ -377,6 +378,7 @@ export async function updateExecutionRoleViaStudio(params: {
|
||||
sessionKey: string;
|
||||
role: ExecutionRoleId;
|
||||
loadAgents: () => Promise<void>;
|
||||
useDomainIntents?: boolean;
|
||||
}): Promise<void> {
|
||||
const agentId = params.agentId.trim();
|
||||
if (!agentId) {
|
||||
@@ -387,6 +389,7 @@ export async function updateExecutionRoleViaStudio(params: {
|
||||
client: params.client,
|
||||
agentId,
|
||||
role: params.role,
|
||||
useDomainIntents: params.useDomainIntents,
|
||||
});
|
||||
const runtimeConfigContext = await resolveAgentRuntimeConfigContext({
|
||||
client: params.client,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type MutationStartGuardResult,
|
||||
} from "@/features/agents/operations/mutationLifecycleWorkflow";
|
||||
|
||||
export const RESERVED_MAIN_AGENT_ID = "main";
|
||||
const RESERVED_MAIN_AGENT_ID = "main";
|
||||
|
||||
type GuardedActionKind =
|
||||
| "delete-agent"
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
isMetaMarkdown,
|
||||
parseMetaMarkdown,
|
||||
} from "@/lib/text/message-extract";
|
||||
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
|
||||
import { postStudioIntent } from "@/lib/controlplane/intents-client";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { randomUUID } from "@/lib/uuid";
|
||||
import type { TranscriptAppendMeta } from "@/features/agents/state/transcript";
|
||||
@@ -71,6 +73,7 @@ export async function sendChatMessageViaStudio(params: {
|
||||
echoUserMessage?: boolean;
|
||||
now?: () => number;
|
||||
generateRunId?: () => string;
|
||||
useDomainIntents?: boolean;
|
||||
}): Promise<void> {
|
||||
const trimmed = params.message.trim();
|
||||
if (!trimmed) return;
|
||||
@@ -78,6 +81,7 @@ export async function sendChatMessageViaStudio(params: {
|
||||
|
||||
const generateRunId = params.generateRunId ?? (() => randomUUID());
|
||||
const now = params.now ?? (() => Date.now());
|
||||
const useDomainIntents = params.useDomainIntents ?? isStudioDomainIntentModeEnabled();
|
||||
|
||||
const agentId = params.agentId;
|
||||
const runId = generateRunId();
|
||||
@@ -184,12 +188,23 @@ export async function sendChatMessageViaStudio(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const sendResult = await params.client.call("chat.send", {
|
||||
const sendPayload = {
|
||||
sessionKey: params.sessionKey,
|
||||
message: buildAgentInstruction({ message: trimmed }),
|
||||
deliver: false,
|
||||
idempotencyKey: runId,
|
||||
});
|
||||
};
|
||||
const sendResult = useDomainIntents
|
||||
? await postStudioIntent<unknown>("/api/intents/chat-send", sendPayload).then(
|
||||
(result) =>
|
||||
(result &&
|
||||
typeof result === "object" &&
|
||||
"payload" in result &&
|
||||
(result as { payload?: unknown }).payload !== undefined
|
||||
? (result as { payload: unknown }).payload
|
||||
: result) as unknown
|
||||
)
|
||||
: await params.client.call("chat.send", sendPayload);
|
||||
|
||||
if (!createdSession) {
|
||||
params.dispatch({
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "@/lib/cron/types";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
export const CRON_ACTION_BUSY_MESSAGE = "Please wait for the current cron action to finish.";
|
||||
const CRON_ACTION_BUSY_MESSAGE = "Please wait for the current cron action to finish.";
|
||||
|
||||
const resolveCreateAgentId = (agentId: string) => {
|
||||
const trimmed = agentId.trim();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type CronJobRestoreInput,
|
||||
} from "@/lib/cron/types";
|
||||
import { deleteGatewayAgent } from "@/lib/gateway/agentConfig";
|
||||
import { postStudioIntent } from "@/lib/controlplane/intents-client";
|
||||
|
||||
type FetchJson = typeof defaultFetchJson;
|
||||
|
||||
@@ -74,6 +75,7 @@ export const deleteAgentViaStudio = async (params: {
|
||||
agentId: string;
|
||||
fetchJson?: FetchJson;
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
useDomainIntents?: boolean;
|
||||
}): Promise<DeleteAgentTransactionResult> => {
|
||||
const fetchJson = params.fetchJson ?? defaultFetchJson;
|
||||
const logError = params.logError ?? ((message, error) => console.error(message, error));
|
||||
@@ -109,6 +111,10 @@ export const deleteAgentViaStudio = async (params: {
|
||||
await restoreCronJobs(params.client, jobs);
|
||||
},
|
||||
deleteGatewayAgent: async (agentId) => {
|
||||
if (params.useDomainIntents) {
|
||||
await postStudioIntent("/api/intents/agent-delete", { agentId });
|
||||
return;
|
||||
}
|
||||
await deleteGatewayAgent({ client: params.client, agentId });
|
||||
},
|
||||
logError,
|
||||
|
||||
@@ -19,7 +19,7 @@ export type ReconcileEligibility = {
|
||||
const SUMMARY_PREVIEW_LIMIT = 8;
|
||||
const SUMMARY_PREVIEW_MAX_CHARS = 240;
|
||||
|
||||
export const resolveSummarySnapshotKeys = (params: {
|
||||
const resolveSummarySnapshotKeys = (params: {
|
||||
agents: Array<{ sessionCreated: boolean; sessionKey: string }>;
|
||||
maxKeys: number;
|
||||
}): string[] => {
|
||||
|
||||
@@ -416,13 +416,6 @@ export const resolveConfigMutationStatusLine = (params: {
|
||||
: "Gateway restart in progress";
|
||||
};
|
||||
|
||||
export const buildAwaitingRestartPatch = (): AwaitingRestartPatch => {
|
||||
return {
|
||||
phase: "awaiting-restart",
|
||||
sawDisconnect: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveConfigMutationPostRunEffects = (
|
||||
result: MutationWorkflowResult
|
||||
): MutationWorkflowPostRunEffects => {
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
planFocusedSelectionPatch,
|
||||
} from "@/features/agents/operations/studioBootstrapWorkflow";
|
||||
import type { AgentState, AgentStoreSeed, FocusFilter } from "@/features/agents/state/store";
|
||||
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
|
||||
import { fetchJson } from "@/lib/http";
|
||||
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
|
||||
import type { StudioSettings, StudioSettingsPatch } from "@/lib/studio/settings";
|
||||
|
||||
@@ -31,14 +33,26 @@ export async function runStudioBootstrapLoadOperation(params: {
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
}): Promise<StudioBootstrapLoadCommand[]> {
|
||||
try {
|
||||
const result = await hydrateAgentFleetFromGateway({
|
||||
client: params.client,
|
||||
gatewayUrl: params.gatewayUrl,
|
||||
cachedConfigSnapshot: params.cachedConfigSnapshot,
|
||||
loadStudioSettings: params.loadStudioSettings,
|
||||
isDisconnectLikeError: params.isDisconnectLikeError,
|
||||
logError: params.logError,
|
||||
});
|
||||
const result = isStudioDomainIntentModeEnabled()
|
||||
? (
|
||||
await fetchJson<{ result: Awaited<ReturnType<typeof hydrateAgentFleetFromGateway>> }>(
|
||||
"/api/runtime/fleet",
|
||||
{
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cachedConfigSnapshot: params.cachedConfigSnapshot }),
|
||||
}
|
||||
)
|
||||
).result
|
||||
: await hydrateAgentFleetFromGateway({
|
||||
client: params.client,
|
||||
gatewayUrl: params.gatewayUrl,
|
||||
cachedConfigSnapshot: params.cachedConfigSnapshot,
|
||||
loadStudioSettings: params.loadStudioSettings,
|
||||
isDisconnectLikeError: params.isDisconnectLikeError,
|
||||
logError: params.logError,
|
||||
});
|
||||
|
||||
const selectionIntent = planBootstrapSelection({
|
||||
hasCurrentSelection: params.hasCurrentSelection,
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
updateGatewayAgentSkillsAllowlist,
|
||||
} from "@/lib/gateway/agentConfig";
|
||||
import { fetchJson } from "@/lib/http";
|
||||
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
|
||||
import { postStudioIntent } from "@/lib/controlplane/intents-client";
|
||||
import { canRemoveSkillSource, filterOsCompatibleSkills } from "@/lib/skills/presentation";
|
||||
import { removeSkillFromGateway } from "@/lib/skills/remove";
|
||||
import {
|
||||
@@ -78,6 +80,7 @@ export type UseAgentSettingsMutationControllerParams = {
|
||||
};
|
||||
|
||||
export function useAgentSettingsMutationController(params: UseAgentSettingsMutationControllerParams) {
|
||||
const useDomainIntents = isStudioDomainIntentModeEnabled();
|
||||
const skillsLoadRequestIdRef = useRef(0);
|
||||
const [settingsSkillsReport, setSettingsSkillsReport] = useState<SkillStatusReport | null>(null);
|
||||
const [settingsSkillsLoading, setSettingsSkillsLoading] = useState(false);
|
||||
@@ -107,7 +110,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
|
||||
const mutationContext: AgentSettingsMutationContext = useMemo(
|
||||
() => ({
|
||||
status: params.status,
|
||||
status: useDomainIntents ? "connected" : params.status,
|
||||
hasCreateBlock: params.hasCreateBlock,
|
||||
hasRenameBlock: hasRenameMutationBlock,
|
||||
hasDeleteBlock: hasDeleteMutationBlock,
|
||||
@@ -123,6 +126,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
hasRenameMutationBlock,
|
||||
params.hasCreateBlock,
|
||||
params.status,
|
||||
useDomainIntents,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -455,12 +459,13 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
agentId: decision.normalizedAgentId,
|
||||
fetchJson,
|
||||
logError: (message, error) => console.error(message, error),
|
||||
useDomainIntents,
|
||||
});
|
||||
params.clearInspectSidebar();
|
||||
},
|
||||
});
|
||||
},
|
||||
[mutationContext, params, runRestartingMutationLifecycle]
|
||||
[mutationContext, params, runRestartingMutationLifecycle, useDomainIntents]
|
||||
);
|
||||
|
||||
const handleCreateCronJob = useCallback(
|
||||
@@ -587,16 +592,23 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
agentName: name,
|
||||
label: `Rename ${agent.name}`,
|
||||
executeMutation: async () => {
|
||||
await renameGatewayAgent({
|
||||
client: params.client,
|
||||
agentId: decision.normalizedAgentId,
|
||||
name,
|
||||
});
|
||||
if (useDomainIntents) {
|
||||
await postStudioIntent("/api/intents/agent-rename", {
|
||||
agentId: decision.normalizedAgentId,
|
||||
name,
|
||||
});
|
||||
} else {
|
||||
await renameGatewayAgent({
|
||||
client: params.client,
|
||||
agentId: decision.normalizedAgentId,
|
||||
name,
|
||||
});
|
||||
}
|
||||
params.dispatchUpdateAgent(decision.normalizedAgentId, { name });
|
||||
},
|
||||
});
|
||||
},
|
||||
[mutationContext, params, runRestartingMutationLifecycle]
|
||||
[mutationContext, params, runRestartingMutationLifecycle, useDomainIntents]
|
||||
);
|
||||
|
||||
const handleUpdateAgentPermissions = useCallback(
|
||||
@@ -625,6 +637,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
sessionKey: agent.sessionKey,
|
||||
draft,
|
||||
loadAgents: async () => {},
|
||||
useDomainIntents,
|
||||
});
|
||||
await params.loadAgents();
|
||||
await params.refreshGatewayConfigSnapshot();
|
||||
|
||||
@@ -11,6 +11,8 @@ import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendO
|
||||
import { mergePendingLivePatch } from "@/features/agents/state/livePatchQueue";
|
||||
import { buildNewSessionAgentPatch, type AgentState } from "@/features/agents/state/store";
|
||||
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
|
||||
import { postStudioIntent } from "@/lib/controlplane/intents-client";
|
||||
|
||||
type ChatInteractionDispatchAction =
|
||||
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
|
||||
@@ -54,6 +56,7 @@ export type ChatInteractionController = {
|
||||
export function useChatInteractionController(
|
||||
params: UseChatInteractionControllerParams
|
||||
): ChatInteractionController {
|
||||
const useDomainIntents = isStudioDomainIntentModeEnabled();
|
||||
const [stopBusyAgentId, setStopBusyAgentId] = useState<string | null>(null);
|
||||
const stopBusyAgentIdRef = useRef<string | null>(stopBusyAgentId);
|
||||
const pendingDraftValuesRef = useRef<Map<string, string>>(new Map());
|
||||
@@ -308,9 +311,13 @@ export function useChatInteractionController(
|
||||
setStopBusyAgentId(agentId);
|
||||
stopBusyAgentIdRef.current = agentId;
|
||||
try {
|
||||
await params.client.call("chat.abort", {
|
||||
sessionKey: stopIntent.sessionKey,
|
||||
});
|
||||
if (useDomainIntents) {
|
||||
await postStudioIntent("/api/intents/chat-abort", { sessionKey: stopIntent.sessionKey });
|
||||
} else {
|
||||
await params.client.call("chat.abort", {
|
||||
sessionKey: stopIntent.sessionKey,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to stop run.";
|
||||
params.setError(message);
|
||||
@@ -328,7 +335,7 @@ export function useChatInteractionController(
|
||||
});
|
||||
}
|
||||
},
|
||||
[params]
|
||||
[params, useDomainIntents]
|
||||
);
|
||||
|
||||
const handleNewSession = useCallback(
|
||||
@@ -348,7 +355,11 @@ export function useChatInteractionController(
|
||||
if (newSessionIntent.kind === "deny") {
|
||||
throw new Error(newSessionIntent.message);
|
||||
}
|
||||
await params.client.call("sessions.reset", { key: newSessionIntent.sessionKey });
|
||||
if (useDomainIntents) {
|
||||
await postStudioIntent("/api/intents/sessions-reset", { key: newSessionIntent.sessionKey });
|
||||
} else {
|
||||
await params.client.call("sessions.reset", { key: newSessionIntent.sessionKey });
|
||||
}
|
||||
const patch = buildNewSessionAgentPatch(agent);
|
||||
params.clearRunTracking(agent.runId);
|
||||
params.clearHistoryInFlight(newSessionIntent.sessionKey);
|
||||
@@ -371,7 +382,7 @@ export function useChatInteractionController(
|
||||
});
|
||||
}
|
||||
},
|
||||
[params]
|
||||
[params, useDomainIntents]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { TRANSCRIPT_V2_ENABLED, logTranscriptDebugMetric } from "@/features/agents/state/transcript";
|
||||
import { randomUUID } from "@/lib/uuid";
|
||||
import { fetchJson } from "@/lib/http";
|
||||
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
|
||||
|
||||
type RuntimeSyncDispatchAction = {
|
||||
type: "updateAgent";
|
||||
@@ -36,7 +38,7 @@ type RuntimeSyncDispatchAction = {
|
||||
|
||||
type GatewayClientLike = {
|
||||
call: <T = unknown>(method: string, params: unknown) => Promise<T>;
|
||||
onGap: (handler: (info: { expected: number; received: number }) => void) => () => void;
|
||||
onGap?: (handler: (info: { expected: number; received: number }) => void) => () => void;
|
||||
};
|
||||
|
||||
export type UseRuntimeSyncControllerParams = {
|
||||
@@ -63,6 +65,7 @@ export type RuntimeSyncController = {
|
||||
export function useRuntimeSyncController(
|
||||
params: UseRuntimeSyncControllerParams
|
||||
): RuntimeSyncController {
|
||||
const useDomainApiReads = isStudioDomainIntentModeEnabled();
|
||||
const agentsRef = useRef(params.agents);
|
||||
const historyInFlightRef = useRef<Set<string>>(new Set());
|
||||
const reconcileRunInFlightRef = useRef<Set<string>>(new Set());
|
||||
@@ -81,6 +84,18 @@ export function useRuntimeSyncController(
|
||||
}, []);
|
||||
|
||||
const loadSummarySnapshot = useCallback(async () => {
|
||||
if (useDomainApiReads) {
|
||||
try {
|
||||
await fetchJson<{ summary?: unknown; freshness?: unknown }>("/api/runtime/summary", {
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (error) {
|
||||
if (!params.isDisconnectLikeError(error)) {
|
||||
console.error("Failed to load domain runtime summary.", error);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const snapshotAgents = agentsRef.current;
|
||||
const summaryIntent = resolveSummarySnapshotIntent({
|
||||
agents: snapshotAgents,
|
||||
@@ -113,10 +128,48 @@ export function useRuntimeSyncController(
|
||||
console.error("Failed to load summary snapshot.", error);
|
||||
}
|
||||
}
|
||||
}, [params.client, params.dispatch, params.isDisconnectLikeError]);
|
||||
}, [params.client, params.dispatch, params.isDisconnectLikeError, useDomainApiReads]);
|
||||
|
||||
const loadAgentHistoryViaDomainApi = useCallback(
|
||||
async (agentId: string, limit: number) => {
|
||||
const encodedAgentId = encodeURIComponent(agentId.trim());
|
||||
if (!encodedAgentId) return;
|
||||
const result = await fetchJson<{ entries?: unknown[] }>(
|
||||
`/api/runtime/agents/${encodedAgentId}/history?limit=${limit}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const entries = Array.isArray(result.entries) ? result.entries : [];
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: {
|
||||
historyLoadedAt: Date.now(),
|
||||
historyFetchLimit: limit,
|
||||
historyFetchedCount: entries.length,
|
||||
historyMaybeTruncated: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
[params.dispatch]
|
||||
);
|
||||
|
||||
const loadAgentHistory = useCallback(
|
||||
async (agentId: string, options?: { limit?: number }) => {
|
||||
if (useDomainApiReads) {
|
||||
const agent = agentsRef.current.find((entry) => entry.agentId === agentId) ?? null;
|
||||
const limit =
|
||||
typeof options?.limit === "number" && Number.isFinite(options.limit)
|
||||
? Math.max(1, Math.floor(options.limit))
|
||||
: agent?.historyFetchLimit ?? defaultHistoryLimit;
|
||||
try {
|
||||
await loadAgentHistoryViaDomainApi(agentId, limit);
|
||||
} catch (error) {
|
||||
if (!params.isDisconnectLikeError(error)) {
|
||||
console.error("Failed to load domain runtime history.", error);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: params.client,
|
||||
agentId,
|
||||
@@ -140,10 +193,12 @@ export function useRuntimeSyncController(
|
||||
},
|
||||
[
|
||||
defaultHistoryLimit,
|
||||
loadAgentHistoryViaDomainApi,
|
||||
maxHistoryLimit,
|
||||
params.client,
|
||||
params.dispatch,
|
||||
params.isDisconnectLikeError,
|
||||
useDomainApiReads,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -162,6 +217,7 @@ export function useRuntimeSyncController(
|
||||
|
||||
const reconcileRunningAgents = useCallback(async () => {
|
||||
if (params.status !== "connected") return;
|
||||
if (useDomainApiReads) return;
|
||||
const commands = await runAgentReconcileOperation({
|
||||
client: params.client,
|
||||
agents: agentsRef.current,
|
||||
@@ -198,6 +254,7 @@ export function useRuntimeSyncController(
|
||||
params.dispatch,
|
||||
params.isDisconnectLikeError,
|
||||
params.status,
|
||||
useDomainApiReads,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -251,6 +308,8 @@ export function useRuntimeSyncController(
|
||||
}, [loadAgentHistory, params.focusedAgentId, params.focusedAgentRunning, params.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (useDomainApiReads) return;
|
||||
if (!params.client.onGap) return;
|
||||
return params.client.onGap((info) => {
|
||||
const recoveryIntent = resolveRuntimeSyncGapRecoveryIntent();
|
||||
console.warn(`Gateway event gap expected ${info.expected}, received ${info.received}.`);
|
||||
@@ -261,7 +320,7 @@ export function useRuntimeSyncController(
|
||||
void reconcileRunningAgents();
|
||||
}
|
||||
});
|
||||
}, [loadSummarySnapshot, params.client, reconcileRunningAgents]);
|
||||
}, [loadSummarySnapshot, params.client, reconcileRunningAgents, useDomainApiReads]);
|
||||
|
||||
return {
|
||||
loadSummarySnapshot,
|
||||
|
||||
@@ -18,7 +18,9 @@ export const mergePendingLivePatch = (
|
||||
}
|
||||
|
||||
if (incomingRunId && !existingRunId) {
|
||||
const { streamText: _dropStreamText, thinkingTrace: _dropThinkingTrace, ...rest } = existing;
|
||||
const rest = { ...existing };
|
||||
delete rest.streamText;
|
||||
delete rest.thinkingTrace;
|
||||
return { ...rest, ...incoming };
|
||||
}
|
||||
|
||||
|
||||
@@ -10,66 +10,9 @@ export const AGENT_FILE_NAMES = [
|
||||
|
||||
export type AgentFileName = (typeof AGENT_FILE_NAMES)[number];
|
||||
|
||||
export const PERSONALITY_FILE_NAMES = [
|
||||
"SOUL.md",
|
||||
"AGENTS.md",
|
||||
"USER.md",
|
||||
"IDENTITY.md",
|
||||
] as const satisfies readonly AgentFileName[];
|
||||
|
||||
export type PersonalityFileName = (typeof PERSONALITY_FILE_NAMES)[number];
|
||||
|
||||
export const PERSONALITY_FILE_LABELS: Record<PersonalityFileName, string> = {
|
||||
"SOUL.md": "Persona",
|
||||
"AGENTS.md": "Directives",
|
||||
"USER.md": "Context",
|
||||
"IDENTITY.md": "Identity",
|
||||
};
|
||||
|
||||
export const isAgentFileName = (value: string): value is AgentFileName =>
|
||||
AGENT_FILE_NAMES.includes(value as AgentFileName);
|
||||
|
||||
export const AGENT_FILE_META: Record<AgentFileName, { title: string; hint: string }> = {
|
||||
"AGENTS.md": {
|
||||
title: "AGENTS.md",
|
||||
hint: "Operating instructions, priorities, and rules.",
|
||||
},
|
||||
"SOUL.md": {
|
||||
title: "SOUL.md",
|
||||
hint: "Persona, tone, and boundaries.",
|
||||
},
|
||||
"IDENTITY.md": {
|
||||
title: "IDENTITY.md",
|
||||
hint: "Name, vibe, and emoji.",
|
||||
},
|
||||
"USER.md": {
|
||||
title: "USER.md",
|
||||
hint: "User profile and preferences.",
|
||||
},
|
||||
"TOOLS.md": {
|
||||
title: "TOOLS.md",
|
||||
hint: "Local tool notes and conventions.",
|
||||
},
|
||||
"HEARTBEAT.md": {
|
||||
title: "HEARTBEAT.md",
|
||||
hint: "Small checklist for heartbeat runs.",
|
||||
},
|
||||
"MEMORY.md": {
|
||||
title: "MEMORY.md",
|
||||
hint: "Durable memory for this agent.",
|
||||
},
|
||||
};
|
||||
|
||||
export const AGENT_FILE_PLACEHOLDERS: Record<AgentFileName, string> = {
|
||||
"AGENTS.md": "How should this agent work? Priorities, rules, and habits.",
|
||||
"SOUL.md": "Tone, personality, boundaries, and how it should sound.",
|
||||
"IDENTITY.md": "Name, vibe, emoji, and a one-line identity.",
|
||||
"USER.md": "How should it address you? Preferences and context.",
|
||||
"TOOLS.md": "Local tool notes, conventions, and shortcuts.",
|
||||
"HEARTBEAT.md": "A tiny checklist for periodic runs.",
|
||||
"MEMORY.md": "Durable facts, decisions, and preferences to remember.",
|
||||
};
|
||||
|
||||
export const createAgentFilesState = () =>
|
||||
Object.fromEntries(
|
||||
AGENT_FILE_NAMES.map((name) => [name, { content: "", exists: false }])
|
||||
|
||||
-1
File diff suppressed because one or more lines are too long
@@ -2,10 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moltbot"] as const;
|
||||
const NEW_STATE_DIRNAME = ".openclaw";
|
||||
const CONFIG_FILENAME = "openclaw.json";
|
||||
const LEGACY_CONFIG_FILENAMES = ["clawdbot.json", "moltbot.json"] as const;
|
||||
|
||||
const resolveDefaultHomeDir = (homedir: () => string = os.homedir): string => {
|
||||
const home = homedir();
|
||||
@@ -38,55 +35,8 @@ export const resolveStateDir = (
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = os.homedir
|
||||
): string => {
|
||||
const override =
|
||||
env.OPENCLAW_STATE_DIR?.trim() ||
|
||||
env.MOLTBOT_STATE_DIR?.trim() ||
|
||||
env.CLAWDBOT_STATE_DIR?.trim();
|
||||
const override = env.OPENCLAW_STATE_DIR?.trim();
|
||||
if (override) return resolveUserPath(override, homedir);
|
||||
const defaultHome = resolveDefaultHomeDir(homedir);
|
||||
const newDir = path.join(defaultHome, NEW_STATE_DIRNAME);
|
||||
const legacyDirs = LEGACY_STATE_DIRNAMES.map((dir) => path.join(defaultHome, dir));
|
||||
const hasNew = fs.existsSync(newDir);
|
||||
if (hasNew) return newDir;
|
||||
const existingLegacy = legacyDirs.find((dir) => {
|
||||
try {
|
||||
return fs.existsSync(dir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return existingLegacy ?? newDir;
|
||||
};
|
||||
|
||||
export const resolveConfigPathCandidates = (
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = os.homedir
|
||||
): string[] => {
|
||||
const explicit =
|
||||
env.OPENCLAW_CONFIG_PATH?.trim() ||
|
||||
env.MOLTBOT_CONFIG_PATH?.trim() ||
|
||||
env.CLAWDBOT_CONFIG_PATH?.trim();
|
||||
if (explicit) return [resolveUserPath(explicit, homedir)];
|
||||
|
||||
const defaultHome = resolveDefaultHomeDir(homedir);
|
||||
const candidates: string[] = [];
|
||||
const stateDir =
|
||||
env.OPENCLAW_STATE_DIR?.trim() ||
|
||||
env.MOLTBOT_STATE_DIR?.trim() ||
|
||||
env.CLAWDBOT_STATE_DIR?.trim();
|
||||
if (stateDir) {
|
||||
const resolved = resolveUserPath(stateDir, homedir);
|
||||
candidates.push(path.join(resolved, CONFIG_FILENAME));
|
||||
candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(resolved, name)));
|
||||
}
|
||||
|
||||
const defaultDirs = [
|
||||
path.join(defaultHome, NEW_STATE_DIRNAME),
|
||||
...LEGACY_STATE_DIRNAMES.map((dir) => path.join(defaultHome, dir)),
|
||||
];
|
||||
for (const dir of defaultDirs) {
|
||||
candidates.push(path.join(dir, CONFIG_FILENAME));
|
||||
candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(dir, name)));
|
||||
}
|
||||
return candidates;
|
||||
return path.join(defaultHome, NEW_STATE_DIRNAME);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
export type ControlPlaneConnectionStatus =
|
||||
| "stopped"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "error";
|
||||
|
||||
export type ControlPlaneDomainEvent =
|
||||
| {
|
||||
type: "runtime.status";
|
||||
status: ControlPlaneConnectionStatus;
|
||||
asOf: string;
|
||||
reason: string | null;
|
||||
}
|
||||
| {
|
||||
type: "gateway.event";
|
||||
event: string;
|
||||
seq: number | null;
|
||||
payload: unknown;
|
||||
asOf: string;
|
||||
};
|
||||
|
||||
export type ControlPlaneOutboxEntry = {
|
||||
id: number;
|
||||
event: ControlPlaneDomainEvent;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type GatewayEventFrame = {
|
||||
type: "event";
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
seq?: number;
|
||||
};
|
||||
|
||||
export type GatewayResponseFrame = {
|
||||
type: "res";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
error?: { code: string; message: string; details?: unknown };
|
||||
};
|
||||
|
||||
export type ControlPlaneGatewaySettings = {
|
||||
url: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type ControlPlaneRuntimeSnapshot = {
|
||||
status: ControlPlaneConnectionStatus;
|
||||
reason: string | null;
|
||||
asOf: string | null;
|
||||
outboxHead: number;
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import type { ControlPlaneRuntimeSnapshot } from "@/lib/controlplane/contracts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CLI_TIMEOUT_MS = 1_500;
|
||||
|
||||
type RuntimeProbeCommandResult =
|
||||
| { ok: true; value: unknown }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type RuntimeProbeSnapshot = {
|
||||
at: string;
|
||||
status: RuntimeProbeCommandResult;
|
||||
sessions: RuntimeProbeCommandResult;
|
||||
};
|
||||
|
||||
export type RuntimeFreshness = {
|
||||
source: "gateway" | "projection" | "probe";
|
||||
stale: boolean;
|
||||
asOf: string | null;
|
||||
reason: "gateway_unavailable" | "probe_only" | "startup" | "controlplane_not_connected" | null;
|
||||
};
|
||||
|
||||
const parseJson = (raw: string): unknown => {
|
||||
return JSON.parse(raw);
|
||||
};
|
||||
|
||||
const resolveProbeError = (error: unknown): string => {
|
||||
if (!(error instanceof Error)) return "probe_failed";
|
||||
if (error.message.includes("ENOENT")) return "openclaw_cli_not_found";
|
||||
if (error.message.includes("timed out")) return "probe_timeout";
|
||||
const normalized = error.message.trim();
|
||||
return normalized || "probe_failed";
|
||||
};
|
||||
|
||||
const runOpenClawJsonCommand = async (args: string[]): Promise<RuntimeProbeCommandResult> => {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("openclaw", args, {
|
||||
timeout: CLI_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
return { ok: true, value: parseJson(stdout) };
|
||||
} catch (error) {
|
||||
return { ok: false, error: resolveProbeError(error) };
|
||||
}
|
||||
};
|
||||
|
||||
export const probeOpenClawLocalState = async (): Promise<RuntimeProbeSnapshot> => {
|
||||
const [status, sessions] = await Promise.all([
|
||||
runOpenClawJsonCommand(["status", "--json"]),
|
||||
runOpenClawJsonCommand(["sessions", "--json"]),
|
||||
]);
|
||||
return {
|
||||
at: new Date().toISOString(),
|
||||
status,
|
||||
sessions,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveFallbackReason = (
|
||||
snapshot: Pick<ControlPlaneRuntimeSnapshot, "status" | "reason" | "asOf">
|
||||
): RuntimeFreshness["reason"] => {
|
||||
if (snapshot.reason && snapshot.reason.trim()) return "gateway_unavailable";
|
||||
if (snapshot.status === "stopped") return "startup";
|
||||
return "controlplane_not_connected";
|
||||
};
|
||||
|
||||
export const deriveRuntimeFreshness = (
|
||||
snapshot: Pick<ControlPlaneRuntimeSnapshot, "status" | "reason" | "asOf">,
|
||||
probe: RuntimeProbeSnapshot | null = null
|
||||
): RuntimeFreshness => {
|
||||
if (snapshot.status === "connected") {
|
||||
return {
|
||||
source: "gateway",
|
||||
stale: false,
|
||||
asOf: snapshot.asOf,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
const probeHealthy = Boolean(probe?.status.ok || probe?.sessions.ok);
|
||||
if (probeHealthy) {
|
||||
return {
|
||||
source: "probe",
|
||||
stale: true,
|
||||
asOf: probe?.at ?? snapshot.asOf,
|
||||
reason: "probe_only",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: "projection",
|
||||
stale: true,
|
||||
asOf: snapshot.asOf,
|
||||
reason: resolveFallbackReason(snapshot),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
|
||||
|
||||
export const isStudioDomainIntentModeEnabled = (): boolean => {
|
||||
const raw = process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE?.trim().toLowerCase() ?? "";
|
||||
if (!raw) return true;
|
||||
return !FALSE_VALUES.has(raw);
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { ControlPlaneRuntime } from "@/lib/controlplane/runtime";
|
||||
import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter";
|
||||
|
||||
export type GatewayExecApprovalSecurity = "deny" | "allowlist" | "full";
|
||||
export type GatewayExecApprovalAsk = "off" | "on-miss" | "always";
|
||||
export type ExecutionRoleId = "conservative" | "collaborative" | "autonomous";
|
||||
|
||||
type ExecAllowlistEntry = {
|
||||
pattern: string;
|
||||
};
|
||||
|
||||
type ExecApprovalsAgent = {
|
||||
security?: GatewayExecApprovalSecurity;
|
||||
ask?: GatewayExecApprovalAsk;
|
||||
askFallback?: string;
|
||||
autoAllowSkills?: boolean;
|
||||
allowlist?: ExecAllowlistEntry[];
|
||||
};
|
||||
|
||||
type ExecApprovalsFile = {
|
||||
version: 1;
|
||||
socket?: {
|
||||
path?: string;
|
||||
token?: string;
|
||||
};
|
||||
defaults?: {
|
||||
security?: string;
|
||||
ask?: string;
|
||||
askFallback?: string;
|
||||
autoAllowSkills?: boolean;
|
||||
};
|
||||
agents?: Record<string, ExecApprovalsAgent>;
|
||||
};
|
||||
|
||||
type ExecApprovalsSnapshot = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
hash: string;
|
||||
file?: ExecApprovalsFile;
|
||||
};
|
||||
|
||||
const normalizeAllowlist = (patterns: Array<{ pattern: string }>): Array<{ pattern: string }> => {
|
||||
const next = patterns
|
||||
.map((entry) => entry.pattern.trim())
|
||||
.filter((pattern) => pattern.length > 0);
|
||||
return Array.from(new Set(next)).map((pattern) => ({ pattern }));
|
||||
};
|
||||
|
||||
const resolvePolicyForRole = (params: {
|
||||
role: ExecutionRoleId;
|
||||
allowlist: Array<{ pattern: string }>;
|
||||
}):
|
||||
| {
|
||||
security: "full" | "allowlist";
|
||||
ask: "off" | "always";
|
||||
allowlist: Array<{ pattern: string }>;
|
||||
}
|
||||
| null => {
|
||||
if (params.role === "conservative") return null;
|
||||
if (params.role === "autonomous") {
|
||||
return { security: "full", ask: "off", allowlist: params.allowlist };
|
||||
}
|
||||
return { security: "allowlist", ask: "always", allowlist: params.allowlist };
|
||||
};
|
||||
|
||||
const isRetryableSetError = (err: unknown): boolean => {
|
||||
if (!(err instanceof ControlPlaneGatewayError)) return false;
|
||||
const message = err.message.toLowerCase();
|
||||
return (
|
||||
err.code.trim().toUpperCase() === "INVALID_REQUEST" &&
|
||||
(message.includes("re-run exec.approvals.get") || message.includes("changed since last load"))
|
||||
);
|
||||
};
|
||||
|
||||
export const upsertAgentExecApprovalsPolicyViaRuntime = async (params: {
|
||||
runtime: ControlPlaneRuntime;
|
||||
agentId: string;
|
||||
role: ExecutionRoleId;
|
||||
}): Promise<void> => {
|
||||
const agentId = params.agentId.trim();
|
||||
if (!agentId) {
|
||||
throw new Error("Agent id is required.");
|
||||
}
|
||||
|
||||
const snapshot = await params.runtime.callGateway<ExecApprovalsSnapshot>("exec.approvals.get", {});
|
||||
const baseFile: ExecApprovalsFile =
|
||||
snapshot.file && typeof snapshot.file === "object"
|
||||
? {
|
||||
version: 1,
|
||||
socket: snapshot.file.socket,
|
||||
defaults: snapshot.file.defaults,
|
||||
agents: { ...(snapshot.file.agents ?? {}) },
|
||||
}
|
||||
: { version: 1, agents: {} };
|
||||
|
||||
const existingAllowlist = Array.isArray(baseFile.agents?.[agentId]?.allowlist)
|
||||
? baseFile.agents?.[agentId]?.allowlist?.filter(
|
||||
(entry): entry is ExecAllowlistEntry =>
|
||||
Boolean(entry && typeof entry.pattern === "string" && entry.pattern.trim().length > 0)
|
||||
) ?? []
|
||||
: [];
|
||||
const policy = resolvePolicyForRole({
|
||||
role: params.role,
|
||||
allowlist: existingAllowlist.map((entry) => ({ pattern: entry.pattern })),
|
||||
});
|
||||
|
||||
const nextAgents = { ...(baseFile.agents ?? {}) };
|
||||
if (!policy) {
|
||||
delete nextAgents[agentId];
|
||||
} else {
|
||||
const existing = nextAgents[agentId] ?? {};
|
||||
nextAgents[agentId] = {
|
||||
...existing,
|
||||
security: policy.security,
|
||||
ask: policy.ask,
|
||||
allowlist: normalizeAllowlist(policy.allowlist),
|
||||
};
|
||||
}
|
||||
|
||||
const nextFile: ExecApprovalsFile = {
|
||||
...baseFile,
|
||||
version: 1,
|
||||
agents: nextAgents,
|
||||
};
|
||||
|
||||
const setPayload = { file: nextFile, ...(snapshot.exists ? { baseHash: snapshot.hash } : {}) };
|
||||
try {
|
||||
await params.runtime.callGateway("exec.approvals.set", setPayload);
|
||||
} catch (err) {
|
||||
if (!isRetryableSetError(err)) throw err;
|
||||
const retrySnapshot = await params.runtime.callGateway<ExecApprovalsSnapshot>("exec.approvals.get", {});
|
||||
await params.runtime.callGateway("exec.approvals.set", {
|
||||
file: nextFile,
|
||||
...(retrySnapshot.exists ? { baseHash: retrySnapshot.hash } : {}),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter";
|
||||
import { getControlPlaneRuntime, isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime";
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const isConfigConflict = (error: ControlPlaneGatewayError): boolean => {
|
||||
const code = error.code.trim().toUpperCase();
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
code === "INVALID_REQUEST" &&
|
||||
(message.includes("basehash") ||
|
||||
message.includes("base hash") ||
|
||||
message.includes("changed since last load") ||
|
||||
message.includes("re-run config.get"))
|
||||
);
|
||||
};
|
||||
|
||||
export const ensureDomainIntentRuntime = async (): Promise<
|
||||
ReturnType<typeof getControlPlaneRuntime> | Response
|
||||
> => {
|
||||
if (!isStudioDomainApiModeEnabled()) {
|
||||
return NextResponse.json({ error: "domain_api_mode_disabled" }, { status: 404 });
|
||||
}
|
||||
const runtime = getControlPlaneRuntime();
|
||||
try {
|
||||
await runtime.ensureStarted();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "controlplane_start_failed";
|
||||
return NextResponse.json(
|
||||
{ error: message, code: "GATEWAY_UNAVAILABLE", reason: "gateway_unavailable" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
return runtime;
|
||||
};
|
||||
|
||||
export const parseIntentBody = async (request: Request): Promise<Record<string, unknown> | Response> => {
|
||||
try {
|
||||
const body = (await request.json()) as unknown;
|
||||
if (!isRecord(body)) {
|
||||
return NextResponse.json({ error: "Invalid intent payload." }, { status: 400 });
|
||||
}
|
||||
return body;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON payload." }, { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
export const executeGatewayIntent = async <T>(
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<NextResponse> => {
|
||||
const runtimeOrError = await ensureDomainIntentRuntime();
|
||||
if (runtimeOrError instanceof Response) {
|
||||
return runtimeOrError as NextResponse;
|
||||
}
|
||||
try {
|
||||
const payload = await runtimeOrError.callGateway<T>(method, params);
|
||||
return NextResponse.json({ ok: true, payload });
|
||||
} catch (err) {
|
||||
if (err instanceof ControlPlaneGatewayError) {
|
||||
if (err.code.trim().toUpperCase() === "GATEWAY_UNAVAILABLE") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: err.message,
|
||||
code: "GATEWAY_UNAVAILABLE",
|
||||
reason: "gateway_unavailable",
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
if (isConfigConflict(err)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
conflict: "base_hash_mismatch",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
details: err.details,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "intent_failed";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { fetchJson } from "@/lib/http";
|
||||
|
||||
export const postStudioIntent = async <T>(path: string, body: Record<string, unknown>): Promise<T> => {
|
||||
return await fetchJson<T>(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,378 @@
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
import type {
|
||||
ControlPlaneConnectionStatus,
|
||||
ControlPlaneDomainEvent,
|
||||
ControlPlaneGatewaySettings,
|
||||
GatewayEventFrame,
|
||||
GatewayResponseFrame,
|
||||
} from "@/lib/controlplane/contracts";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 8_000;
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
const INITIAL_RECONNECT_DELAY_MS = 1_000;
|
||||
const MAX_RECONNECT_DELAY_MS = 15_000;
|
||||
const CONNECT_PROTOCOL = 3;
|
||||
|
||||
const DEFAULT_METHOD_ALLOWLIST = new Set<string>([
|
||||
"status",
|
||||
"chat.send",
|
||||
"chat.abort",
|
||||
"agents.update",
|
||||
"agents.delete",
|
||||
"agents.list",
|
||||
"agents.create",
|
||||
"sessions.list",
|
||||
"sessions.preview",
|
||||
"sessions.patch",
|
||||
"sessions.reset",
|
||||
"config.get",
|
||||
"config.patch",
|
||||
"config.set",
|
||||
"exec.approval.resolve",
|
||||
"exec.approvals.get",
|
||||
"exec.approvals.set",
|
||||
"agent.wait",
|
||||
]);
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
export class ControlPlaneGatewayError extends Error {
|
||||
readonly code: string;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(params: { code: string; message: string; details?: unknown }) {
|
||||
super(params.message);
|
||||
this.name = "ControlPlaneGatewayError";
|
||||
this.code = params.code;
|
||||
this.details = params.details;
|
||||
}
|
||||
}
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object");
|
||||
|
||||
const resolveOriginForUpstream = (upstreamUrl: string): string => {
|
||||
const url = new URL(upstreamUrl);
|
||||
const proto = url.protocol === "wss:" ? "https:" : "http:";
|
||||
const hostname =
|
||||
url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "0.0.0.0"
|
||||
? "localhost"
|
||||
: url.hostname;
|
||||
const host = url.port ? `${hostname}:${url.port}` : hostname;
|
||||
return `${proto}//${host}`;
|
||||
};
|
||||
|
||||
const loadGatewaySettings = (): ControlPlaneGatewaySettings => {
|
||||
const settings = loadStudioSettings();
|
||||
const gateway = settings.gateway;
|
||||
const url = typeof gateway?.url === "string" ? gateway.url.trim() : "";
|
||||
const token = typeof gateway?.token === "string" ? gateway.token.trim() : "";
|
||||
if (!url) {
|
||||
throw new Error("Control-plane start failed: Studio gateway URL is not configured.");
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error("Control-plane start failed: Studio gateway token is not configured.");
|
||||
}
|
||||
return { url, token };
|
||||
};
|
||||
|
||||
export type OpenClawAdapterOptions = {
|
||||
loadSettings?: () => ControlPlaneGatewaySettings;
|
||||
createWebSocket?: (url: string, opts: { origin: string }) => WebSocket;
|
||||
methodAllowlist?: Set<string>;
|
||||
onDomainEvent?: (event: ControlPlaneDomainEvent) => void;
|
||||
};
|
||||
|
||||
export class OpenClawGatewayAdapter {
|
||||
private ws: WebSocket | null = null;
|
||||
private status: ControlPlaneConnectionStatus = "stopped";
|
||||
private statusReason: string | null = null;
|
||||
private connectRequestId: string | null = null;
|
||||
private connectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private startPromise: Promise<void> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempt = 0;
|
||||
private stopping = false;
|
||||
private nextRequestNumber = 1;
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
private loadSettings: () => ControlPlaneGatewaySettings;
|
||||
private createWebSocket: (url: string, opts: { origin: string }) => WebSocket;
|
||||
private methodAllowlist: Set<string>;
|
||||
private onDomainEvent?: (event: ControlPlaneDomainEvent) => void;
|
||||
|
||||
constructor(options?: OpenClawAdapterOptions) {
|
||||
this.loadSettings = options?.loadSettings ?? loadGatewaySettings;
|
||||
this.createWebSocket = options?.createWebSocket ?? ((url, opts) => new WebSocket(url, opts));
|
||||
this.methodAllowlist = options?.methodAllowlist ?? DEFAULT_METHOD_ALLOWLIST;
|
||||
this.onDomainEvent = options?.onDomainEvent;
|
||||
}
|
||||
|
||||
getStatus(): ControlPlaneConnectionStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getStatusReason(): string | null {
|
||||
return this.statusReason;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.status === "connected") return;
|
||||
if (this.startPromise) return this.startPromise;
|
||||
this.stopping = false;
|
||||
this.startPromise = this.connect().finally(() => {
|
||||
this.startPromise = null;
|
||||
});
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopping = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer);
|
||||
this.connectTimer = null;
|
||||
}
|
||||
this.rejectPending("Control-plane adapter stopped.");
|
||||
const ws = this.ws;
|
||||
this.ws = null;
|
||||
this.connectRequestId = null;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.once("close", () => resolve());
|
||||
ws.close(1000, "controlplane stopping");
|
||||
});
|
||||
} else {
|
||||
ws?.terminate();
|
||||
}
|
||||
this.updateStatus("stopped", null);
|
||||
}
|
||||
|
||||
async request<T = unknown>(method: string, params: unknown): Promise<T> {
|
||||
const normalizedMethod = method.trim();
|
||||
if (!normalizedMethod) {
|
||||
throw new Error("Gateway method is required.");
|
||||
}
|
||||
if (!this.methodAllowlist.has(normalizedMethod)) {
|
||||
throw new Error(`Gateway method is not allowlisted: ${normalizedMethod}`);
|
||||
}
|
||||
const ws = this.ws;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN || this.status !== "connected") {
|
||||
throw new ControlPlaneGatewayError({
|
||||
code: "GATEWAY_UNAVAILABLE",
|
||||
message: "Gateway is unavailable.",
|
||||
});
|
||||
}
|
||||
|
||||
const id = String(this.nextRequestNumber++);
|
||||
const frame = { type: "req", id, method: normalizedMethod, params };
|
||||
|
||||
const response = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Gateway request timed out for method: ${normalizedMethod}`));
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
ws.send(JSON.stringify(frame), (err) => {
|
||||
if (!err) return;
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Failed to send gateway request for method: ${normalizedMethod}`));
|
||||
});
|
||||
});
|
||||
|
||||
return response as T;
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
const settings = this.loadSettings();
|
||||
const ws = this.createWebSocket(settings.url, { origin: resolveOriginForUpstream(settings.url) });
|
||||
this.ws = ws;
|
||||
this.connectRequestId = null;
|
||||
this.updateStatus(this.reconnectAttempt > 0 ? "reconnecting" : "connecting", null);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer);
|
||||
this.connectTimer = null;
|
||||
}
|
||||
fn();
|
||||
};
|
||||
|
||||
this.connectTimer = setTimeout(() => {
|
||||
settle(() => {
|
||||
ws.close(1011, "connect timeout");
|
||||
reject(new Error("Control-plane connect timed out waiting for connect response."));
|
||||
});
|
||||
}, CONNECT_TIMEOUT_MS);
|
||||
|
||||
ws.on("message", (raw) => {
|
||||
const parsed = this.parseFrame(String(raw ?? ""));
|
||||
if (!parsed) return;
|
||||
if (parsed.type === "event") {
|
||||
if (parsed.event === "connect.challenge") {
|
||||
this.sendConnectRequest(settings.token);
|
||||
return;
|
||||
}
|
||||
this.emitEvent({
|
||||
type: "gateway.event",
|
||||
event: parsed.event,
|
||||
seq: typeof parsed.seq === "number" ? parsed.seq : null,
|
||||
payload: parsed.payload,
|
||||
asOf: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!this.handleResponseFrame(parsed)) return;
|
||||
if (parsed.id === this.connectRequestId) {
|
||||
if (parsed.ok) {
|
||||
this.reconnectAttempt = 0;
|
||||
this.updateStatus("connected", null);
|
||||
settle(() => resolve());
|
||||
return;
|
||||
}
|
||||
const code = parsed.error?.code ?? "CONNECT_FAILED";
|
||||
const message = parsed.error?.message ?? "Connect failed.";
|
||||
settle(() => {
|
||||
ws.close(1011, "connect failed");
|
||||
reject(new Error(`Control-plane connect rejected: ${code} ${message}`));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (this.stopping) return;
|
||||
if (!settled) {
|
||||
settle(() => reject(new Error("Control-plane gateway connection closed during connect.")));
|
||||
return;
|
||||
}
|
||||
this.updateStatus("reconnecting", "gateway_closed");
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
|
||||
ws.on("error", () => {
|
||||
if (this.stopping) return;
|
||||
if (!settled) {
|
||||
settle(() => reject(new Error("Control-plane gateway connection failed.")));
|
||||
}
|
||||
});
|
||||
}).catch((err) => {
|
||||
this.updateStatus("error", err instanceof Error ? err.message : "connect_error");
|
||||
this.scheduleReconnect();
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.stopping) return;
|
||||
if (this.reconnectTimer) return;
|
||||
const delay = Math.min(
|
||||
INITIAL_RECONNECT_DELAY_MS * Math.pow(1.7, this.reconnectAttempt),
|
||||
MAX_RECONNECT_DELAY_MS
|
||||
);
|
||||
this.reconnectAttempt += 1;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
void this.start().catch(() => {});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private sendConnectRequest(token: string): void {
|
||||
const ws = this.ws;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN || this.connectRequestId) return;
|
||||
const id = String(this.nextRequestNumber++);
|
||||
this.connectRequestId = id;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: CONNECT_PROTOCOL,
|
||||
maxProtocol: CONNECT_PROTOCOL,
|
||||
client: {
|
||||
id: "openclaw-studio-controlplane",
|
||||
version: "dev",
|
||||
platform: "node",
|
||||
mode: "operator",
|
||||
},
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
|
||||
caps: [],
|
||||
auth: { token },
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private parseFrame(raw: string): GatewayEventFrame | GatewayResponseFrame | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isObject(parsed) || typeof parsed.type !== "string") return null;
|
||||
if (parsed.type === "event" && typeof parsed.event === "string") {
|
||||
return parsed as GatewayEventFrame;
|
||||
}
|
||||
if (parsed.type === "res" && typeof parsed.id === "string") {
|
||||
return parsed as GatewayResponseFrame;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private handleResponseFrame(frame: GatewayResponseFrame): boolean {
|
||||
const pending = this.pending.get(frame.id);
|
||||
if (!pending) return true;
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(frame.id);
|
||||
if (frame.ok) {
|
||||
pending.resolve(frame.payload);
|
||||
return true;
|
||||
}
|
||||
pending.reject(
|
||||
new ControlPlaneGatewayError({
|
||||
code: frame.error?.code ?? "GATEWAY_REQUEST_FAILED",
|
||||
message: frame.error?.message ?? "Gateway request failed.",
|
||||
details: frame.error?.details,
|
||||
})
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private rejectPending(message: string): void {
|
||||
for (const [, pending] of this.pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error(message));
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private updateStatus(status: ControlPlaneConnectionStatus, reason: string | null): void {
|
||||
this.status = status;
|
||||
this.statusReason = reason;
|
||||
this.emitEvent({
|
||||
type: "runtime.status",
|
||||
status,
|
||||
reason,
|
||||
asOf: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private emitEvent(event: ControlPlaneDomainEvent): void {
|
||||
this.onDomainEvent?.(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ControlPlaneDomainEvent } from "@/lib/controlplane/contracts";
|
||||
|
||||
const safeString = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||
|
||||
export const deriveControlPlaneEventKey = (event: ControlPlaneDomainEvent): string => {
|
||||
if (event.type === "runtime.status") {
|
||||
return ["runtime.status", event.status, safeString(event.reason), event.asOf].join(":");
|
||||
}
|
||||
if (typeof event.seq === "number" && Number.isFinite(event.seq)) {
|
||||
return ["gateway.event", event.event, "seq", String(event.seq)].join(":");
|
||||
}
|
||||
return ["gateway.event", event.event, "", event.asOf].join(":");
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
import type {
|
||||
ControlPlaneDomainEvent,
|
||||
ControlPlaneOutboxEntry,
|
||||
ControlPlaneRuntimeSnapshot,
|
||||
} from "@/lib/controlplane/contracts";
|
||||
import { deriveControlPlaneEventKey } from "@/lib/controlplane/outbox";
|
||||
import { resolveStateDir } from "@/lib/clawdbot/paths";
|
||||
|
||||
const RUNTIME_DB_DIRNAME = "openclaw-studio";
|
||||
const RUNTIME_DB_FILENAME = "runtime.db";
|
||||
|
||||
const DEFAULT_STATUS = "stopped" as const;
|
||||
|
||||
type OutboxRow = {
|
||||
id: number;
|
||||
event_json: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type ProjectionRow = {
|
||||
status: string;
|
||||
reason: string | null;
|
||||
as_of: string | null;
|
||||
};
|
||||
|
||||
const parseDomainEvent = (raw: string): ControlPlaneDomainEvent => {
|
||||
return JSON.parse(raw) as ControlPlaneDomainEvent;
|
||||
};
|
||||
|
||||
const toOutboxEntry = (row: OutboxRow): ControlPlaneOutboxEntry => {
|
||||
return {
|
||||
id: row.id,
|
||||
event: parseDomainEvent(row.event_json),
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveControlPlaneRuntimeDbPath = (): string =>
|
||||
path.join(resolveStateDir(), RUNTIME_DB_DIRNAME, RUNTIME_DB_FILENAME);
|
||||
|
||||
export class SQLiteControlPlaneProjectionStore {
|
||||
private readonly db: Database.Database;
|
||||
private readonly readProjectionStmt: Database.Statement<[], ProjectionRow | undefined>;
|
||||
private readonly readOutboxHeadStmt: Database.Statement<[], { head: number }>;
|
||||
private readonly readOutboxAfterStmt: Database.Statement<[number, number], OutboxRow>;
|
||||
private readonly readOutboxByIdStmt: Database.Statement<[number], OutboxRow | undefined>;
|
||||
private readonly readProcessedStmt: Database.Statement<[string], { outbox_id: number | null } | undefined>;
|
||||
private readonly insertProcessedStmt: Database.Statement<[string, string]>;
|
||||
private readonly insertOutboxStmt: Database.Statement<[string, string, string]>;
|
||||
private readonly updateProcessedOutboxStmt: Database.Statement<[number, string]>;
|
||||
private readonly upsertStatusProjectionStmt: Database.Statement<
|
||||
[string, string | null, string, string]
|
||||
>;
|
||||
private readonly upsertGatewayProjectionStmt: Database.Statement<[string, string]>;
|
||||
private readonly applyEventTx: (
|
||||
event: ControlPlaneDomainEvent,
|
||||
eventKey: string
|
||||
) => ControlPlaneOutboxEntry;
|
||||
|
||||
constructor(dbPath: string = resolveControlPlaneRuntimeDbPath()) {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
this.db = new Database(dbPath);
|
||||
this.db.pragma("journal_mode = WAL");
|
||||
this.db.pragma("foreign_keys = ON");
|
||||
this.migrate();
|
||||
|
||||
this.readProjectionStmt = this.db.prepare(
|
||||
"SELECT status, reason, as_of FROM runtime_projection WHERE id = 1"
|
||||
);
|
||||
this.readOutboxHeadStmt = this.db.prepare("SELECT COALESCE(MAX(id), 0) AS head FROM outbox");
|
||||
this.readOutboxAfterStmt = this.db.prepare(
|
||||
"SELECT id, event_json, created_at FROM outbox WHERE id > ? ORDER BY id ASC LIMIT ?"
|
||||
);
|
||||
this.readOutboxByIdStmt = this.db.prepare(
|
||||
"SELECT id, event_json, created_at FROM outbox WHERE id = ?"
|
||||
);
|
||||
this.readProcessedStmt = this.db.prepare(
|
||||
"SELECT outbox_id FROM processed_events WHERE event_key = ?"
|
||||
);
|
||||
this.insertProcessedStmt = this.db.prepare(
|
||||
"INSERT OR IGNORE INTO processed_events (event_key, created_at) VALUES (?, ?)"
|
||||
);
|
||||
this.insertOutboxStmt = this.db.prepare(
|
||||
"INSERT INTO outbox (event_type, event_json, created_at) VALUES (?, ?, ?)"
|
||||
);
|
||||
this.updateProcessedOutboxStmt = this.db.prepare(
|
||||
"UPDATE processed_events SET outbox_id = ? WHERE event_key = ?"
|
||||
);
|
||||
this.upsertStatusProjectionStmt = this.db.prepare(`
|
||||
INSERT INTO runtime_projection (id, status, reason, as_of, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
reason = excluded.reason,
|
||||
as_of = excluded.as_of,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
this.upsertGatewayProjectionStmt = this.db.prepare(`
|
||||
INSERT INTO runtime_projection (id, status, reason, as_of, updated_at)
|
||||
VALUES (1, '${DEFAULT_STATUS}', NULL, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
as_of = excluded.as_of,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
|
||||
this.applyEventTx = this.db.transaction((event: ControlPlaneDomainEvent, eventKey: string) => {
|
||||
const existing = this.readProcessedStmt.get(eventKey);
|
||||
if (existing?.outbox_id) {
|
||||
const row = this.readOutboxByIdStmt.get(existing.outbox_id);
|
||||
if (!row) {
|
||||
throw new Error(`Outbox row missing for processed event key: ${eventKey}`);
|
||||
}
|
||||
return toOutboxEntry(row);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
this.insertProcessedStmt.run(eventKey, now);
|
||||
|
||||
if (event.type === "runtime.status") {
|
||||
this.upsertStatusProjectionStmt.run(event.status, event.reason ?? null, event.asOf, now);
|
||||
} else {
|
||||
this.upsertGatewayProjectionStmt.run(event.asOf, now);
|
||||
}
|
||||
|
||||
const info = this.insertOutboxStmt.run(event.type, JSON.stringify(event), now);
|
||||
const outboxId = Number(info.lastInsertRowid);
|
||||
this.updateProcessedOutboxStmt.run(outboxId, eventKey);
|
||||
|
||||
const row = this.readOutboxByIdStmt.get(outboxId);
|
||||
if (!row) {
|
||||
throw new Error(`Failed to read inserted outbox row id=${outboxId}`);
|
||||
}
|
||||
return toOutboxEntry(row);
|
||||
});
|
||||
}
|
||||
|
||||
applyDomainEvent(
|
||||
event: ControlPlaneDomainEvent,
|
||||
eventKey: string = deriveControlPlaneEventKey(event)
|
||||
): ControlPlaneOutboxEntry {
|
||||
return this.applyEventTx(event, eventKey);
|
||||
}
|
||||
|
||||
readOutboxAfter(lastSeenId: number, limit: number = 500): ControlPlaneOutboxEntry[] {
|
||||
const safeLastSeen = Number.isFinite(lastSeenId) && lastSeenId >= 0 ? lastSeenId : 0;
|
||||
const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 500;
|
||||
return this.readOutboxAfterStmt.all(safeLastSeen, safeLimit).map(toOutboxEntry);
|
||||
}
|
||||
|
||||
outboxHead(): number {
|
||||
const row = this.readOutboxHeadStmt.get();
|
||||
return row?.head ?? 0;
|
||||
}
|
||||
|
||||
snapshot(): ControlPlaneRuntimeSnapshot {
|
||||
const projection = this.readProjectionStmt.get();
|
||||
const outboxHead = this.outboxHead();
|
||||
if (!projection) {
|
||||
return {
|
||||
status: DEFAULT_STATUS,
|
||||
reason: null,
|
||||
asOf: null,
|
||||
outboxHead,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: projection.status as ControlPlaneRuntimeSnapshot["status"],
|
||||
reason: projection.reason,
|
||||
asOf: projection.as_of,
|
||||
outboxHead,
|
||||
};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
private migrate(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS runtime_projection (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
status TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
as_of TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT NOT NULL,
|
||||
event_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS processed_events (
|
||||
event_key TEXT PRIMARY KEY,
|
||||
outbox_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (outbox_id) REFERENCES outbox(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_id ON outbox(id);
|
||||
`);
|
||||
const version = Number(this.db.pragma("user_version", { simple: true }));
|
||||
if (version < 1) {
|
||||
this.db.pragma("user_version = 1");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ControlPlaneDomainEvent, ControlPlaneOutboxEntry } from "@/lib/controlplane/contracts";
|
||||
|
||||
const AGENT_SESSION_KEY_RE = /^agent:([^:]+):/;
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object");
|
||||
|
||||
const parseAgentIdFromSessionKey = (value: unknown): string | null => {
|
||||
if (typeof value !== "string") return null;
|
||||
const match = value.match(AGENT_SESSION_KEY_RE);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const resolveAgentIdForDomainEvent = (event: ControlPlaneDomainEvent): string | null => {
|
||||
if (event.type !== "gateway.event") return null;
|
||||
const payload = event.payload;
|
||||
if (!isObject(payload)) return null;
|
||||
const directAgentId = typeof payload.agentId === "string" ? payload.agentId.trim() : "";
|
||||
if (directAgentId) return directAgentId;
|
||||
const fromSession =
|
||||
parseAgentIdFromSessionKey(payload.sessionKey) ??
|
||||
parseAgentIdFromSessionKey(payload.key) ??
|
||||
parseAgentIdFromSessionKey(payload.runSessionKey);
|
||||
return fromSession;
|
||||
};
|
||||
|
||||
export const selectAgentHistoryEntries = (
|
||||
entries: ControlPlaneOutboxEntry[],
|
||||
agentId: string,
|
||||
limit: number
|
||||
): ControlPlaneOutboxEntry[] => {
|
||||
const normalizedAgent = agentId.trim();
|
||||
if (!normalizedAgent) return [];
|
||||
const filtered = entries.filter((entry) => resolveAgentIdForDomainEvent(entry.event) === normalizedAgent);
|
||||
if (limit <= 0) return [];
|
||||
if (filtered.length <= limit) return filtered;
|
||||
return filtered.slice(filtered.length - limit);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
ControlPlaneDomainEvent,
|
||||
ControlPlaneOutboxEntry,
|
||||
ControlPlaneRuntimeSnapshot,
|
||||
} from "@/lib/controlplane/contracts";
|
||||
import { OpenClawGatewayAdapter, type OpenClawAdapterOptions } from "@/lib/controlplane/openclaw-adapter";
|
||||
import { SQLiteControlPlaneProjectionStore } from "@/lib/controlplane/projection-store";
|
||||
|
||||
const DOMAIN_MODE_FALSE_VALUES = new Set(["0", "false", "no", "off"]);
|
||||
|
||||
const readDomainModeRawValue = (env: NodeJS.ProcessEnv = process.env): string => {
|
||||
const serverValue = env.STUDIO_DOMAIN_API_MODE?.trim().toLowerCase() ?? "";
|
||||
if (serverValue) return serverValue;
|
||||
return env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE?.trim().toLowerCase() ?? "";
|
||||
};
|
||||
|
||||
const readDomainApiMode = (env: NodeJS.ProcessEnv = process.env): boolean => {
|
||||
const raw = readDomainModeRawValue(env);
|
||||
if (!raw) return true;
|
||||
return !DOMAIN_MODE_FALSE_VALUES.has(raw);
|
||||
};
|
||||
|
||||
export type ControlPlaneRuntimeOptions = {
|
||||
adapterOptions?: OpenClawAdapterOptions;
|
||||
dbPath?: string;
|
||||
};
|
||||
|
||||
export class ControlPlaneRuntime {
|
||||
private readonly store: SQLiteControlPlaneProjectionStore;
|
||||
private readonly adapter: OpenClawGatewayAdapter;
|
||||
private readonly eventSubscribers = new Set<(entry: ControlPlaneOutboxEntry) => void>();
|
||||
|
||||
constructor(options?: ControlPlaneRuntimeOptions) {
|
||||
this.store = new SQLiteControlPlaneProjectionStore(options?.dbPath);
|
||||
this.adapter = new OpenClawGatewayAdapter({
|
||||
...(options?.adapterOptions ?? {}),
|
||||
onDomainEvent: (event) => this.handleDomainEvent(event),
|
||||
});
|
||||
}
|
||||
|
||||
isDomainApiModeEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return readDomainApiMode(env);
|
||||
}
|
||||
|
||||
async ensureStarted(): Promise<void> {
|
||||
await this.adapter.start();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await this.adapter.stop();
|
||||
}
|
||||
|
||||
snapshot(): ControlPlaneRuntimeSnapshot {
|
||||
return this.store.snapshot();
|
||||
}
|
||||
|
||||
eventsAfter(lastSeenId: number, limit?: number): ControlPlaneOutboxEntry[] {
|
||||
return this.store.readOutboxAfter(lastSeenId, limit);
|
||||
}
|
||||
|
||||
subscribe(handler: (entry: ControlPlaneOutboxEntry) => void): () => void {
|
||||
this.eventSubscribers.add(handler);
|
||||
return () => {
|
||||
this.eventSubscribers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
async callGateway<T = unknown>(method: string, params: unknown): Promise<T> {
|
||||
return await this.adapter.request<T>(method, params);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.store.close();
|
||||
}
|
||||
|
||||
private handleDomainEvent(event: ControlPlaneDomainEvent): void {
|
||||
const entry = this.store.applyDomainEvent(event);
|
||||
for (const subscriber of this.eventSubscribers) {
|
||||
subscriber(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type GlobalControlPlaneState = typeof globalThis & {
|
||||
__openclawStudioControlPlaneRuntime?: ControlPlaneRuntime;
|
||||
};
|
||||
|
||||
export const getControlPlaneRuntime = (options?: ControlPlaneRuntimeOptions): ControlPlaneRuntime => {
|
||||
const globalState = globalThis as GlobalControlPlaneState;
|
||||
if (!globalState.__openclawStudioControlPlaneRuntime) {
|
||||
globalState.__openclawStudioControlPlaneRuntime = new ControlPlaneRuntime(options);
|
||||
}
|
||||
return globalState.__openclawStudioControlPlaneRuntime;
|
||||
};
|
||||
|
||||
export const resetControlPlaneRuntimeForTests = (): void => {
|
||||
const globalState = globalThis as GlobalControlPlaneState;
|
||||
delete globalState.__openclawStudioControlPlaneRuntime;
|
||||
};
|
||||
|
||||
export const isStudioDomainApiModeEnabled = (env: NodeJS.ProcessEnv = process.env): boolean =>
|
||||
readDomainApiMode(env);
|
||||
@@ -100,7 +100,7 @@ const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings |
|
||||
const raw = value as { url?: unknown; token?: unknown };
|
||||
const url = typeof raw.url === "string" ? raw.url.trim() : "";
|
||||
const token = typeof raw.token === "string" ? raw.token.trim() : "";
|
||||
if (!url || !token) return null;
|
||||
if (!url) return null;
|
||||
return { url, token };
|
||||
};
|
||||
|
||||
@@ -411,6 +411,7 @@ export type GatewayConnectionState = {
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
localGatewayDefaults: StudioGatewaySettings | null;
|
||||
domainApiModeEnabled: boolean | null;
|
||||
error: string | null;
|
||||
connect: () => Promise<void>;
|
||||
disconnect: () => void;
|
||||
@@ -486,16 +487,18 @@ export const useGatewayConnection = (
|
||||
): GatewayConnectionState => {
|
||||
const [client] = useState(() => new GatewayClient());
|
||||
const didAutoConnect = useRef(false);
|
||||
const loadedGatewaySettings = useRef<{ gatewayUrl: string; token: string } | null>(null);
|
||||
const loadedGatewaySettings = useRef<{ gatewayUrl: string } | null>(null);
|
||||
const retryAttemptRef = useRef(0);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const wasManualDisconnectRef = useRef(false);
|
||||
const tokenDirtyRef = useRef(false);
|
||||
|
||||
const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_UPSTREAM_GATEWAY_URL);
|
||||
const [token, setToken] = useState("");
|
||||
const [token, setTokenState] = useState("");
|
||||
const [localGatewayDefaults, setLocalGatewayDefaults] = useState<StudioGatewaySettings | null>(
|
||||
null
|
||||
);
|
||||
const [domainApiModeEnabled, setDomainApiModeEnabled] = useState<boolean | null>(null);
|
||||
const [status, setStatus] = useState<GatewayStatus>("disconnected");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectErrorCode, setConnectErrorCode] = useState<string | null>(null);
|
||||
@@ -513,14 +516,19 @@ export const useGatewayConnection = (
|
||||
const gateway = settings?.gateway ?? null;
|
||||
if (cancelled) return;
|
||||
setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults));
|
||||
const envelopeDomainMode =
|
||||
"domainApiModeEnabled" in envelope ? envelope.domainApiModeEnabled : undefined;
|
||||
setDomainApiModeEnabled(
|
||||
typeof envelopeDomainMode === "boolean" ? envelopeDomainMode : null
|
||||
);
|
||||
const nextGatewayUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL;
|
||||
const nextToken = typeof gateway?.token === "string" ? gateway.token : "";
|
||||
loadedGatewaySettings.current = {
|
||||
gatewayUrl: nextGatewayUrl.trim(),
|
||||
token: nextToken,
|
||||
};
|
||||
setGatewayUrl(nextGatewayUrl);
|
||||
setToken(nextToken);
|
||||
setTokenState(nextToken);
|
||||
tokenDirtyRef.current = false;
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load gateway settings.";
|
||||
@@ -531,7 +539,6 @@ export const useGatewayConnection = (
|
||||
if (!loadedGatewaySettings.current) {
|
||||
loadedGatewaySettings.current = {
|
||||
gatewayUrl: DEFAULT_UPSTREAM_GATEWAY_URL.trim(),
|
||||
token: "",
|
||||
};
|
||||
}
|
||||
setSettingsLoaded(true);
|
||||
@@ -636,18 +643,22 @@ export const useGatewayConnection = (
|
||||
const baseline = loadedGatewaySettings.current;
|
||||
if (!baseline) return;
|
||||
const nextGatewayUrl = gatewayUrl.trim();
|
||||
if (nextGatewayUrl === baseline.gatewayUrl && token === baseline.token) {
|
||||
const shouldPersistToken = tokenDirtyRef.current;
|
||||
if (!shouldPersistToken && nextGatewayUrl === baseline.gatewayUrl) {
|
||||
return;
|
||||
}
|
||||
const gatewayPatch: { url: string; token?: string } = { url: nextGatewayUrl };
|
||||
if (shouldPersistToken) {
|
||||
gatewayPatch.token = token;
|
||||
tokenDirtyRef.current = false;
|
||||
}
|
||||
settingsCoordinator.schedulePatch(
|
||||
{
|
||||
gateway: {
|
||||
url: nextGatewayUrl,
|
||||
token,
|
||||
},
|
||||
gateway: gatewayPatch,
|
||||
},
|
||||
400
|
||||
);
|
||||
loadedGatewaySettings.current = { gatewayUrl: nextGatewayUrl };
|
||||
}, [gatewayUrl, settingsCoordinator, settingsLoaded, token]);
|
||||
|
||||
const useLocalGatewayDefaults = useCallback(() => {
|
||||
@@ -655,11 +666,17 @@ export const useGatewayConnection = (
|
||||
return;
|
||||
}
|
||||
setGatewayUrl(localGatewayDefaults.url);
|
||||
setToken(localGatewayDefaults.token);
|
||||
setTokenState(localGatewayDefaults.token);
|
||||
tokenDirtyRef.current = false;
|
||||
setError(null);
|
||||
setConnectErrorCode(null);
|
||||
}, [localGatewayDefaults]);
|
||||
|
||||
const setToken = useCallback((value: string) => {
|
||||
tokenDirtyRef.current = true;
|
||||
setTokenState(value);
|
||||
}, []);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
setError(null);
|
||||
setConnectErrorCode(null);
|
||||
@@ -678,6 +695,7 @@ export const useGatewayConnection = (
|
||||
gatewayUrl,
|
||||
token,
|
||||
localGatewayDefaults,
|
||||
domainApiModeEnabled,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
|
||||
@@ -24,7 +24,7 @@ const resolveReloadModeFromConfig = (config: unknown): string | null => {
|
||||
return mode.length > 0 ? mode : "hybrid";
|
||||
};
|
||||
|
||||
export const shouldAwaitDisconnectRestartForReloadMode = (mode: string | null): boolean =>
|
||||
const shouldAwaitDisconnectRestartForReloadMode = (mode: string | null): boolean =>
|
||||
mode !== "hot" && mode !== "off" && mode !== "hybrid";
|
||||
|
||||
export async function shouldAwaitDisconnectRestartForRemoteMutation(params: {
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import type { GatewayConfigSnapshot } from "@/lib/gateway/agentConfig";
|
||||
import { fetchJson } from "@/lib/http";
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const shouldRetryConfigWrite = (err: unknown) => {
|
||||
if (!(err instanceof GatewayResponseError)) return false;
|
||||
return /re-run config\.get|config changed since last load/i.test(err.message);
|
||||
};
|
||||
|
||||
const readDotEnvKeys = async (): Promise<string[]> => {
|
||||
if (typeof window === "undefined") {
|
||||
return [];
|
||||
}
|
||||
const url = new URL("/api/gateway/dotenv-keys", window.location.origin).toString();
|
||||
const { keys } = await fetchJson<{ keys: string[] }>(url);
|
||||
return Array.isArray(keys) ? keys : [];
|
||||
};
|
||||
|
||||
const readDefaultSandboxEnvMap = (config: Record<string, unknown>): Record<string, string> => {
|
||||
const agents = isRecord(config.agents) ? config.agents : null;
|
||||
const defaults = agents && isRecord(agents.defaults) ? agents.defaults : null;
|
||||
const sandbox = defaults && isRecord(defaults.sandbox) ? defaults.sandbox : null;
|
||||
const docker = sandbox && isRecord(sandbox.docker) ? sandbox.docker : null;
|
||||
const env = docker && isRecord(docker.env) ? docker.env : null;
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
if (!env) return result;
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (typeof value === "string") {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const writeDefaultSandboxEnvMap = (
|
||||
config: Record<string, unknown>,
|
||||
env: Record<string, string>,
|
||||
): Record<string, unknown> => {
|
||||
const agents = isRecord(config.agents) ? { ...config.agents } : {};
|
||||
const defaults = isRecord(agents.defaults) ? { ...(agents.defaults as Record<string, unknown>) } : {};
|
||||
const sandbox = isRecord(defaults.sandbox) ? { ...(defaults.sandbox as Record<string, unknown>) } : {};
|
||||
const docker = isRecord(sandbox.docker) ? { ...(sandbox.docker as Record<string, unknown>) } : {};
|
||||
|
||||
docker.env = env;
|
||||
sandbox.docker = docker;
|
||||
defaults.sandbox = sandbox;
|
||||
(agents as Record<string, unknown>).defaults = defaults;
|
||||
|
||||
return { ...config, agents };
|
||||
};
|
||||
|
||||
export const ensureGatewaySandboxEnvAllowlistFromDotEnv = async (params: {
|
||||
client: GatewayClient;
|
||||
}): Promise<void> => {
|
||||
let keys: string[] = [];
|
||||
try {
|
||||
keys = await readDotEnvKeys();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "";
|
||||
if (message.includes("status 404")) {
|
||||
return;
|
||||
}
|
||||
console.warn("Failed to sync sandbox env allowlist from dotenv keys.", err);
|
||||
return;
|
||||
}
|
||||
if (keys.length === 0) return;
|
||||
|
||||
const tryOnce = async (attempt: number): Promise<void> => {
|
||||
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
|
||||
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
|
||||
|
||||
const currentEnv = readDefaultSandboxEnvMap(baseConfig);
|
||||
const nextEnv: Record<string, string> = { ...currentEnv };
|
||||
|
||||
let changed = false;
|
||||
for (const key of keys) {
|
||||
if (key in nextEnv) continue;
|
||||
nextEnv[key] = `\${${key}}`;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return;
|
||||
|
||||
const nextConfig = writeDefaultSandboxEnvMap(baseConfig, nextEnv);
|
||||
const payload: Record<string, unknown> = {
|
||||
raw: JSON.stringify(nextConfig, null, 2),
|
||||
};
|
||||
const baseHash = typeof snapshot.hash === "string" ? snapshot.hash.trim() : "";
|
||||
if (snapshot.exists !== false) {
|
||||
if (!baseHash) {
|
||||
throw new Error("Gateway config hash unavailable; re-run config.get.");
|
||||
}
|
||||
payload.baseHash = baseHash;
|
||||
}
|
||||
try {
|
||||
await params.client.call("config.set", payload);
|
||||
} catch (err) {
|
||||
if (attempt < 1 && shouldRetryConfigWrite(err)) {
|
||||
return tryOnce(attempt + 1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
await tryOnce(0);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
import * as childProcess from "node:child_process";
|
||||
|
||||
const SSH_TARGET_ENV = "OPENCLAW_GATEWAY_SSH_TARGET";
|
||||
@@ -45,15 +44,7 @@ export const resolveGatewaySshTargetFromGatewayUrl = (
|
||||
return `${user}@${hostname}`;
|
||||
};
|
||||
|
||||
export const resolveGatewaySshTarget = (env: NodeJS.ProcessEnv = process.env): string => {
|
||||
const configured = resolveConfiguredSshTarget(env);
|
||||
if (configured) return configured;
|
||||
|
||||
const settings = loadStudioSettings();
|
||||
return resolveGatewaySshTargetFromGatewayUrl(settings.gateway?.url?.trim() ?? "", env);
|
||||
};
|
||||
|
||||
export const extractJsonErrorMessage = (value: string): string | null => {
|
||||
const extractJsonErrorMessage = (value: string): string | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
@@ -72,7 +63,7 @@ export const extractJsonErrorMessage = (value: string): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
export const parseJsonOutput = (raw: string, label: string): unknown => {
|
||||
const parseJsonOutput = (raw: string, label: string): unknown => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`Command produced empty JSON output (${label}).`);
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
export type StudioSettingsResponse = {
|
||||
settings: StudioSettings;
|
||||
localGatewayDefaults?: StudioGatewaySettings | null;
|
||||
domainApiModeEnabled?: boolean;
|
||||
};
|
||||
|
||||
type FocusedPatch = Record<string, Partial<StudioFocusedPreference> | null>;
|
||||
@@ -124,12 +125,19 @@ export class StudioSettingsCoordinator {
|
||||
this.timer = null;
|
||||
}
|
||||
const patch = this.pendingPatch;
|
||||
this.pendingPatch = null;
|
||||
if (!patch) {
|
||||
return this.queue;
|
||||
}
|
||||
const write = this.queue.then(async () => {
|
||||
await this.transport.updateSettings(patch);
|
||||
if (this.pendingPatch === patch) {
|
||||
this.pendingPatch = null;
|
||||
}
|
||||
try {
|
||||
await this.transport.updateSettings(patch);
|
||||
} catch (err) {
|
||||
this.pendingPatch = mergeStudioPatch(this.pendingPatch, patch);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
this.queue = write.catch((err) => {
|
||||
console.error("Failed to persist studio settings patch.", err);
|
||||
@@ -147,11 +155,11 @@ export class StudioSettingsCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchStudioSettings = async (): Promise<StudioSettingsResponse> => {
|
||||
const fetchStudioSettings = async (): Promise<StudioSettingsResponse> => {
|
||||
return fetchJson<StudioSettingsResponse>("/api/studio", { cache: "no-store" });
|
||||
};
|
||||
|
||||
export const updateStudioSettings = async (
|
||||
const updateStudioSettings = async (
|
||||
patch: StudioSettingsPatch
|
||||
): Promise<StudioSettingsResponse> => {
|
||||
return fetchJson<StudioSettingsResponse>("/api/studio", {
|
||||
|
||||
@@ -14,7 +14,7 @@ const SETTINGS_DIRNAME = "openclaw-studio";
|
||||
const SETTINGS_FILENAME = "settings.json";
|
||||
const OPENCLAW_CONFIG_FILENAME = "openclaw.json";
|
||||
|
||||
export const resolveStudioSettingsPath = () =>
|
||||
const resolveStudioSettingsPath = () =>
|
||||
path.join(resolveStateDir(), SETTINGS_DIRNAME, SETTINGS_FILENAME);
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -45,6 +45,27 @@ export const loadLocalGatewayDefaults = () => {
|
||||
return readOpenclawGatewayDefaults();
|
||||
};
|
||||
|
||||
export const redactStudioSettingsSecrets = (settings: StudioSettings): StudioSettings => {
|
||||
if (!settings.gateway) return settings;
|
||||
return {
|
||||
...settings,
|
||||
gateway: {
|
||||
...settings.gateway,
|
||||
token: "",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const redactLocalGatewayDefaultsSecrets = (
|
||||
defaults: { url: string; token: string } | null
|
||||
): { url: string; token: string } | null => {
|
||||
if (!defaults) return null;
|
||||
return {
|
||||
...defaults,
|
||||
token: "",
|
||||
};
|
||||
};
|
||||
|
||||
export const loadStudioSettings = (): StudioSettings => {
|
||||
const settingsPath = resolveStudioSettingsPath();
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
@@ -69,7 +90,7 @@ export const loadStudioSettings = (): StudioSettings => {
|
||||
return settings;
|
||||
};
|
||||
|
||||
export const saveStudioSettings = (next: StudioSettings) => {
|
||||
const saveStudioSettings = (next: StudioSettings) => {
|
||||
const settingsPath = resolveStudioSettingsPath();
|
||||
const dir = path.dirname(settingsPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
|
||||
@@ -3,6 +3,11 @@ export type StudioGatewaySettings = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type StudioGatewaySettingsPatch = {
|
||||
url?: string | null;
|
||||
token?: string | null;
|
||||
};
|
||||
|
||||
export type FocusFilter = "all" | "running" | "approvals";
|
||||
export type StudioViewMode = "focused";
|
||||
|
||||
@@ -20,7 +25,7 @@ export type StudioSettings = {
|
||||
};
|
||||
|
||||
export type StudioSettingsPatch = {
|
||||
gateway?: StudioGatewaySettings | null;
|
||||
gateway?: StudioGatewaySettingsPatch | null;
|
||||
focused?: Record<string, Partial<StudioFocusedPreference> | null>;
|
||||
avatars?: Record<string, Record<string, string | null> | null>;
|
||||
};
|
||||
@@ -124,6 +129,23 @@ const normalizeGatewaySettings = (value: unknown): StudioGatewaySettings | null
|
||||
return { url, token };
|
||||
};
|
||||
|
||||
const hasOwn = (value: Record<string, unknown>, key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(value, key);
|
||||
|
||||
const mergeGatewaySettings = (
|
||||
current: StudioGatewaySettings | null,
|
||||
patch: StudioGatewaySettingsPatch | null | undefined
|
||||
): StudioGatewaySettings | null => {
|
||||
if (patch === undefined) return current;
|
||||
if (patch === null) return null;
|
||||
if (!isRecord(patch)) return current;
|
||||
|
||||
const nextUrl = hasOwn(patch, "url") ? normalizeGatewayUrl(patch.url) : current?.url ?? "";
|
||||
const nextToken = hasOwn(patch, "token") ? coerceString(patch.token) : current?.token ?? "";
|
||||
if (!nextUrl) return null;
|
||||
return { url: nextUrl, token: nextToken };
|
||||
};
|
||||
|
||||
const normalizeFocused = (value: unknown): Record<string, StudioFocusedPreference> => {
|
||||
if (!isRecord(value)) return {};
|
||||
const focused: Record<string, StudioFocusedPreference> = {};
|
||||
@@ -179,8 +201,7 @@ export const mergeStudioSettings = (
|
||||
current: StudioSettings,
|
||||
patch: StudioSettingsPatch
|
||||
): StudioSettings => {
|
||||
const nextGateway =
|
||||
patch.gateway === undefined ? current.gateway : normalizeGatewaySettings(patch.gateway);
|
||||
const nextGateway = mergeGatewaySettings(current.gateway, patch.gateway);
|
||||
const nextFocused = { ...current.focused };
|
||||
const nextAvatars = { ...current.avatars };
|
||||
if (patch.focused) {
|
||||
|
||||
@@ -373,7 +373,7 @@ const formatToolResultMeta = (details?: Record<string, unknown> | null, isError?
|
||||
return parts.length ? parts.join(" · ") : "";
|
||||
};
|
||||
|
||||
export const extractToolCalls = (message: unknown): ToolCallRecord[] => {
|
||||
const extractToolCalls = (message: unknown): ToolCallRecord[] => {
|
||||
if (!message || typeof message !== "object") return [];
|
||||
const content = (message as Record<string, unknown>).content;
|
||||
if (!Array.isArray(content)) return [];
|
||||
@@ -391,7 +391,7 @@ export const extractToolCalls = (message: unknown): ToolCallRecord[] => {
|
||||
return calls;
|
||||
};
|
||||
|
||||
export const extractToolResult = (message: unknown): ToolResultRecord | null => {
|
||||
const extractToolResult = (message: unknown): ToolResultRecord | null => {
|
||||
if (!message || typeof message !== "object") return null;
|
||||
const record = message as Record<string, unknown>;
|
||||
const role = typeof record.role === "string" ? record.role : "";
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
if (!process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE) {
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
|
||||
}
|
||||
if (!process.env.STUDIO_DOMAIN_API_MODE) {
|
||||
process.env.STUDIO_DOMAIN_API_MODE = "false";
|
||||
}
|
||||
|
||||
const ensureLocalStorage = () => {
|
||||
if (typeof window === "undefined") return;
|
||||
const existing = window.localStorage as unknown as Record<string, unknown> | undefined;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { updateExecutionRoleViaStudio } from "@/features/agents/operations/agentPermissionsOperation";
|
||||
|
||||
describe("agentPermissionsOperation intent mode", () => {
|
||||
it("uses exec-approvals-set intent when domain mode is enabled", async () => {
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "exec.approvals.get" || method === "exec.approvals.set") {
|
||||
throw new Error(`${method} should not be called in domain mode`);
|
||||
}
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
hash: "cfg-hash-1",
|
||||
config: { agents: [{ id: "agent-1", sandbox: { mode: "normal" } }] },
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (method === "sessions.patch") {
|
||||
return { ok: true, key: "agent:agent-1:main" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await updateExecutionRoleViaStudio({
|
||||
client: { call } as never,
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
role: "collaborative",
|
||||
loadAgents: async () => {},
|
||||
useDomainIntents: true,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/intents/exec-approvals-set",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
expect(call).not.toHaveBeenCalledWith("exec.approvals.get", expect.anything());
|
||||
expect(call).not.toHaveBeenCalledWith("exec.approvals.set", expect.anything());
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -50,8 +50,6 @@ describe("AgentSettingsPanel header", () => {
|
||||
agent: createAgent(),
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
|
||||
@@ -131,8 +131,6 @@ describe("AgentSettingsPanel", () => {
|
||||
agent: createAgent(),
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -153,8 +151,6 @@ describe("AgentSettingsPanel", () => {
|
||||
agent: createAgent(),
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -176,8 +172,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "advanced",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -198,8 +192,6 @@ describe("AgentSettingsPanel", () => {
|
||||
agent: createAgent(),
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -230,8 +222,6 @@ describe("AgentSettingsPanel", () => {
|
||||
agent: createAgent(),
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -260,8 +250,6 @@ describe("AgentSettingsPanel", () => {
|
||||
onUpdateAgentPermissions,
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -295,8 +283,6 @@ describe("AgentSettingsPanel", () => {
|
||||
agent: createAgent(),
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -349,8 +335,6 @@ describe("AgentSettingsPanel", () => {
|
||||
agent: createAgent(),
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -372,8 +356,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "advanced",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -395,8 +377,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "skills",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -422,8 +402,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "skills",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -446,8 +424,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "skills",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -475,8 +451,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "skills",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -512,8 +486,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "system",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -558,8 +530,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "system",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -597,8 +567,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "system",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -631,8 +599,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "system",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -659,8 +625,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "skills",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -690,8 +654,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "skills",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -717,8 +679,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "skills",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -738,8 +698,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "skills",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -762,8 +720,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [createCronJob("job-1")],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -788,8 +744,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs,
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -809,8 +763,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs,
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -833,8 +785,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs,
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -854,8 +804,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs,
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -876,8 +824,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -899,8 +845,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -921,8 +865,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -944,8 +886,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -975,8 +915,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -1028,8 +966,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -1056,8 +992,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -1101,8 +1035,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [createCronJob("job-1")],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -1124,8 +1056,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "advanced",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
@@ -1147,8 +1077,6 @@ describe("AgentSettingsPanel", () => {
|
||||
mode: "advanced",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
|
||||
@@ -141,6 +141,43 @@ describe("sendChatMessageViaStudio", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses chat-send intent in domain mode", async () => {
|
||||
const agent = createAgent({ sessionSettingsSynced: true, sessionCreated: true });
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "chat.send") {
|
||||
throw new Error("chat.send should not be called in domain mode");
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true, payload: { runId: "run-1", status: "started" } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "hello",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
useDomainIntents: true,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/intents/chat-send",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
expect(call).not.toHaveBeenCalledWith("chat.send", expect.anything());
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("continues_send_when_webchat_patch_is_blocked", async () => {
|
||||
const agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false });
|
||||
const dispatch = vi.fn();
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { SQLiteControlPlaneProjectionStore } from "@/lib/controlplane/projection-store";
|
||||
|
||||
describe("SQLiteControlPlaneProjectionStore", () => {
|
||||
let tempDir: string | null = null;
|
||||
|
||||
const makeDbPath = () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "controlplane-store-"));
|
||||
return path.join(tempDir, "runtime.db");
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses same schema across restarts and preserves snapshot", () => {
|
||||
const dbPath = makeDbPath();
|
||||
const first = new SQLiteControlPlaneProjectionStore(dbPath);
|
||||
first.applyDomainEvent({
|
||||
type: "runtime.status",
|
||||
status: "connected",
|
||||
reason: null,
|
||||
asOf: "2026-02-28T02:00:00.000Z",
|
||||
});
|
||||
first.close();
|
||||
|
||||
const second = new SQLiteControlPlaneProjectionStore(dbPath);
|
||||
const snapshot = second.snapshot();
|
||||
expect(snapshot.status).toBe("connected");
|
||||
expect(snapshot.asOf).toBe("2026-02-28T02:00:00.000Z");
|
||||
expect(snapshot.outboxHead).toBe(1);
|
||||
second.close();
|
||||
});
|
||||
|
||||
it("deduplicates reapplied events and keeps outbox ordering", () => {
|
||||
const store = new SQLiteControlPlaneProjectionStore(makeDbPath());
|
||||
const firstEvent = {
|
||||
type: "gateway.event" as const,
|
||||
event: "runtime.delta",
|
||||
seq: 42,
|
||||
payload: { content: "a" },
|
||||
asOf: "2026-02-28T02:01:00.000Z",
|
||||
};
|
||||
const secondEvent = {
|
||||
type: "gateway.event" as const,
|
||||
event: "runtime.final",
|
||||
seq: 43,
|
||||
payload: { content: "b" },
|
||||
asOf: "2026-02-28T02:01:02.000Z",
|
||||
};
|
||||
|
||||
const first = store.applyDomainEvent(firstEvent);
|
||||
const duplicate = store.applyDomainEvent(firstEvent);
|
||||
const replayedWithNewTimestamp = store.applyDomainEvent({
|
||||
...firstEvent,
|
||||
asOf: "2026-02-28T02:01:05.000Z",
|
||||
});
|
||||
const second = store.applyDomainEvent(secondEvent);
|
||||
|
||||
expect(first.id).toBe(1);
|
||||
expect(duplicate.id).toBe(1);
|
||||
expect(replayedWithNewTimestamp.id).toBe(1);
|
||||
expect(second.id).toBe(2);
|
||||
|
||||
const replay = store.readOutboxAfter(0, 10);
|
||||
expect(replay.map((entry) => entry.id)).toEqual([1, 2]);
|
||||
expect(replay[0]?.event).toEqual(firstEvent);
|
||||
expect(replay[1]?.event).toEqual(secondEvent);
|
||||
|
||||
store.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
ControlPlaneRuntime,
|
||||
getControlPlaneRuntime,
|
||||
isStudioDomainApiModeEnabled,
|
||||
resetControlPlaneRuntimeForTests,
|
||||
} from "@/lib/controlplane/runtime";
|
||||
|
||||
const closeWebSocketServer = (server: WebSocketServer) =>
|
||||
new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
|
||||
describe("control-plane runtime", () => {
|
||||
let tempDir: string | null = null;
|
||||
|
||||
const makeRuntimeDbPath = () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "controlplane-runtime-"));
|
||||
return path.join(tempDir, "runtime.db");
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
const runtime = getControlPlaneRuntime();
|
||||
await runtime.disconnect();
|
||||
runtime.close();
|
||||
resetControlPlaneRuntimeForTests();
|
||||
delete process.env.STUDIO_DOMAIN_API_MODE;
|
||||
delete process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE;
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("connects and disconnects through adapter lifecycle", async () => {
|
||||
const upstream = new WebSocketServer({ port: 0 });
|
||||
const address = upstream.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected upstream server to have a port");
|
||||
}
|
||||
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
|
||||
|
||||
upstream.on("connection", (ws) => {
|
||||
ws.send(JSON.stringify({ type: "event", event: "connect.challenge", payload: { nonce: "n1" } }));
|
||||
ws.on("message", (raw) => {
|
||||
const parsed = JSON.parse(String(raw ?? ""));
|
||||
if (parsed?.type !== "req" || typeof parsed.id !== "string") return;
|
||||
if (parsed.method === "connect") {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: parsed.id,
|
||||
ok: true,
|
||||
payload: { type: "hello-ok", protocol: 3 },
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (parsed.method === "status") {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: parsed.id,
|
||||
ok: true,
|
||||
payload: { ok: true, source: "upstream" },
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const runtime = new ControlPlaneRuntime({
|
||||
dbPath: makeRuntimeDbPath(),
|
||||
adapterOptions: {
|
||||
loadSettings: () => ({ url: upstreamUrl, token: "upstream-token" }),
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.ensureStarted();
|
||||
const connectedSnapshot = runtime.snapshot();
|
||||
expect(connectedSnapshot.status).toBe("connected");
|
||||
|
||||
const statusPayload = await runtime.callGateway<{ ok: boolean; source: string }>("status", {});
|
||||
expect(statusPayload).toEqual({ ok: true, source: "upstream" });
|
||||
|
||||
await runtime.disconnect();
|
||||
const disconnectedSnapshot = runtime.snapshot();
|
||||
expect(disconnectedSnapshot.status).toBe("stopped");
|
||||
|
||||
await closeWebSocketServer(upstream);
|
||||
});
|
||||
|
||||
it("enforces gateway method allowlist", async () => {
|
||||
const upstream = new WebSocketServer({ port: 0 });
|
||||
const address = upstream.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected upstream server to have a port");
|
||||
}
|
||||
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
|
||||
|
||||
upstream.on("connection", (ws) => {
|
||||
ws.send(JSON.stringify({ type: "event", event: "connect.challenge", payload: {} }));
|
||||
ws.on("message", (raw) => {
|
||||
const parsed = JSON.parse(String(raw ?? ""));
|
||||
if (parsed?.method !== "connect") return;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: parsed.id,
|
||||
ok: true,
|
||||
payload: { type: "hello-ok", protocol: 3 },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const runtime = new ControlPlaneRuntime({
|
||||
dbPath: makeRuntimeDbPath(),
|
||||
adapterOptions: {
|
||||
loadSettings: () => ({ url: upstreamUrl, token: "upstream-token" }),
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.ensureStarted();
|
||||
await expect(runtime.callGateway("sessions.delete", { key: "x" })).rejects.toThrow(
|
||||
"Gateway method is not allowlisted"
|
||||
);
|
||||
|
||||
await runtime.disconnect();
|
||||
await closeWebSocketServer(upstream);
|
||||
});
|
||||
|
||||
it("uses process-local singleton runtime", () => {
|
||||
const a = getControlPlaneRuntime();
|
||||
const b = getControlPlaneRuntime();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("parses STUDIO_DOMAIN_API_MODE values", () => {
|
||||
process.env.STUDIO_DOMAIN_API_MODE = "true";
|
||||
expect(isStudioDomainApiModeEnabled()).toBe(true);
|
||||
process.env.STUDIO_DOMAIN_API_MODE = "1";
|
||||
expect(isStudioDomainApiModeEnabled()).toBe(true);
|
||||
process.env.STUDIO_DOMAIN_API_MODE = "false";
|
||||
expect(isStudioDomainApiModeEnabled()).toBe(false);
|
||||
delete process.env.STUDIO_DOMAIN_API_MODE;
|
||||
expect(isStudioDomainApiModeEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE when server mode is unset", () => {
|
||||
delete process.env.STUDIO_DOMAIN_API_MODE;
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
|
||||
expect(isStudioDomainApiModeEnabled()).toBe(false);
|
||||
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
|
||||
expect(isStudioDomainApiModeEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers STUDIO_DOMAIN_API_MODE over NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE", () => {
|
||||
process.env.STUDIO_DOMAIN_API_MODE = "false";
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
|
||||
expect(isStudioDomainApiModeEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -212,4 +212,63 @@ describe("execApprovalResolveOperation", () => {
|
||||
|
||||
expect(onAllowed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses exec-approval-resolve intent in domain mode", async () => {
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "exec.approval.resolve") {
|
||||
throw new Error("exec.approval.resolve should not be called in domain mode");
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true, payload: { ok: true } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const approval: PendingExecApproval = {
|
||||
id: "appr-1",
|
||||
agentId: "a1",
|
||||
sessionKey: "sess-1",
|
||||
command: "echo hi",
|
||||
cwd: null,
|
||||
host: null,
|
||||
security: null,
|
||||
ask: null,
|
||||
resolvedPath: null,
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
resolving: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const approvalsByAgentId = createState<Record<string, PendingExecApproval[]>>({ a1: [approval] });
|
||||
const unscopedApprovals = createState<PendingExecApproval[]>([]);
|
||||
|
||||
await resolveExecApprovalViaStudio({
|
||||
client: { call },
|
||||
approvalId: "appr-1",
|
||||
decision: "deny",
|
||||
getAgents: () => [],
|
||||
getLatestAgent: () => null,
|
||||
getPendingState: () => ({
|
||||
approvalsByAgentId: approvalsByAgentId.get(),
|
||||
unscopedApprovals: unscopedApprovals.get(),
|
||||
}),
|
||||
setPendingExecApprovalsByAgentId: approvalsByAgentId.set,
|
||||
setUnscopedPendingExecApprovals: unscopedApprovals.set,
|
||||
requestHistoryRefresh: vi.fn(),
|
||||
isDisconnectLikeError: () => false,
|
||||
useDomainIntents: true,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/intents/exec-approval-resolve",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
expect(call).not.toHaveBeenCalledWith("exec.approval.resolve", expect.anything());
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import {
|
||||
@@ -12,6 +12,11 @@ import type { ExecApprovalPendingSnapshot } from "@/features/agents/approvals/ex
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { EXEC_APPROVAL_AUTO_RESUME_MARKER } from "@/lib/text/message-extract";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
import { postStudioIntent } from "@/lib/controlplane/intents-client";
|
||||
|
||||
vi.mock("@/lib/controlplane/intents-client", () => ({
|
||||
postStudioIntent: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
@@ -76,7 +81,20 @@ const createPendingState = (
|
||||
});
|
||||
|
||||
describe("execApprovalRunControlOperation", () => {
|
||||
const mockedPostStudioIntent = vi.mocked(postStudioIntent);
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE;
|
||||
});
|
||||
|
||||
it("uses legacy gateway calls when domain mode is disabled", () => {
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
|
||||
expect(process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE).toBe("false");
|
||||
});
|
||||
|
||||
it("pauses a run for pending approval after stale paused-run cleanup", async () => {
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
|
||||
mockedPostStudioIntent.mockReset();
|
||||
const call = vi.fn(async () => ({ ok: true }));
|
||||
const pausedRunIdByAgentId = new Map<string, string>([
|
||||
["stale-agent", "stale-run"],
|
||||
@@ -101,6 +119,8 @@ describe("execApprovalRunControlOperation", () => {
|
||||
});
|
||||
|
||||
it("reverts paused-run map entry when pause abort call fails", async () => {
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
|
||||
mockedPostStudioIntent.mockReset();
|
||||
const call = vi.fn(async () => {
|
||||
throw new Error("abort failed");
|
||||
});
|
||||
@@ -126,6 +146,8 @@ describe("execApprovalRunControlOperation", () => {
|
||||
});
|
||||
|
||||
it("auto-resumes in order: dispatch running, wait paused run, then send follow-up", async () => {
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
|
||||
mockedPostStudioIntent.mockReset();
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "agent.wait") return { status: "ok" };
|
||||
throw new Error(`Unexpected method ${method}`);
|
||||
@@ -174,6 +196,8 @@ describe("execApprovalRunControlOperation", () => {
|
||||
});
|
||||
|
||||
it("skips follow-up send when post-wait auto-resume intent no longer holds", async () => {
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
|
||||
mockedPostStudioIntent.mockReset();
|
||||
const call = vi.fn(async () => ({ status: "ok" }));
|
||||
const dispatch = vi.fn();
|
||||
const sendChatMessage = vi.fn(async () => undefined);
|
||||
@@ -209,6 +233,8 @@ describe("execApprovalRunControlOperation", () => {
|
||||
});
|
||||
|
||||
it("resolves approvals through resolver and delegates allow flow to auto-resume operation", async () => {
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
|
||||
mockedPostStudioIntent.mockReset();
|
||||
const resolveExecApproval = vi.fn(async (params: { onAllowed?: (input: {
|
||||
approval: PendingExecApproval;
|
||||
targetAgentId: string;
|
||||
@@ -246,6 +272,8 @@ describe("execApprovalRunControlOperation", () => {
|
||||
});
|
||||
|
||||
it("executes ingress commands from gateway events", () => {
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
|
||||
mockedPostStudioIntent.mockReset();
|
||||
const dispatch = vi.fn();
|
||||
const replacePendingState = vi.fn();
|
||||
const pauseRunForApproval = vi.fn(async () => undefined);
|
||||
@@ -294,4 +322,46 @@ describe("execApprovalRunControlOperation", () => {
|
||||
expect(replacePendingState).not.toHaveBeenCalled();
|
||||
expect(pauseRunForApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses intents for pause and wait in domain mode", async () => {
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
|
||||
mockedPostStudioIntent.mockReset();
|
||||
mockedPostStudioIntent.mockResolvedValue({ ok: true });
|
||||
const call = vi.fn(async () => ({ ok: true }));
|
||||
const pausedRunIdByAgentId = new Map<string, string>();
|
||||
|
||||
await runPauseRunForExecApprovalOperation({
|
||||
status: "connected",
|
||||
client: { call },
|
||||
approval: createApproval("approval-1"),
|
||||
preferredAgentId: "agent-1",
|
||||
getAgents: () => [createAgent({ runId: "run-1" })],
|
||||
pausedRunIdByAgentId,
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
});
|
||||
|
||||
await runExecApprovalAutoResumeOperation({
|
||||
client: { call },
|
||||
dispatch: vi.fn(),
|
||||
approval: createApproval("approval-1"),
|
||||
targetAgentId: "agent-1",
|
||||
getAgents: () => [createAgent({ status: "running", runId: "run-1" })],
|
||||
getPendingState: () => createPendingState(),
|
||||
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
sendChatMessage: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/chat-abort", {
|
||||
sessionKey: "agent:agent-1:main",
|
||||
});
|
||||
expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/agent-wait", {
|
||||
runId: "run-1",
|
||||
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
expect(call).not.toHaveBeenCalledWith("chat.abort", expect.anything());
|
||||
expect(call).not.toHaveBeenCalledWith("agent.wait", expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("intent routes", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("chat-send route forwards to gateway intent runtime", async () => {
|
||||
const callGateway = vi.fn(async () => ({ runId: "run-1", status: "started" }));
|
||||
vi.doMock("@/lib/controlplane/runtime", () => ({
|
||||
isStudioDomainApiModeEnabled: () => true,
|
||||
getControlPlaneRuntime: () => ({
|
||||
ensureStarted: async () => {},
|
||||
callGateway,
|
||||
}),
|
||||
}));
|
||||
const mod = await import("@/app/api/intents/chat-send/route");
|
||||
|
||||
const response = await mod.POST(
|
||||
new Request("http://localhost/api/intents/chat-send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sessionKey: "agent:agent-1:main",
|
||||
message: "hello",
|
||||
idempotencyKey: "run-1",
|
||||
deliver: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(callGateway).toHaveBeenCalledWith("chat.send", {
|
||||
sessionKey: "agent:agent-1:main",
|
||||
message: "hello",
|
||||
idempotencyKey: "run-1",
|
||||
deliver: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("sessions-reset and agent-wait routes forward expected payloads", async () => {
|
||||
const callGateway = vi.fn(async () => ({ ok: true }));
|
||||
vi.doMock("@/lib/controlplane/runtime", () => ({
|
||||
isStudioDomainApiModeEnabled: () => true,
|
||||
getControlPlaneRuntime: () => ({
|
||||
ensureStarted: async () => {},
|
||||
callGateway,
|
||||
}),
|
||||
}));
|
||||
const resetRoute = await import("@/app/api/intents/sessions-reset/route");
|
||||
const waitRoute = await import("@/app/api/intents/agent-wait/route");
|
||||
|
||||
const resetResponse = await resetRoute.POST(
|
||||
new Request("http://localhost/api/intents/sessions-reset", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: "agent:agent-1:main" }),
|
||||
})
|
||||
);
|
||||
const waitResponse = await waitRoute.POST(
|
||||
new Request("http://localhost/api/intents/agent-wait", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ runId: "run-1", timeoutMs: 3000 }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(resetResponse.status).toBe(200);
|
||||
expect(waitResponse.status).toBe(200);
|
||||
expect(callGateway).toHaveBeenCalledWith("sessions.reset", { key: "agent:agent-1:main" });
|
||||
expect(callGateway).toHaveBeenCalledWith("agent.wait", { runId: "run-1", timeoutMs: 3000 });
|
||||
});
|
||||
|
||||
it("exec-approvals-set role mode delegates to policy upsert helper", async () => {
|
||||
const upsert = vi.fn(async () => undefined);
|
||||
vi.doMock("@/lib/controlplane/runtime", () => ({
|
||||
isStudioDomainApiModeEnabled: () => true,
|
||||
getControlPlaneRuntime: () => ({
|
||||
ensureStarted: async () => {},
|
||||
callGateway: vi.fn(async () => ({ ok: true })),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/lib/controlplane/exec-approvals", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/controlplane/exec-approvals")>(
|
||||
"@/lib/controlplane/exec-approvals"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
upsertAgentExecApprovalsPolicyViaRuntime: upsert,
|
||||
};
|
||||
});
|
||||
const mod = await import("@/app/api/intents/exec-approvals-set/route");
|
||||
|
||||
const response = await mod.POST(
|
||||
new Request("http://localhost/api/intents/exec-approvals-set", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ agentId: "agent-1", role: "collaborative" }),
|
||||
})
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "agent-1",
|
||||
role: "collaborative",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("chat-send returns deterministic gateway_unavailable response when runtime cannot start", async () => {
|
||||
vi.doMock("@/lib/controlplane/runtime", () => ({
|
||||
isStudioDomainApiModeEnabled: () => true,
|
||||
getControlPlaneRuntime: () => ({
|
||||
ensureStarted: async () => {
|
||||
throw new Error("gateway unavailable");
|
||||
},
|
||||
callGateway: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
const mod = await import("@/app/api/intents/chat-send/route");
|
||||
|
||||
const response = await mod.POST(
|
||||
new Request("http://localhost/api/intents/chat-send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sessionKey: "agent:agent-1:main",
|
||||
message: "hello",
|
||||
idempotencyKey: "run-1",
|
||||
deliver: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
const body = await response.json() as { code?: string; reason?: string };
|
||||
expect(body.code).toBe("GATEWAY_UNAVAILABLE");
|
||||
expect(body.reason).toBe("gateway_unavailable");
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
let tempHome: string | null = null;
|
||||
|
||||
const setupHome = () => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-home-"));
|
||||
vi.spyOn(os, "homedir").mockReturnValue(tempHome);
|
||||
|
||||
fs.mkdirSync(path.join(tempHome, "Documents"), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempHome, "Downloads"), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempHome, "Doc.txt"), "doc", "utf8");
|
||||
fs.writeFileSync(path.join(tempHome, "Notes.txt"), "notes", "utf8");
|
||||
fs.writeFileSync(path.join(tempHome, ".secret"), "hidden", "utf8");
|
||||
};
|
||||
|
||||
const cleanupHome = () => {
|
||||
const home = tempHome;
|
||||
tempHome = null;
|
||||
vi.restoreAllMocks();
|
||||
if (!home) return;
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
let GET: typeof import("@/app/api/path-suggestions/route")["GET"];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ GET } = await import("@/app/api/path-suggestions/route"));
|
||||
});
|
||||
|
||||
beforeEach(setupHome);
|
||||
afterEach(cleanupHome);
|
||||
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
describe("/api/path-suggestions route", () => {
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("returns non-hidden entries for home by default", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/path-suggestions"));
|
||||
const body = (await response.json()) as { entries: Array<{ displayPath: string }> };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.entries.map((entry) => entry.displayPath)).toEqual([
|
||||
"~/Documents/",
|
||||
"~/Downloads/",
|
||||
"~/Doc.txt",
|
||||
"~/Notes.txt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters by prefix within the current directory", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/path-suggestions?q=~/Doc"));
|
||||
const body = (await response.json()) as { entries: Array<{ displayPath: string }> };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.entries.map((entry) => entry.displayPath)).toEqual([
|
||||
"~/Documents/",
|
||||
"~/Doc.txt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects paths outside the home directory", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/path-suggestions?q=~/../"));
|
||||
const body = (await response.json()) as { error: string };
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toMatch(/home/i);
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 for missing directories", async () => {
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api/path-suggestions?q=~/Missing/")
|
||||
);
|
||||
const body = (await response.json()) as { error: string };
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toMatch(/does not exist/i);
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type RuntimeMock = {
|
||||
ensureStarted: () => Promise<void>;
|
||||
snapshot: () => { status: string; reason: string | null; asOf: string | null; outboxHead: number };
|
||||
eventsAfter: (lastSeenId: number, limit?: number) => Array<{
|
||||
id: number;
|
||||
event: unknown;
|
||||
createdAt: string;
|
||||
}>;
|
||||
subscribe: (handler: (entry: { id: number; event: unknown; createdAt: string }) => void) => () => void;
|
||||
};
|
||||
|
||||
const loadRouteModule = async <T>(modulePath: string, runtimeMock: RuntimeMock) => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/controlplane/runtime", () => ({
|
||||
isStudioDomainApiModeEnabled: () => true,
|
||||
getControlPlaneRuntime: () => runtimeMock,
|
||||
}));
|
||||
return await import(modulePath) as T;
|
||||
};
|
||||
|
||||
describe("runtime routes", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("summary route returns projection-backed snapshot and freshness", async () => {
|
||||
const runtimeMock: RuntimeMock = {
|
||||
ensureStarted: async () => {},
|
||||
snapshot: () => ({
|
||||
status: "connected",
|
||||
reason: null,
|
||||
asOf: "2026-02-28T02:40:00.000Z",
|
||||
outboxHead: 12,
|
||||
}),
|
||||
eventsAfter: () => [],
|
||||
subscribe: () => () => {},
|
||||
};
|
||||
|
||||
const mod = await loadRouteModule<{ GET: () => Promise<Response> }>(
|
||||
"@/app/api/runtime/summary/route",
|
||||
runtimeMock
|
||||
);
|
||||
const response = await mod.GET();
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json() as {
|
||||
enabled: boolean;
|
||||
summary: { status: string; outboxHead: number };
|
||||
freshness: { stale: boolean; source: string };
|
||||
};
|
||||
expect(body.enabled).toBe(true);
|
||||
expect(body.summary.status).toBe("connected");
|
||||
expect(body.summary.outboxHead).toBe(12);
|
||||
expect(body.freshness.stale).toBe(false);
|
||||
expect(body.freshness.source).toBe("gateway");
|
||||
});
|
||||
|
||||
it("summary route returns degraded projection freshness when gateway start fails", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/controlplane/runtime", () => ({
|
||||
isStudioDomainApiModeEnabled: () => true,
|
||||
getControlPlaneRuntime: () => ({
|
||||
ensureStarted: async () => {
|
||||
throw new Error("gateway offline");
|
||||
},
|
||||
snapshot: () => ({
|
||||
status: "error",
|
||||
reason: "gateway_closed",
|
||||
asOf: "2026-02-28T02:40:00.000Z",
|
||||
outboxHead: 9,
|
||||
}),
|
||||
eventsAfter: () => [],
|
||||
subscribe: () => () => {},
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/lib/controlplane/degraded-read", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/controlplane/degraded-read")>(
|
||||
"@/lib/controlplane/degraded-read"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
probeOpenClawLocalState: vi.fn(async () => ({
|
||||
at: "2026-02-28T02:41:00.000Z",
|
||||
status: { ok: false, error: "openclaw_cli_not_found" },
|
||||
sessions: { ok: false, error: "openclaw_cli_not_found" },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const mod = await import("@/app/api/runtime/summary/route");
|
||||
const response = await mod.GET();
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json() as {
|
||||
error?: string;
|
||||
freshness: { stale: boolean; source: string; reason: string | null };
|
||||
};
|
||||
expect(body.error).toBe("gateway offline");
|
||||
expect(body.freshness.stale).toBe(true);
|
||||
expect(body.freshness.source).toBe("projection");
|
||||
expect(body.freshness.reason).toBe("gateway_unavailable");
|
||||
});
|
||||
|
||||
it("agent history route filters by agent id", async () => {
|
||||
const runtimeMock: RuntimeMock = {
|
||||
ensureStarted: async () => {},
|
||||
snapshot: () => ({
|
||||
status: "connected",
|
||||
reason: null,
|
||||
asOf: "2026-02-28T02:40:00.000Z",
|
||||
outboxHead: 3,
|
||||
}),
|
||||
eventsAfter: () => [
|
||||
{
|
||||
id: 1,
|
||||
event: {
|
||||
type: "gateway.event",
|
||||
event: "runtime.delta",
|
||||
seq: 10,
|
||||
payload: { sessionKey: "agent:alpha:main", delta: "a" },
|
||||
asOf: "2026-02-28T02:40:01.000Z",
|
||||
},
|
||||
createdAt: "2026-02-28T02:40:01.000Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
event: {
|
||||
type: "gateway.event",
|
||||
event: "runtime.delta",
|
||||
seq: 11,
|
||||
payload: { sessionKey: "agent:beta:main", delta: "b" },
|
||||
asOf: "2026-02-28T02:40:02.000Z",
|
||||
},
|
||||
createdAt: "2026-02-28T02:40:02.000Z",
|
||||
},
|
||||
],
|
||||
subscribe: () => () => {},
|
||||
};
|
||||
|
||||
const mod = await loadRouteModule<{
|
||||
GET: (
|
||||
request: Request,
|
||||
context: { params: Promise<{ agentId: string }> }
|
||||
) => Promise<Response>;
|
||||
}>("@/app/api/runtime/agents/[agentId]/history/route", runtimeMock);
|
||||
|
||||
const response = await mod.GET(
|
||||
new Request("http://localhost/api/runtime/agents/alpha/history?limit=50"),
|
||||
{ params: Promise.resolve({ agentId: "alpha" }) }
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json() as { entries: Array<{ id: number }> };
|
||||
expect(body.entries.map((entry) => entry.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
it("stream route replays from Last-Event-ID and emits live updates", async () => {
|
||||
let subscriber: ((entry: { id: number; event: unknown; createdAt: string }) => void) | null = null;
|
||||
const runtimeMock: RuntimeMock = {
|
||||
ensureStarted: async () => {},
|
||||
snapshot: () => ({
|
||||
status: "connected",
|
||||
reason: null,
|
||||
asOf: "2026-02-28T02:40:00.000Z",
|
||||
outboxHead: 4,
|
||||
}),
|
||||
eventsAfter: (lastSeenId: number) => {
|
||||
expect(lastSeenId).toBe(2);
|
||||
return [
|
||||
{
|
||||
id: 3,
|
||||
event: {
|
||||
type: "gateway.event",
|
||||
event: "runtime.delta",
|
||||
seq: 20,
|
||||
payload: { sessionKey: "agent:alpha:main", delta: "replay" },
|
||||
asOf: "2026-02-28T02:40:03.000Z",
|
||||
},
|
||||
createdAt: "2026-02-28T02:40:03.000Z",
|
||||
},
|
||||
];
|
||||
},
|
||||
subscribe: (handler) => {
|
||||
subscriber = handler;
|
||||
return () => {
|
||||
subscriber = null;
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const mod = await loadRouteModule<{ GET: (request: Request) => Promise<Response> }>(
|
||||
"@/app/api/runtime/stream/route",
|
||||
runtimeMock
|
||||
);
|
||||
const response = await mod.GET(
|
||||
new Request("http://localhost/api/runtime/stream", {
|
||||
headers: { "Last-Event-ID": "2" },
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
||||
expect(response.body).toBeTruthy();
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
const first = await reader.read();
|
||||
const firstChunk = new TextDecoder().decode(first.value);
|
||||
expect(firstChunk).toContain("id: 3");
|
||||
expect(firstChunk).toContain("event: gateway.event");
|
||||
|
||||
const emit = subscriber as ((entry: { id: number; event: unknown; createdAt: string }) => void) | null;
|
||||
emit?.({
|
||||
id: 4,
|
||||
event: {
|
||||
type: "runtime.status",
|
||||
status: "reconnecting",
|
||||
reason: "gateway_closed",
|
||||
asOf: "2026-02-28T02:40:04.000Z",
|
||||
},
|
||||
createdAt: "2026-02-28T02:40:04.000Z",
|
||||
});
|
||||
|
||||
const second = await reader.read();
|
||||
const secondChunk = new TextDecoder().decode(second.value);
|
||||
expect(secondChunk).toContain("id: 4");
|
||||
expect(secondChunk).toContain("event: runtime.status");
|
||||
|
||||
await reader.cancel();
|
||||
});
|
||||
|
||||
it("agent-rename and agent-delete intent routes forward to runtime", async () => {
|
||||
const callGateway = vi.fn(async () => ({ ok: true }));
|
||||
vi.doMock("@/lib/controlplane/runtime", () => ({
|
||||
isStudioDomainApiModeEnabled: () => true,
|
||||
getControlPlaneRuntime: () => ({
|
||||
ensureStarted: async () => {},
|
||||
callGateway,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renameRoute = await import("@/app/api/intents/agent-rename/route");
|
||||
const deleteRoute = await import("@/app/api/intents/agent-delete/route");
|
||||
|
||||
const renameRes = await renameRoute.POST(
|
||||
new Request("http://localhost/api/intents/agent-rename", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ agentId: "agent-1", name: "Agent One Renamed" }),
|
||||
})
|
||||
);
|
||||
expect(renameRes.status).toBe(200);
|
||||
|
||||
const deleteRes = await deleteRoute.POST(
|
||||
new Request("http://localhost/api/intents/agent-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ agentId: "agent-1" }),
|
||||
})
|
||||
);
|
||||
expect(deleteRes.status).toBe(200);
|
||||
|
||||
expect(callGateway).toHaveBeenCalledWith("agents.update", {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One Renamed",
|
||||
});
|
||||
expect(callGateway).toHaveBeenCalledWith("agents.delete", {
|
||||
agentId: "agent-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("runtime fleet route hydrates through control-plane runtime", async () => {
|
||||
const callGateway = vi.fn(async () => ({ ok: true }));
|
||||
vi.doMock("@/lib/controlplane/runtime", () => ({
|
||||
isStudioDomainApiModeEnabled: () => true,
|
||||
getControlPlaneRuntime: () => ({
|
||||
ensureStarted: async () => {},
|
||||
callGateway,
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/lib/studio/settings-store", () => ({
|
||||
loadStudioSettings: () => ({
|
||||
version: 1,
|
||||
gateway: { url: "ws://localhost:3000/ws", token: "" },
|
||||
localGatewayDefaults: { url: "", token: "" },
|
||||
focused: {},
|
||||
avatars: {},
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/features/agents/operations/agentFleetHydration", () => ({
|
||||
hydrateAgentFleetFromGateway: vi.fn(async () => ({
|
||||
seeds: [{ agentId: "agent-1", name: "Agent One", sessionKey: "agent:agent-1:main" }],
|
||||
sessionCreatedAgentIds: ["agent-1"],
|
||||
sessionSettingsSyncedAgentIds: ["agent-1"],
|
||||
summaryPatches: [],
|
||||
suggestedSelectedAgentId: "agent-1",
|
||||
configSnapshot: null,
|
||||
})),
|
||||
}));
|
||||
const route = await import("@/app/api/runtime/fleet/route");
|
||||
const response = await route.POST(
|
||||
new Request("http://localhost/api/runtime/fleet", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cachedConfigSnapshot: null }),
|
||||
})
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json() as { result: { seeds: Array<{ agentId: string }> } };
|
||||
expect(body.result.seeds[0]?.agentId).toBe("agent-1");
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentStoreSeed } from "@/features/agents/state/store";
|
||||
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
|
||||
import type { StudioSettingsPatch } from "@/lib/studio/settings";
|
||||
import { fetchJson } from "@/lib/http";
|
||||
|
||||
vi.mock("@/features/agents/operations/agentFleetHydration", () => ({
|
||||
hydrateAgentFleetFromGateway: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/http", () => ({
|
||||
fetchJson: vi.fn(),
|
||||
}));
|
||||
|
||||
import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration";
|
||||
import {
|
||||
@@ -21,10 +25,13 @@ import {
|
||||
} from "@/features/agents/operations/studioBootstrapOperation";
|
||||
|
||||
const hydrateAgentFleetFromGatewayMock = vi.mocked(hydrateAgentFleetFromGateway);
|
||||
const fetchJsonMock = vi.mocked(fetchJson);
|
||||
|
||||
describe("studioBootstrapOperation", () => {
|
||||
beforeEach(() => {
|
||||
hydrateAgentFleetFromGatewayMock.mockReset();
|
||||
fetchJsonMock.mockReset();
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
|
||||
});
|
||||
|
||||
it("builds bootstrap commands from hydrated fleet result", async () => {
|
||||
@@ -96,6 +103,44 @@ describe("studioBootstrapOperation", () => {
|
||||
expect(commands).toEqual([{ kind: "set-error", message: "load failed" }]);
|
||||
});
|
||||
|
||||
it("uses runtime fleet API in domain mode", async () => {
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
|
||||
const seeds: AgentStoreSeed[] = [{ agentId: "agent-1", name: "Agent One", sessionKey: "s1" }];
|
||||
fetchJsonMock.mockResolvedValue({
|
||||
result: {
|
||||
seeds,
|
||||
sessionCreatedAgentIds: [],
|
||||
sessionSettingsSyncedAgentIds: [],
|
||||
summaryPatches: [],
|
||||
suggestedSelectedAgentId: "agent-1",
|
||||
configSnapshot: null,
|
||||
},
|
||||
});
|
||||
|
||||
const commands = await runStudioBootstrapLoadOperation({
|
||||
client: { call: async () => null },
|
||||
gatewayUrl: "https://gateway.test",
|
||||
cachedConfigSnapshot: null,
|
||||
loadStudioSettings: async () => null,
|
||||
isDisconnectLikeError: () => false,
|
||||
preferredSelectedAgentId: null,
|
||||
hasCurrentSelection: false,
|
||||
});
|
||||
|
||||
expect(fetchJsonMock).toHaveBeenCalledWith(
|
||||
"/api/runtime/fleet",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
expect(hydrateAgentFleetFromGatewayMock).not.toHaveBeenCalled();
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
kind: "hydrate-agents",
|
||||
seeds,
|
||||
initialSelectedAgentId: "agent-1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("executes bootstrap commands with injected callbacks", () => {
|
||||
const commands: StudioBootstrapLoadCommand[] = [
|
||||
{
|
||||
|
||||
@@ -103,6 +103,21 @@ describe("studio settings normalization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves gateway token when patching only url", () => {
|
||||
const current = normalizeStudioSettings({
|
||||
gateway: { url: "ws://gateway.old:18789", token: "secret-token" },
|
||||
});
|
||||
|
||||
const merged = mergeStudioSettings(current, {
|
||||
gateway: { url: "ws://gateway.new:18789" },
|
||||
});
|
||||
|
||||
expect(merged.gateway).toEqual({
|
||||
url: "ws://gateway.new:18789",
|
||||
token: "secret-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes avatar seeds per gateway", () => {
|
||||
const normalized = normalizeStudioSettings({
|
||||
avatars: {
|
||||
|
||||
@@ -88,4 +88,27 @@ describe("StudioSettingsCoordinator", () => {
|
||||
|
||||
expect(updateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requeues pending patch when update fails", async () => {
|
||||
const fetchSettings = vi.fn(async () => ({ settings: defaultStudioSettings() }));
|
||||
const updateSettings = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("write failed"))
|
||||
.mockResolvedValue({ settings: defaultStudioSettings() });
|
||||
const coordinator = new StudioSettingsCoordinator({ fetchSettings, updateSettings }, 1000);
|
||||
|
||||
coordinator.schedulePatch({
|
||||
gateway: { url: "ws://localhost:18789", token: "session-a" },
|
||||
});
|
||||
|
||||
await expect(coordinator.flushPending()).rejects.toThrow("write failed");
|
||||
await coordinator.flushPending();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledTimes(2);
|
||||
expect(updateSettings).toHaveBeenNthCalledWith(2, {
|
||||
gateway: { url: "ws://localhost:18789", token: "session-a" },
|
||||
});
|
||||
|
||||
coordinator.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,14 @@ const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${n
|
||||
|
||||
describe("studio settings route", () => {
|
||||
const priorStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const priorStudioDomainApiMode = process.env.STUDIO_DOMAIN_API_MODE;
|
||||
const priorNextPublicStudioDomainApiMode = process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE;
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.OPENCLAW_STATE_DIR = priorStateDir;
|
||||
process.env.STUDIO_DOMAIN_API_MODE = priorStudioDomainApiMode;
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = priorNextPublicStudioDomainApiMode;
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
@@ -28,14 +32,28 @@ describe("studio settings route", () => {
|
||||
const body = (await response.json()) as {
|
||||
settings?: Record<string, unknown>;
|
||||
localGatewayDefaults?: unknown;
|
||||
domainApiModeEnabled?: unknown;
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.settings?.gateway).toBe(null);
|
||||
expect(body.localGatewayDefaults ?? null).toBeNull();
|
||||
expect(typeof body.domainApiModeEnabled).toBe("boolean");
|
||||
expect(body.settings?.version).toBe(1);
|
||||
});
|
||||
|
||||
it("GET reports domain mode from server env with STUDIO override precedence", async () => {
|
||||
tempDir = makeTempDir("studio-settings-domain-mode");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
|
||||
process.env.STUDIO_DOMAIN_API_MODE = "false";
|
||||
|
||||
const response = await GET();
|
||||
const body = (await response.json()) as { domainApiModeEnabled?: unknown };
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.domainApiModeEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("GET returns local gateway defaults from openclaw.json", async () => {
|
||||
tempDir = makeTempDir("studio-settings-get-local-defaults");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
@@ -54,11 +72,11 @@ describe("studio settings route", () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.localGatewayDefaults).toEqual({
|
||||
url: "ws://localhost:18791",
|
||||
token: "local-token",
|
||||
token: "",
|
||||
});
|
||||
expect(body.settings?.gateway).toEqual({
|
||||
url: "ws://localhost:18791",
|
||||
token: "local-token",
|
||||
token: "",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,7 +113,7 @@ describe("studio settings route", () => {
|
||||
};
|
||||
|
||||
expect(getResponse.status).toBe(200);
|
||||
expect(body.settings?.gateway).toEqual({ url: "ws://example.test:1234", token: "t" });
|
||||
expect(body.settings?.gateway).toEqual({ url: "ws://example.test:1234", token: "" });
|
||||
|
||||
const settingsPath = path.join(tempDir, "openclaw-studio", "settings.json");
|
||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||
@@ -103,4 +121,44 @@ describe("studio settings route", () => {
|
||||
const parsed = JSON.parse(raw) as { gateway?: { url?: string; token?: string } | null };
|
||||
expect(parsed.gateway).toEqual({ url: "ws://example.test:1234", token: "t" });
|
||||
});
|
||||
|
||||
it("PUT url-only gateway patch preserves existing token", async () => {
|
||||
tempDir = makeTempDir("studio-settings-put-url-only");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
fs.mkdirSync(path.join(tempDir, "openclaw-studio"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, "openclaw-studio", "settings.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
gateway: { url: "ws://old.example:18789", token: "secret-token" },
|
||||
focused: {},
|
||||
avatars: {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const putResponse = await PUT({
|
||||
json: async () => ({ gateway: { url: "ws://new.example:18789" } }),
|
||||
} as unknown as Request);
|
||||
expect(putResponse.status).toBe(200);
|
||||
|
||||
const getResponse = await GET();
|
||||
const body = (await getResponse.json()) as {
|
||||
settings?: { gateway?: { url?: string; token?: string } | null };
|
||||
};
|
||||
expect(getResponse.status).toBe(200);
|
||||
expect(body.settings?.gateway).toEqual({ url: "ws://new.example:18789", token: "" });
|
||||
|
||||
const persisted = JSON.parse(
|
||||
fs.readFileSync(path.join(tempDir, "openclaw-studio", "settings.json"), "utf8")
|
||||
) as { gateway?: { url?: string; token?: string } };
|
||||
expect(persisted.gateway).toEqual({
|
||||
url: "ws://new.example:18789",
|
||||
token: "secret-token",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,6 +234,7 @@ describe("useAgentSettingsMutationController", () => {
|
||||
const mockedUpdateSkill = vi.mocked(updateSkill);
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
|
||||
restartBlockHookParams = null;
|
||||
mockedDeleteAgentViaStudio.mockReset();
|
||||
mockedPerformCronCreateFlow.mockReset();
|
||||
@@ -277,6 +278,7 @@ describe("useAgentSettingsMutationController", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE;
|
||||
});
|
||||
|
||||
it("delete_denied_by_guard_does_not_run_delete_side_effect", async () => {
|
||||
@@ -290,6 +292,33 @@ describe("useAgentSettingsMutationController", () => {
|
||||
expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("domain_mode_allows_delete_when_browser_gateway_is_disconnected", async () => {
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
mockedRunLifecycle.mockImplementation(async ({ deps }) => {
|
||||
deps.setQueuedBlock();
|
||||
deps.setMutatingBlock();
|
||||
await deps.executeMutation();
|
||||
deps.clearBlock();
|
||||
return true;
|
||||
});
|
||||
mockedDeleteAgentViaStudio.mockResolvedValue({ trashed: { trashDir: "", moved: [] }, restored: null });
|
||||
|
||||
const ctx = renderController({ status: "disconnected" });
|
||||
|
||||
await act(async () => {
|
||||
await ctx.getValue().handleDeleteAgent("agent-1");
|
||||
});
|
||||
|
||||
expect(mockedRunLifecycle).toHaveBeenCalled();
|
||||
expect(mockedDeleteAgentViaStudio).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "agent-1",
|
||||
useDomainIntents: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("delete_cancelled_by_confirmation_does_not_run_delete_side_effect", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
const ctx = renderController();
|
||||
|
||||
@@ -5,10 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
|
||||
import { postStudioIntent } from "@/lib/controlplane/intents-client";
|
||||
|
||||
vi.mock("@/features/agents/operations/chatSendOperation", () => ({
|
||||
sendChatMessageViaStudio: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/controlplane/intents-client", () => ({
|
||||
postStudioIntent: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => {
|
||||
const base: AgentState = {
|
||||
@@ -191,6 +195,7 @@ const renderController = (
|
||||
|
||||
describe("useChatInteractionController", () => {
|
||||
const mockedSendChatMessageViaStudio = vi.mocked(sendChatMessageViaStudio);
|
||||
const mockedPostStudioIntent = vi.mocked(postStudioIntent);
|
||||
const originalRaf = globalThis.requestAnimationFrame;
|
||||
const originalCaf = globalThis.cancelAnimationFrame;
|
||||
|
||||
@@ -198,6 +203,9 @@ describe("useChatInteractionController", () => {
|
||||
vi.useFakeTimers();
|
||||
mockedSendChatMessageViaStudio.mockReset();
|
||||
mockedSendChatMessageViaStudio.mockResolvedValue(undefined);
|
||||
mockedPostStudioIntent.mockReset();
|
||||
mockedPostStudioIntent.mockResolvedValue({ ok: true });
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -205,6 +213,7 @@ describe("useChatInteractionController", () => {
|
||||
globalThis.requestAnimationFrame = originalRaf;
|
||||
globalThis.cancelAnimationFrame = originalCaf;
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE;
|
||||
});
|
||||
|
||||
it("flushes pending draft and cancels debounce timer", async () => {
|
||||
@@ -500,6 +509,28 @@ describe("useChatInteractionController", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses sessions-reset intent for new-session in domain mode", async () => {
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
|
||||
const ctx = renderController({
|
||||
agents: [
|
||||
createAgent({
|
||||
agentId: "agent-1",
|
||||
runId: "run-42",
|
||||
sessionKey: " session-42 ",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await ctx.getValue().handleNewSession("agent-1");
|
||||
});
|
||||
|
||||
expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/sessions-reset", {
|
||||
key: "session-42",
|
||||
});
|
||||
expect(ctx.call).not.toHaveBeenCalledWith("sessions.reset", expect.anything());
|
||||
});
|
||||
|
||||
it("appends output when new-session fails", async () => {
|
||||
const ctx = renderController({
|
||||
agents: [
|
||||
|
||||
@@ -74,7 +74,10 @@ const setupAndImportHook = async (gatewayUrl: string | null) => {
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
localGatewayDefaults: { url: string; token: string } | null;
|
||||
domainApiModeEnabled: boolean | null;
|
||||
useLocalGatewayDefaults: () => void;
|
||||
setGatewayUrl: (value: string) => void;
|
||||
setToken: (value: string) => void;
|
||||
},
|
||||
captured,
|
||||
};
|
||||
@@ -166,11 +169,12 @@ describe("useGatewayConnection", () => {
|
||||
loadSettingsEnvelope: async () => ({
|
||||
settings: {
|
||||
version: 1,
|
||||
gateway: { url: "wss://remote.example", token: "remote-token" },
|
||||
gateway: { url: "wss://remote.example", token: "" },
|
||||
focused: {},
|
||||
avatars: {},
|
||||
},
|
||||
localGatewayDefaults: { url: "ws://localhost:18789", token: "local-token" },
|
||||
localGatewayDefaults: { url: "ws://localhost:18789", token: "" },
|
||||
domainApiModeEnabled: true,
|
||||
}),
|
||||
schedulePatch: () => {},
|
||||
flushPending: async () => {},
|
||||
@@ -188,6 +192,11 @@ describe("useGatewayConnection", () => {
|
||||
{ "data-testid": "localDefaultsUrl" },
|
||||
state.localGatewayDefaults?.url ?? ""
|
||||
),
|
||||
createElement(
|
||||
"div",
|
||||
{ "data-testid": "domainApiModeEnabled" },
|
||||
state.domainApiModeEnabled === null ? "null" : String(state.domainApiModeEnabled)
|
||||
),
|
||||
createElement(
|
||||
"button",
|
||||
{
|
||||
@@ -205,14 +214,123 @@ describe("useGatewayConnection", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("wss://remote.example");
|
||||
});
|
||||
expect(screen.getByTestId("token")).toHaveTextContent("remote-token");
|
||||
expect(screen.getByTestId("token")).toHaveTextContent("");
|
||||
expect(screen.getByTestId("localDefaultsUrl")).toHaveTextContent("ws://localhost:18789");
|
||||
expect(screen.getByTestId("domainApiModeEnabled")).toHaveTextContent("true");
|
||||
|
||||
fireEvent.click(screen.getByTestId("useLocalDefaults"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("ws://localhost:18789");
|
||||
});
|
||||
expect(screen.getByTestId("token")).toHaveTextContent("local-token");
|
||||
expect(screen.getByTestId("token")).toHaveTextContent("");
|
||||
});
|
||||
|
||||
it("persists gateway url changes without sending token", async () => {
|
||||
const { useGatewayConnection } = await setupAndImportHook(null);
|
||||
const schedulePatch = vi.fn();
|
||||
const coordinator = {
|
||||
loadSettings: async () => null,
|
||||
loadSettingsEnvelope: async () => ({
|
||||
settings: {
|
||||
version: 1,
|
||||
gateway: { url: "wss://remote.example", token: "" },
|
||||
focused: {},
|
||||
avatars: {},
|
||||
},
|
||||
localGatewayDefaults: null,
|
||||
}),
|
||||
schedulePatch,
|
||||
flushPending: async () => {},
|
||||
};
|
||||
|
||||
const Probe = () => {
|
||||
const state = useGatewayConnection(coordinator);
|
||||
return createElement(
|
||||
"div",
|
||||
null,
|
||||
createElement("div", { "data-testid": "gatewayUrl" }, state.gatewayUrl),
|
||||
createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
onClick: () => state.setGatewayUrl("wss://remote-next.example"),
|
||||
"data-testid": "changeUrl",
|
||||
},
|
||||
"change"
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
render(createElement(Probe));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("wss://remote.example");
|
||||
});
|
||||
expect(schedulePatch).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId("changeUrl"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(schedulePatch).toHaveBeenCalled();
|
||||
});
|
||||
expect(schedulePatch).toHaveBeenLastCalledWith(
|
||||
{ gateway: { url: "wss://remote-next.example" } },
|
||||
400
|
||||
);
|
||||
});
|
||||
|
||||
it("persists token only after explicit token edit", async () => {
|
||||
const { useGatewayConnection } = await setupAndImportHook(null);
|
||||
const schedulePatch = vi.fn();
|
||||
const coordinator = {
|
||||
loadSettings: async () => null,
|
||||
loadSettingsEnvelope: async () => ({
|
||||
settings: {
|
||||
version: 1,
|
||||
gateway: { url: "wss://remote.example", token: "" },
|
||||
focused: {},
|
||||
avatars: {},
|
||||
},
|
||||
localGatewayDefaults: null,
|
||||
}),
|
||||
schedulePatch,
|
||||
flushPending: async () => {},
|
||||
};
|
||||
|
||||
const Probe = () => {
|
||||
const state = useGatewayConnection(coordinator);
|
||||
return createElement(
|
||||
"div",
|
||||
null,
|
||||
createElement("div", { "data-testid": "gatewayUrl" }, state.gatewayUrl),
|
||||
createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
onClick: () => state.setToken("manual-token"),
|
||||
"data-testid": "setToken",
|
||||
},
|
||||
"token"
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
render(createElement(Probe));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("wss://remote.example");
|
||||
});
|
||||
expect(schedulePatch).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId("setToken"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(schedulePatch).toHaveBeenCalled();
|
||||
});
|
||||
expect(schedulePatch).toHaveBeenLastCalledWith(
|
||||
{ gateway: { url: "wss://remote.example", token: "manual-token" } },
|
||||
400
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,6 +181,7 @@ describe("useRuntimeSyncController", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
|
||||
mockedRunHistorySyncOperation.mockReset();
|
||||
mockedRunHistorySyncOperation.mockResolvedValue([]);
|
||||
mockedExecuteHistorySyncCommands.mockReset();
|
||||
@@ -192,6 +193,7 @@ describe("useRuntimeSyncController", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE;
|
||||
});
|
||||
|
||||
it("runs reconcile immediately and every 3000ms while connected then cleans up", async () => {
|
||||
@@ -365,4 +367,50 @@ describe("useRuntimeSyncController", () => {
|
||||
|
||||
expect(inFlightSeen).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
it("uses domain runtime APIs when NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE is enabled", async () => {
|
||||
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/runtime/summary")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
enabled: true,
|
||||
summary: { status: "connected", reason: null, asOf: null, outboxHead: 0 },
|
||||
freshness: { source: "controlplane", stale: false, asOf: null, reason: null },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/runtime/agents/")) {
|
||||
return new Response(JSON.stringify({ enabled: true, entries: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ctx = renderController({
|
||||
focusedAgentId: "agent-1",
|
||||
focusedAgentRunning: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/runtime/summary", expect.anything());
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/runtime/agents/agent-1/history"),
|
||||
expect.anything()
|
||||
);
|
||||
expect(ctx.call).not.toHaveBeenCalledWith("status", {});
|
||||
vi.unstubAllGlobals();
|
||||
ctx.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user