diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..f3f52b4 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.9.0 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 33019aa..f6643fc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -78,6 +78,7 @@ Flow: ### 2b) Control-plane domain API mode + replay/history - **Authoritative mode source**: `src/app/page.tsx` derives `useDomainApiMode` only from `/api/studio` (`domainApiModeEnabled === true`). Client env checks are not used for runtime request routing in the main app flow. +- **Legacy WS suppression in domain mode**: `useGatewayConnection` in `src/lib/gateway/GatewayClient.ts` does not auto-open or auto-retry `/api/gateway/ws` when `domainApiModeEnabled === true`; legacy browser WS remains available for explicit legacy-mode/diagnostic use. - **Live stream replay contract** (`/api/runtime/stream`): with `Last-Event-ID > 0`, replay starts after that id; without `Last-Event-ID`, replay starts from the recent outbox tail (`outboxHead - REPLAY_LIMIT`) to avoid stale full-history startup replays. - **Gap-free stream bootstrap sequencing** (`/api/runtime/stream`): stream startup subscribes first, buffers startup live rows, fetches replay from the effective cursor/floor, drains buffered rows in ascending outbox id order, and emits all rows through one monotonic id guard (`entry.id > lastDeliveredId`). This prevents replay/subscribe boundary drops and replay/live overlap duplicates in reconnect and fresh-connect paths. - **History pagination contract** (`/api/runtime/agents/[agentId]/history`): accepts `limit` and optional `beforeOutboxId` (exclusive upper bound), returns `entries` in ascending outbox order plus `hasMore` and `nextBeforeOutboxId`. Initial reads fetch the newest window; “load more” requests pass the returned cursor. @@ -106,6 +107,7 @@ Flow: ## 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 `/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 `/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`). +- **Runtime durability + startup guard**: domain-mode runtime projection/outbox persistence is stored in `${resolveStateDir()}/openclaw-studio/runtime.db` via `better-sqlite3` (`src/lib/controlplane/projection-store.ts`). Startup scripts (`verify:native-runtime:repair` for `dev` and `verify:native-runtime:check` for `start`) verify native addon compatibility before server boot. - **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**: diff --git a/README.md b/README.md index 492371e..188efaf 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ All setups use the same install/run path (recommended): `npx -y openclaw-studio@ ## Requirements -- Node.js 18+ (LTS recommended) +- Node.js 20.9+ (LTS recommended) - An OpenClaw Gateway URL + token - Tailscale (optional, recommended for remote access) @@ -78,10 +78,12 @@ Notes: ## How It Connects (Mental Model) -There are **two separate network paths**: +In domain API mode (default), there are **two primary paths**: -1. Browser -> Studio: HTTP for the UI, plus a WebSocket to `ws(s)://:3000/api/gateway/ws` -2. Studio -> Gateway (upstream): a second WebSocket opened by the Studio Node server to your configured Upstream URL +1. Browser -> Studio: HTTP + SSE (`/api/runtime/*`, `/api/intents/*`, `/api/runtime/stream`) +2. Studio -> Gateway (upstream): one server-owned WebSocket opened by the Studio Node process + +The legacy browser WebSocket bridge (`/api/gateway/ws`) is still available for compatibility/diagnostics when domain mode is disabled. This is why `ws://localhost:18789` means “gateway on the Studio host”, not “gateway on your phone”. @@ -99,9 +101,19 @@ npm run dev Paths and key settings: - OpenClaw config: `~/.openclaw/openclaw.json` (or via `OPENCLAW_STATE_DIR`) - Studio settings: `~/.openclaw/openclaw-studio/settings.json` +- Control-plane runtime DB: `~/.openclaw/openclaw-studio/runtime.db` - Default gateway URL: `ws://localhost:18789` (override via Studio Settings or `NEXT_PUBLIC_GATEWAY_URL`) +- Domain API mode toggle: `STUDIO_DOMAIN_API_MODE` (server) or `NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE` fallback. The UI reads the effective value from `/api/studio` (`domainApiModeEnabled`) and uses that server-reported value for runtime routing. - `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`) +Startup guard behavior: +- `npm run dev` and `npm run dev:turbo` run `verify:native-runtime:repair` before server startup. +- `npm run start` runs `verify:native-runtime:check` before startup (check-only; no dependency mutation). + +Why SQLite exists now: +- Studio’s server-owned control plane stores durable runtime projection + replay outbox in `runtime.db`. +- This keeps runtime history and SSE replay deterministic across page refreshes and process restarts. + ## UI guide See `docs/ui-guide.md` for UI workflows (agent creation, cron jobs, exec approvals). @@ -127,6 +139,12 @@ If the UI loads but “Connect” fails, it’s usually Studio->Gateway: - 401 “Studio access token required”: `STUDIO_ACCESS_TOKEN` is enabled; open `/?access_token=...` once to set the cookie. - Helpful error codes: `studio.gateway_url_missing`, `studio.gateway_token_missing`, `studio.upstream_error`, `studio.upstream_closed`. +If startup fails with `better_sqlite3.node` / `NODE_MODULE_VERSION` mismatch: +- Run `npm run verify:native-runtime:repair` +- If it still fails, run: + - `npm rebuild better-sqlite3` + - `npm install` + ## Architecture See `ARCHITECTURE.md` for details on modules and data flow. diff --git a/next.config.ts b/next.config.ts index cb651cd..d031c52 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,7 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = {}; +const nextConfig: NextConfig = { + serverExternalPackages: ["ws", "better-sqlite3"], +}; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 3ef14af..39ec1ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "eslint-config-next": "16.1.6", "eslint-config-prettier": "^10.1.8", "jsdom": "^27.4.0", + "postcss": "^8.5.6", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.0.18" diff --git a/package.json b/package.json index 47a7718..b748b51 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,15 @@ "name": "openclaw-studio", "version": "0.1.0", "private": true, + "engines": { + "node": ">=20.9.0" + }, "scripts": { + "verify:native-runtime:check": "node scripts/verify-native-runtime.mjs --check", + "verify:native-runtime:repair": "node scripts/verify-native-runtime.mjs --repair", + "predev": "npm run verify:native-runtime:repair", + "predev:turbo": "npm run verify:native-runtime:repair", + "prestart": "npm run verify:native-runtime:check", "dev": "node server/index.js --dev", "dev:turbo": "node server/index.js --dev", "build": "next build", @@ -15,7 +23,7 @@ "typecheck": "tsc --noEmit", "test": "vitest", "e2e": "playwright test", - "pw:open:max": "scripts/playwright-open-maximized.sh" + "pw:open:max": "bash scripts/playwright-open-maximized.sh" }, "dependencies": { "@multiavatar/multiavatar": "github:multiavatar/Multiavatar", @@ -46,6 +54,7 @@ "eslint-config-next": "16.1.6", "eslint-config-prettier": "^10.1.8", "jsdom": "^27.4.0", + "postcss": "^8.5.6", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.0.18" diff --git a/scripts/verify-native-runtime.mjs b/scripts/verify-native-runtime.mjs new file mode 100644 index 0000000..5ce4a45 --- /dev/null +++ b/scripts/verify-native-runtime.mjs @@ -0,0 +1,110 @@ +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +const mode = process.argv.includes("--repair") ? "repair" : "check"; +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + +const log = (message) => { + console.info(`[native-runtime] ${message}`); +}; + +const getErrorCode = (error) => { + if (!error || typeof error !== "object" || Array.isArray(error)) return ""; + const code = error.code; + if (typeof code !== "string") return ""; + return code.trim().toUpperCase(); +}; + +const getErrorMessage = (error) => + error instanceof Error ? error.message : String(error ?? "unknown_error"); + +const isNativeMismatchError = (error, message) => { + const code = getErrorCode(error); + const normalized = message.toLowerCase(); + const hasModuleVersionSignal = + normalized.includes("node_module_version") || + normalized.includes("compiled against a different node.js version"); + const hasBetterSqliteSignal = + normalized.includes("better_sqlite3.node") || normalized.includes("better-sqlite3"); + if (!hasModuleVersionSignal || !hasBetterSqliteSignal) return false; + return code.length === 0 || code === "ERR_DLOPEN_FAILED"; +}; + +const isMissingBetterSqliteModule = (error, message) => { + const code = getErrorCode(error); + const normalized = message.toLowerCase(); + if (!normalized.includes("better-sqlite3")) return false; + if (code === "MODULE_NOT_FOUND") return true; + return normalized.includes("cannot find module"); +}; + +const printRemediation = () => { + console.error("[native-runtime] remediation: npm rebuild better-sqlite3"); + console.error("[native-runtime] remediation: npm install"); +}; + +const verifyLoad = () => { + try { + require("better-sqlite3"); + return { ok: true }; + } catch (error) { + return { + ok: false, + error, + message: getErrorMessage(error), + }; + } +}; + +const rebuildBetterSqlite = () => { + const result = spawnSync(npmCommand, ["rebuild", "better-sqlite3"], { + stdio: "inherit", + env: process.env, + }); + return result.status === 0; +}; + +log(`mode=${mode}`); +log(`node=${process.version} abi=${process.versions.modules}`); + +const firstPass = verifyLoad(); +if (firstPass.ok) { + log("better-sqlite3 load: ok"); + process.exit(0); +} + +if (!isNativeMismatchError(firstPass.error, firstPass.message)) { + if (isMissingBetterSqliteModule(firstPass.error, firstPass.message)) { + console.error(`[native-runtime] better-sqlite3 module is missing: ${firstPass.message}`); + printRemediation(); + process.exit(1); + } + console.error(`[native-runtime] better-sqlite3 load failed: ${firstPass.message}`); + printRemediation(); + process.exit(1); +} + +console.error(`[native-runtime] detected native ABI mismatch: ${firstPass.message}`); + +if (mode !== "repair") { + printRemediation(); + process.exit(1); +} + +log("attempting rebuild: npm rebuild better-sqlite3"); +if (!rebuildBetterSqlite()) { + console.error("[native-runtime] rebuild failed"); + printRemediation(); + process.exit(1); +} + +const secondPass = verifyLoad(); +if (!secondPass.ok) { + console.error(`[native-runtime] better-sqlite3 still failing after rebuild: ${secondPass.message}`); + printRemediation(); + process.exit(1); +} + +log("better-sqlite3 load: ok (after rebuild)"); diff --git a/server/gateway-proxy.js b/server/gateway-proxy.js index bd63909..17f8497 100644 --- a/server/gateway-proxy.js +++ b/server/gateway-proxy.js @@ -79,12 +79,19 @@ const hasCompleteDeviceAuth = (params) => { ); }; +const isExpectedCloseBeforeOpenError = (error) => { + if (!(error instanceof Error)) return false; + const message = error.message.toLowerCase(); + return message.includes("closed before the connection was established"); +}; + function createGatewayProxy(options) { const { loadUpstreamSettings, allowWs = (req) => resolvePathname(req.url) === "/api/gateway/ws", log = () => {}, logError = (msg, err) => console.error(msg, err), + createUpstreamWebSocket = (url, wsOptions) => new WebSocket(url, wsOptions), } = options || {}; if (typeof loadUpstreamSettings !== "function") { @@ -186,7 +193,16 @@ function createGatewayProxy(options) { return; } - upstreamWs = new WebSocket(upstreamUrl, { origin: upstreamOrigin }); + try { + upstreamWs = createUpstreamWebSocket(upstreamUrl, { origin: upstreamOrigin }); + } catch (err) { + logError("Upstream gateway WebSocket creation failed.", err); + sendConnectError( + "studio.upstream_error", + "Failed to connect to upstream gateway WebSocket." + ); + return; + } upstreamWs.on("open", () => { upstreamReady = true; @@ -230,6 +246,10 @@ function createGatewayProxy(options) { }); upstreamWs.on("error", (err) => { + if (isExpectedCloseBeforeOpenError(err) && closed) { + log("Suppressed upstream close-before-open race."); + return; + } logError("Upstream gateway WebSocket error.", err); sendConnectError( "studio.upstream_error", diff --git a/server/index.js b/server/index.js index 1f689de..8d2c466 100644 --- a/server/index.js +++ b/server/index.js @@ -1,3 +1,6 @@ +process.env.WS_NO_BUFFER_UTIL = process.env.WS_NO_BUFFER_UTIL || "1"; +process.env.WS_NO_UTF_8_VALIDATE = process.env.WS_NO_UTF_8_VALIDATE || "1"; + const http = require("node:http"); const next = require("next"); diff --git a/src/app/api/intents/agent-create/route.ts b/src/app/api/intents/agent-create/route.ts new file mode 100644 index 0000000..7be47f1 --- /dev/null +++ b/src/app/api/intents/agent-create/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from "next/server"; + +import { ensureDomainIntentRuntime, parseIntentBody } from "@/lib/controlplane/intent-route"; +import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter"; +import { slugifyAgentName } from "@/lib/gateway/agentConfig"; + +export const runtime = "nodejs"; + +type GatewayConfigSnapshot = { + path?: string | null; +}; + +const dirnameLike = (value: string): string => { + const lastSlash = value.lastIndexOf("/"); + const lastBackslash = value.lastIndexOf("\\"); + const index = Math.max(lastSlash, lastBackslash); + if (index < 0) return ""; + return value.slice(0, index); +}; + +const joinPathLike = (dir: string, leaf: string): string => { + const sep = dir.includes("\\") ? "\\" : "/"; + const trimmedDir = dir.endsWith("/") || dir.endsWith("\\") ? dir.slice(0, -1) : dir; + return `${trimmedDir}${sep}${leaf}`; +}; + +export async function POST(request: Request) { + const bodyOrError = await parseIntentBody(request); + if (bodyOrError instanceof Response) { + return bodyOrError as NextResponse; + } + + const name = typeof bodyOrError.name === "string" ? bodyOrError.name.trim() : ""; + if (!name) { + return NextResponse.json({ error: "name is required." }, { status: 400 }); + } + + const runtimeOrError = await ensureDomainIntentRuntime(); + if (runtimeOrError instanceof Response) { + return runtimeOrError as NextResponse; + } + + try { + const snapshot = await runtimeOrError.callGateway("config.get", {}); + const configPath = typeof snapshot.path === "string" ? snapshot.path.trim() : ""; + if (!configPath) { + throw new Error( + 'Gateway did not return a config path; cannot compute a default workspace for "agents.create".' + ); + } + const stateDir = dirnameLike(configPath); + if (!stateDir) { + throw new Error( + `Gateway config path "${configPath}" is missing a directory; cannot compute workspace.` + ); + } + const workspace = joinPathLike(stateDir, `workspace-${slugifyAgentName(name)}`); + const payload = await runtimeOrError.callGateway("agents.create", { + name, + workspace, + }); + 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 } + ); + } + 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 }); + } +} diff --git a/src/app/api/intents/agent-permissions-update/route.ts b/src/app/api/intents/agent-permissions-update/route.ts new file mode 100644 index 0000000..0762792 --- /dev/null +++ b/src/app/api/intents/agent-permissions-update/route.ts @@ -0,0 +1,319 @@ +import { NextResponse } from "next/server"; + +import { + ensureDomainIntentRuntime, + parseIntentBody, +} from "@/lib/controlplane/intent-route"; +import { + upsertAgentExecApprovalsPolicyViaRuntime, + type ExecutionRoleId, +} from "@/lib/controlplane/exec-approvals"; +import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter"; +import type { ControlPlaneRuntime } from "@/lib/controlplane/runtime"; + +export const runtime = "nodejs"; + +type CommandModeId = "off" | "ask" | "auto"; +type GatewayConfigSnapshot = { + config?: unknown; + hash?: string; + exists?: boolean; +}; +type ConfigAgentEntry = Record & { id: string }; +type GatewayAgentToolsOverrides = { + allow?: string[]; + alsoAllow?: string[]; + deny?: string[]; +}; + +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +const coerceStringArray = (value: unknown): string[] | null => { + if (!Array.isArray(value)) return null; + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.length > 0); +}; + +const normalizeToolList = (values: string[] | undefined): string[] | undefined => { + if (!values) return undefined; + const next = values + .map((value) => value.trim()) + .filter((value) => value.length > 0); + return Array.from(new Set(next)); +}; + +const readConfigAgentList = (config: Record | undefined): ConfigAgentEntry[] => { + if (!config) return []; + const agentsRaw = config.agents; + const agents = isRecord(agentsRaw) ? agentsRaw : null; + const list = Array.isArray(agents?.list) ? agents.list : []; + return list.filter((entry): entry is ConfigAgentEntry => { + if (!isRecord(entry)) return false; + if (typeof entry.id !== "string") return false; + return entry.id.trim().length > 0; + }); +}; + +const writeConfigAgentList = ( + config: Record, + list: ConfigAgentEntry[] +): Record => { + const agents = isRecord(config.agents) ? { ...config.agents } : {}; + return { ...config, agents: { ...agents, list } }; +}; + +const upsertConfigAgentEntry = ( + list: ConfigAgentEntry[], + agentId: string, + updater: (entry: ConfigAgentEntry) => ConfigAgentEntry +): ConfigAgentEntry[] => { + let found = false; + const nextList = list.map((entry) => { + if (entry.id !== agentId) return entry; + found = true; + return updater({ ...entry, id: agentId }); + }); + if (!found) { + nextList.push(updater({ id: agentId })); + } + return nextList; +}; + +const resolveRoleForCommandMode = (mode: CommandModeId): ExecutionRoleId => { + if (mode === "auto") return "autonomous"; + if (mode === "ask") return "collaborative"; + return "conservative"; +}; + +const resolveToolGroupOverrides = (params: { + existingTools: unknown; + runtimeEnabled: boolean; + webEnabled: boolean; + fsEnabled: boolean; +}): { tools: GatewayAgentToolsOverrides } => { + const tools = isRecord(params.existingTools) ? params.existingTools : null; + const existingAllow = coerceStringArray(tools?.allow); + const existingAlsoAllow = coerceStringArray(tools?.alsoAllow); + const existingDeny = coerceStringArray(tools?.deny) ?? []; + + const usesAllow = existingAllow !== null; + const allowed = new Set(usesAllow ? existingAllow : existingAlsoAllow ?? []); + const denied = new Set(existingDeny); + + const applyGroup = (group: "group:runtime" | "group:web" | "group:fs", enabled: boolean) => { + if (enabled) { + allowed.add(group); + denied.delete(group); + return; + } + allowed.delete(group); + denied.add(group); + }; + + applyGroup("group:runtime", params.runtimeEnabled); + applyGroup("group:web", params.webEnabled); + applyGroup("group:fs", params.fsEnabled); + + const allowedList = Array.from(allowed); + const denyList = Array.from(denied).filter((entry) => !allowed.has(entry)); + return { + tools: usesAllow + ? { allow: allowedList, deny: denyList } + : { alsoAllow: allowedList, deny: denyList }, + }; +}; + +const resolveSessionExecSettingsForRole = (params: { + role: ExecutionRoleId; + sandboxMode: string; +}) => { + if (params.role === "conservative") { + return { execHost: null, execSecurity: "deny" as const, execAsk: "off" as const }; + } + const normalizedMode = params.sandboxMode.trim().toLowerCase(); + const execHost = normalizedMode === "all" ? "sandbox" : "gateway"; + if (params.role === "autonomous") { + return { execHost, execSecurity: "full" as const, execAsk: "off" as const }; + } + return { execHost, execSecurity: "allowlist" as const, execAsk: "always" as const }; +}; + +const isConfigConflict = (err: unknown): boolean => { + if (!(err instanceof ControlPlaneGatewayError)) return false; + if (err.code.trim().toUpperCase() !== "INVALID_REQUEST") return false; + const message = err.message.toLowerCase(); + return ( + message.includes("basehash") || + message.includes("base hash") || + message.includes("changed since last load") || + message.includes("re-run config.get") + ); +}; + +const isGatewayUnavailable = (err: unknown): boolean => + err instanceof ControlPlaneGatewayError && err.code.trim().toUpperCase() === "GATEWAY_UNAVAILABLE"; + +const buildConfigSetPayload = (params: { + config: Record; + hash?: string; + exists?: boolean; +}): Record => { + const payload: Record = { + raw: JSON.stringify(params.config, null, 2), + }; + if (params.exists !== false) { + const baseHash = params.hash?.trim(); + if (!baseHash) { + throw new Error("Gateway config hash unavailable; re-run config.get."); + } + payload.baseHash = baseHash; + } + return payload; +}; + +const applyAgentToolsOverrides = async (params: { + runtime: ControlPlaneRuntime; + agentId: string; + baseConfig: Record; + snapshotHash?: string; + snapshotExists?: boolean; + overrides: GatewayAgentToolsOverrides; + attempt?: number; +}): Promise => { + const attempt = params.attempt ?? 0; + const list = readConfigAgentList(params.baseConfig); + const nextList = upsertConfigAgentEntry(list, params.agentId, (entry) => { + const next: ConfigAgentEntry = { ...entry, id: params.agentId }; + const currentTools = isRecord(next.tools) ? { ...next.tools } : {}; + const allow = normalizeToolList(params.overrides.allow); + if (allow !== undefined) { + currentTools.allow = allow; + delete currentTools.alsoAllow; + } + const alsoAllow = normalizeToolList(params.overrides.alsoAllow); + if (alsoAllow !== undefined) { + currentTools.alsoAllow = alsoAllow; + delete currentTools.allow; + } + const deny = normalizeToolList(params.overrides.deny); + if (deny !== undefined) { + currentTools.deny = deny; + } + next.tools = currentTools; + return next; + }); + const nextConfig = writeConfigAgentList(params.baseConfig, nextList); + const payload = buildConfigSetPayload({ + config: nextConfig, + hash: params.snapshotHash, + exists: params.snapshotExists, + }); + try { + await params.runtime.callGateway("config.set", payload); + } catch (err) { + if (attempt >= 1 || !isConfigConflict(err)) { + throw err; + } + const retrySnapshot = await params.runtime.callGateway("config.get", {}); + const retryConfig = isRecord(retrySnapshot.config) + ? (retrySnapshot.config as Record) + : {}; + await applyAgentToolsOverrides({ + ...params, + baseConfig: retryConfig, + snapshotHash: retrySnapshot.hash, + snapshotExists: retrySnapshot.exists, + attempt: attempt + 1, + }); + } +}; + +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 sessionKey = + typeof bodyOrError.sessionKey === "string" ? bodyOrError.sessionKey.trim() : ""; + const commandMode = + typeof bodyOrError.commandMode === "string" ? bodyOrError.commandMode.trim() : ""; + const webAccess = typeof bodyOrError.webAccess === "boolean" ? bodyOrError.webAccess : null; + const fileTools = typeof bodyOrError.fileTools === "boolean" ? bodyOrError.fileTools : null; + if (!agentId || !sessionKey) { + return NextResponse.json({ error: "agentId and sessionKey are required." }, { status: 400 }); + } + if (commandMode !== "off" && commandMode !== "ask" && commandMode !== "auto") { + return NextResponse.json({ error: "commandMode must be one of: off, ask, auto." }, { status: 400 }); + } + if (webAccess === null || fileTools === null) { + return NextResponse.json({ error: "webAccess and fileTools must be boolean values." }, { status: 400 }); + } + + const runtimeOrError = await ensureDomainIntentRuntime(); + if (runtimeOrError instanceof Response) { + return runtimeOrError as NextResponse; + } + + try { + const role = resolveRoleForCommandMode(commandMode as CommandModeId); + const snapshot = await runtimeOrError.callGateway("config.get", {}); + const baseConfig = isRecord(snapshot.config) ? (snapshot.config as Record) : {}; + const list = readConfigAgentList(baseConfig); + const configEntry = list.find((entry) => entry.id === agentId) ?? null; + const sandboxRaw = + configEntry && isRecord(configEntry.sandbox) ? (configEntry.sandbox as Record) : null; + const sandboxMode = typeof sandboxRaw?.mode === "string" ? sandboxRaw.mode : ""; + const toolsRaw = configEntry && isRecord(configEntry.tools) ? configEntry.tools : null; + + const toolOverrides = resolveToolGroupOverrides({ + existingTools: toolsRaw, + runtimeEnabled: role !== "conservative", + webEnabled: webAccess, + fsEnabled: fileTools, + }); + await applyAgentToolsOverrides({ + runtime: runtimeOrError, + agentId, + baseConfig, + snapshotHash: snapshot.hash, + snapshotExists: snapshot.exists, + overrides: toolOverrides.tools, + }); + + const execSettings = resolveSessionExecSettingsForRole({ role, sandboxMode }); + await runtimeOrError.callGateway("sessions.patch", { + key: sessionKey, + execHost: execSettings.execHost, + execSecurity: execSettings.execSecurity, + execAsk: execSettings.execAsk, + }); + await upsertAgentExecApprovalsPolicyViaRuntime({ + runtime: runtimeOrError, + agentId, + role, + }); + + return NextResponse.json({ ok: true }); + } catch (err) { + if (isGatewayUnavailable(err)) { + return NextResponse.json( + { error: "Gateway is unavailable.", code: "GATEWAY_UNAVAILABLE", reason: "gateway_unavailable" }, + { status: 503 } + ); + } + if (isConfigConflict(err)) { + const message = err instanceof Error ? err.message : "config conflict"; + return NextResponse.json( + { error: message, code: "INVALID_REQUEST", conflict: "base_hash_mismatch" }, + { status: 409 } + ); + } + const message = err instanceof Error ? err.message : "agent_permissions_update_failed"; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/src/app/api/intents/exec-approvals-set/route.ts b/src/app/api/intents/exec-approvals-set/route.ts deleted file mode 100644 index 31d8be5..0000000 --- a/src/app/api/intents/exec-approvals-set/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -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(["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 }); - } -} diff --git a/src/app/api/intents/session-settings-sync/route.ts b/src/app/api/intents/session-settings-sync/route.ts new file mode 100644 index 0000000..fe6afb4 --- /dev/null +++ b/src/app/api/intents/session-settings-sync/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; + +import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; + +export const runtime = "nodejs"; + +const hasOwn = (value: Record, key: string) => + Object.prototype.hasOwnProperty.call(value, key); + +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 }); + } + + const includeModel = hasOwn(bodyOrError, "model"); + const includeThinkingLevel = hasOwn(bodyOrError, "thinkingLevel"); + const includeExecHost = hasOwn(bodyOrError, "execHost"); + const includeExecSecurity = hasOwn(bodyOrError, "execSecurity"); + const includeExecAsk = hasOwn(bodyOrError, "execAsk"); + if ( + !includeModel && + !includeThinkingLevel && + !includeExecHost && + !includeExecSecurity && + !includeExecAsk + ) { + return NextResponse.json( + { error: "At least one session setting field is required." }, + { status: 400 } + ); + } + + return await executeGatewayIntent("sessions.patch", { + key: sessionKey, + ...(includeModel ? { model: bodyOrError.model ?? null } : {}), + ...(includeThinkingLevel ? { thinkingLevel: bodyOrError.thinkingLevel ?? null } : {}), + ...(includeExecHost ? { execHost: bodyOrError.execHost ?? null } : {}), + ...(includeExecSecurity ? { execSecurity: bodyOrError.execSecurity ?? null } : {}), + ...(includeExecAsk ? { execAsk: bodyOrError.execAsk ?? null } : {}), + }); +} diff --git a/src/app/api/runtime/agents/[agentId]/history/route.ts b/src/app/api/runtime/agents/[agentId]/history/route.ts index d3f628f..9e7df7d 100644 --- a/src/app/api/runtime/agents/[agentId]/history/route.ts +++ b/src/app/api/runtime/agents/[agentId]/history/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { deriveRuntimeFreshness, probeOpenClawLocalState } from "@/lib/controlplane/degraded-read"; +import { serializeRuntimeInitFailure } from "@/lib/controlplane/runtime-init-errors"; import { bootstrapDomainRuntime } from "@/lib/controlplane/runtime-route-bootstrap"; export const runtime = "nodejs"; @@ -45,9 +46,7 @@ export async function GET( return NextResponse.json( { enabled: true, - error: bootstrap.message, - code: "CONTROLPLANE_RUNTIME_INIT_FAILED", - reason: "runtime_init_failed", + ...serializeRuntimeInitFailure(bootstrap.failure), }, { status: 503 } ); diff --git a/src/app/api/runtime/fleet/route.ts b/src/app/api/runtime/fleet/route.ts index 739187d..01f9f32 100644 --- a/src/app/api/runtime/fleet/route.ts +++ b/src/app/api/runtime/fleet/route.ts @@ -3,7 +3,9 @@ import { NextResponse } from "next/server"; import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration"; import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models"; import { deriveRuntimeFreshness, probeOpenClawLocalState } from "@/lib/controlplane/degraded-read"; -import type { ControlPlaneOutboxEntry } from "@/lib/controlplane/contracts"; +import type { ControlPlaneOutboxEntry, ControlPlaneRuntimeSnapshot } from "@/lib/controlplane/contracts"; +import { serializeRuntimeInitFailure } from "@/lib/controlplane/runtime-init-errors"; +import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter"; import { bootstrapDomainRuntime } from "@/lib/controlplane/runtime-route-bootstrap"; import { loadStudioSettings } from "@/lib/studio/settings-store"; @@ -97,6 +99,54 @@ const deriveDegradedFleetResult = ( }; }; +const resolveGatewayErrorCode = (error: unknown): string => { + if (error instanceof ControlPlaneGatewayError) { + return error.code.trim().toUpperCase(); + } + if (isRecord(error) && typeof error.code === "string") { + return error.code.trim().toUpperCase(); + } + return ""; +}; + +const resolveErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const isMissingScopeGatewayError = (error: unknown): boolean => { + const code = resolveGatewayErrorCode(error); + if (code !== "INVALID_REQUEST") return false; + return resolveErrorMessage(error).toLowerCase().includes("missing scope"); +}; + +const isGatewayUnavailableError = (error: unknown): boolean => + resolveGatewayErrorCode(error) === "GATEWAY_UNAVAILABLE"; + +const buildDegradedFleetResponse = async (params: { + controlPlane: { + snapshot: () => ControlPlaneRuntimeSnapshot; + eventsAfter: (lastSeenId: number, limit?: number) => ControlPlaneOutboxEntry[]; + }; + cachedConfigSnapshot: GatewayModelPolicySnapshot | null; + error: string; + code: string; + reason: string; +}) => { + const snapshot = params.controlPlane.snapshot(); + const floorOutboxId = Math.max(0, snapshot.outboxHead - DEGRADED_FLEET_OUTBOX_SCAN_LIMIT); + const entries = params.controlPlane.eventsAfter(floorOutboxId, DEGRADED_FLEET_OUTBOX_SCAN_LIMIT); + const probe = await probeOpenClawLocalState(); + return NextResponse.json({ + enabled: true, + degraded: true, + error: params.error, + code: params.code, + reason: params.reason, + freshness: deriveRuntimeFreshness(snapshot, probe), + probe, + result: deriveDegradedFleetResult(entries, params.cachedConfigSnapshot), + }); +}; + export async function POST(request: Request) { const bootstrap = await bootstrapDomainRuntime(); if (bootstrap.kind === "mode-disabled") { @@ -118,9 +168,7 @@ export async function POST(request: Request) { return NextResponse.json( { enabled: true, - error: bootstrap.message, - code: "CONTROLPLANE_RUNTIME_INIT_FAILED", - reason: "runtime_init_failed", + ...serializeRuntimeInitFailure(bootstrap.failure), }, { status: 503 } ); @@ -128,20 +176,12 @@ export async function POST(request: Request) { const controlPlane = bootstrap.runtime; if (bootstrap.kind === "start-failed") { - const snapshot = controlPlane.snapshot(); - const floorOutboxId = Math.max(0, snapshot.outboxHead - DEGRADED_FLEET_OUTBOX_SCAN_LIMIT); - const entries = controlPlane.eventsAfter(floorOutboxId, DEGRADED_FLEET_OUTBOX_SCAN_LIMIT); - const probe = await probeOpenClawLocalState(); - - return NextResponse.json({ - enabled: true, - degraded: true, + return await buildDegradedFleetResponse({ + controlPlane, + cachedConfigSnapshot, error: bootstrap.message, code: "GATEWAY_UNAVAILABLE", reason: "gateway_unavailable", - freshness: deriveRuntimeFreshness(snapshot, probe), - probe, - result: deriveDegradedFleetResult(entries, cachedConfigSnapshot), }); } @@ -163,6 +203,24 @@ export async function POST(request: Request) { }); return NextResponse.json({ enabled: true, result }); } catch (err) { + if (isMissingScopeGatewayError(err)) { + return await buildDegradedFleetResponse({ + controlPlane, + cachedConfigSnapshot, + error: resolveErrorMessage(err), + code: "INSUFFICIENT_SCOPE", + reason: "insufficient_scope", + }); + } + if (isGatewayUnavailableError(err)) { + return await buildDegradedFleetResponse({ + controlPlane, + cachedConfigSnapshot, + error: resolveErrorMessage(err), + code: "GATEWAY_UNAVAILABLE", + reason: "gateway_unavailable", + }); + } const message = err instanceof Error ? err.message : "fleet_load_failed"; return NextResponse.json({ enabled: true, error: message }, { status: 500 }); } diff --git a/src/app/api/runtime/stream/route.ts b/src/app/api/runtime/stream/route.ts index c2cb7cd..22b1744 100644 --- a/src/app/api/runtime/stream/route.ts +++ b/src/app/api/runtime/stream/route.ts @@ -1,4 +1,5 @@ import type { ControlPlaneOutboxEntry } from "@/lib/controlplane/contracts"; +import { serializeRuntimeInitFailure } from "@/lib/controlplane/runtime-init-errors"; import { bootstrapDomainRuntime } from "@/lib/controlplane/runtime-route-bootstrap"; export const runtime = "nodejs"; @@ -37,9 +38,7 @@ export async function GET(request: Request) { return new Response( JSON.stringify({ enabled: true, - error: bootstrap.message, - code: "CONTROLPLANE_RUNTIME_INIT_FAILED", - reason: "runtime_init_failed", + ...serializeRuntimeInitFailure(bootstrap.failure), }), { status: 503, headers: { "content-type": "application/json; charset=utf-8" } } ); diff --git a/src/app/api/runtime/summary/route.ts b/src/app/api/runtime/summary/route.ts index ef91289..681d8a4 100644 --- a/src/app/api/runtime/summary/route.ts +++ b/src/app/api/runtime/summary/route.ts @@ -1,22 +1,27 @@ import { NextResponse } from "next/server"; import { deriveRuntimeFreshness, probeOpenClawLocalState } from "@/lib/controlplane/degraded-read"; -import { getControlPlaneRuntime, isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime"; +import { serializeRuntimeInitFailure } from "@/lib/controlplane/runtime-init-errors"; +import { bootstrapDomainRuntime } from "@/lib/controlplane/runtime-route-bootstrap"; export const runtime = "nodejs"; export async function GET() { - if (!isStudioDomainApiModeEnabled()) { + const bootstrap = await bootstrapDomainRuntime(); + if (bootstrap.kind === "mode-disabled") { 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"; + if (bootstrap.kind === "runtime-init-failed") { + return NextResponse.json( + { + enabled: true, + ...serializeRuntimeInitFailure(bootstrap.failure), + }, + { status: 503 } + ); } + const controlPlane = bootstrap.runtime; + const startError = bootstrap.kind === "start-failed" ? bootstrap.message : null; const snapshot = controlPlane.snapshot(); const probe = snapshot.status === "connected" ? null : await probeOpenClawLocalState(); diff --git a/src/features/agents/approvals/execApprovalControlLoopWorkflow.ts b/src/features/agents/approvals/execApprovalControlLoopWorkflow.ts index b8b77f9..9c2c303 100644 --- a/src/features/agents/approvals/execApprovalControlLoopWorkflow.ts +++ b/src/features/agents/approvals/execApprovalControlLoopWorkflow.ts @@ -30,11 +30,11 @@ export type ExecApprovalIngressCommand = | { kind: "recordCronDedupeKey"; dedupeKey: string } | { kind: "appendCronTranscript"; intent: CronTranscriptIntent }; -export type PauseRunIntent = +type PauseRunIntent = | { kind: "skip"; reason: string } | { kind: "pause"; agentId: string; sessionKey: string; runId: string }; -export type AutoResumeIntent = +type AutoResumeIntent = | { kind: "skip"; reason: string } | { kind: "resume"; targetAgentId: string; pausedRunId: string; sessionKey: string }; diff --git a/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts b/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts index 2899cb2..00c7e0c 100644 --- a/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts +++ b/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts @@ -15,7 +15,7 @@ export type ExecApprovalEventEffects = { markActivityAgentIds: string[]; }; -export type ExecApprovalFollowUpIntent = { +type ExecApprovalFollowUpIntent = { shouldSend: boolean; agentId: string | null; sessionKey: string | null; diff --git a/src/features/agents/approvals/execApprovalResolveOperation.ts b/src/features/agents/approvals/execApprovalResolveOperation.ts index 22bdf6c..7e550eb 100644 --- a/src/features/agents/approvals/execApprovalResolveOperation.ts +++ b/src/features/agents/approvals/execApprovalResolveOperation.ts @@ -5,17 +5,12 @@ 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; -}; +import type { RuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport"; type SetState = (next: T | ((current: T) => T)) => void; export const resolveExecApprovalViaStudio = async (params: { - client: GatewayClientLike; + runtimeWriteTransport: RuntimeWriteTransport; approvalId: string; decision: ExecApprovalDecision; getAgents: () => AgentState[]; @@ -35,11 +30,9 @@ export const resolveExecApprovalViaStudio = async (params: { isDisconnectLikeError: (error: unknown) => boolean; shouldTreatUnknownId?: (error: unknown) => boolean; logWarn?: (message: string, error: unknown) => void; - useDomainIntents?: boolean; }): Promise => { const id = params.approvalId.trim(); if (!id) return; - const useDomainIntents = params.useDomainIntents ?? isStudioDomainIntentModeEnabled(); const resolvePendingApproval = ( approvalId: string, @@ -118,11 +111,7 @@ export const resolveExecApprovalViaStudio = async (params: { setLocalApprovalState(true, null); try { - 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 }); - } + await params.runtimeWriteTransport.execApprovalResolve({ id, decision: params.decision }); removeLocalApproval(id); if (params.decision !== "allow-once" && params.decision !== "allow-always") { @@ -138,7 +127,7 @@ export const resolveExecApprovalViaStudio = async (params: { const activeRunId = latest?.runId?.trim() ?? ""; if (activeRunId) { try { - await params.client.call("agent.wait", { runId: activeRunId, timeoutMs: 15_000 }); + await params.runtimeWriteTransport.agentWait({ runId: activeRunId, timeoutMs: 15_000 }); } catch (waitError) { if (!params.isDisconnectLikeError(waitError)) { (params.logWarn ?? ((message, error) => console.warn(message, error)))( diff --git a/src/features/agents/approvals/execApprovalRunControlWorkflow.ts b/src/features/agents/approvals/execApprovalRunControlWorkflow.ts index 8e43887..0fed066 100644 --- a/src/features/agents/approvals/execApprovalRunControlWorkflow.ts +++ b/src/features/agents/approvals/execApprovalRunControlWorkflow.ts @@ -11,12 +11,12 @@ import type { AgentState } from "@/features/agents/state/store"; type GatewayEventFrame = Parameters[0]["event"]; -export type PauseRunControlPlan = { +type PauseRunControlPlan = { stalePausedAgentIds: string[]; pauseIntent: ReturnType; }; -export type AutoResumeRunControlPlan = { +type AutoResumeRunControlPlan = { preWaitIntent: ReturnType; postWaitIntent: ReturnType; }; diff --git a/src/features/agents/approvals/execApprovalRuntimeCoordinator.ts b/src/features/agents/approvals/execApprovalRuntimeCoordinator.ts index 3d44f1d..05e6ea5 100644 --- a/src/features/agents/approvals/execApprovalRuntimeCoordinator.ts +++ b/src/features/agents/approvals/execApprovalRuntimeCoordinator.ts @@ -17,12 +17,12 @@ export type ApprovalPendingState = { unscopedApprovals: PendingExecApproval[]; }; -export type ApprovalPauseRequest = { +type ApprovalPauseRequest = { approval: PendingExecApproval; preferredAgentId: string | null; }; -export type ApprovalIngressResult = { +type ApprovalIngressResult = { pendingState: ApprovalPendingState; pauseRequests: ApprovalPauseRequest[]; markActivityAgentIds: string[]; @@ -33,11 +33,11 @@ export type AwaitingUserInputPatch = { awaitingUserInput: boolean; }; -export type AutoResumePreflightIntent = +type AutoResumePreflightIntent = | { kind: "skip"; reason: "missing-paused-run" | "blocking-pending-approvals" } | { kind: "resume"; targetAgentId: string; pausedRunId: string }; -export type AutoResumeDispatchIntent = +type AutoResumeDispatchIntent = | { kind: "skip"; reason: "missing-paused-run" | "missing-agent" | "run-replaced" | "missing-session-key" } | { kind: "resume"; targetAgentId: string; pausedRunId: string; sessionKey: string }; diff --git a/src/features/agents/components/chatItems.ts b/src/features/agents/components/chatItems.ts index 6b5851a..178bdc0 100644 --- a/src/features/agents/components/chatItems.ts +++ b/src/features/agents/components/chatItems.ts @@ -25,7 +25,7 @@ export type AssistantTraceEvent = | { kind: "thinking"; text: string } | { kind: "tool"; text: string }; -export type AgentChatRenderBlock = +type AgentChatRenderBlock = | { kind: "user"; text: string; timestampMs?: number } | { kind: "assistant"; @@ -35,7 +35,7 @@ export type AgentChatRenderBlock = traceEvents: AssistantTraceEvent[]; }; -export type BuildAgentChatItemsInput = { +type BuildAgentChatItemsInput = { outputLines: string[]; streamText: string | null; liveThinkingTrace: string; diff --git a/src/features/agents/operations/agentFleetHydration.ts b/src/features/agents/operations/agentFleetHydration.ts index 142b087..0d8fb3e 100644 --- a/src/features/agents/operations/agentFleetHydration.ts +++ b/src/features/agents/operations/agentFleetHydration.ts @@ -53,7 +53,7 @@ type ExecApprovalsSnapshot = { }; }; -export type HydrateAgentFleetResult = { +type HydrateAgentFleetResult = { seeds: AgentStoreSeed[]; sessionCreatedAgentIds: string[]; sessionSettingsSyncedAgentIds: string[]; diff --git a/src/features/agents/operations/agentFleetHydrationDerivation.ts b/src/features/agents/operations/agentFleetHydrationDerivation.ts index 45fa2f2..694cee3 100644 --- a/src/features/agents/operations/agentFleetHydrationDerivation.ts +++ b/src/features/agents/operations/agentFleetHydrationDerivation.ts @@ -159,7 +159,7 @@ const resolveDefaultModelForAgent = ( return resolveConfiguredModelKey(raw, modelAliases); }; -export type DeriveFleetHydrationInput = { +type DeriveFleetHydrationInput = { gatewayUrl: string; configSnapshot: GatewayModelPolicySnapshot | null; settings: StudioSettings | null; @@ -170,7 +170,7 @@ export type DeriveFleetHydrationInput = { previewResult: SummaryPreviewSnapshot | null; }; -export type DerivedHydrateAgentFleetResult = { +type DerivedHydrateAgentFleetResult = { seeds: AgentStoreSeed[]; sessionCreatedAgentIds: string[]; sessionSettingsSyncedAgentIds: string[]; diff --git a/src/features/agents/operations/agentReconcileOperation.ts b/src/features/agents/operations/agentReconcileOperation.ts index eff7e6b..7376cc7 100644 --- a/src/features/agents/operations/agentReconcileOperation.ts +++ b/src/features/agents/operations/agentReconcileOperation.ts @@ -9,7 +9,7 @@ type GatewayClientLike = { call: (method: string, params: unknown) => Promise; }; -export type ReconcileCommand = +type ReconcileCommand = | { kind: "clearRunTracking"; runId: string } | { kind: "dispatchUpdateAgent"; agentId: string; patch: Partial } | { kind: "requestHistoryRefresh"; agentId: string } diff --git a/src/features/agents/operations/agentSettingsMutationWorkflow.ts b/src/features/agents/operations/agentSettingsMutationWorkflow.ts index c26f21f..d3aa6a8 100644 --- a/src/features/agents/operations/agentSettingsMutationWorkflow.ts +++ b/src/features/agents/operations/agentSettingsMutationWorkflow.ts @@ -19,7 +19,7 @@ type GuardedActionKind = | "save-skill-api-key"; type CronActionKind = "run-cron-job" | "delete-cron-job"; -export type AgentSettingsMutationRequest = +type AgentSettingsMutationRequest = | { kind: GuardedActionKind; agentId: string; skillName?: string; skillKey?: string } | { kind: "create-cron-job"; agentId: string } | { kind: CronActionKind; agentId: string; jobId: string }; @@ -34,7 +34,7 @@ export type AgentSettingsMutationContext = { cronDeleteBusyJobId: string | null; }; -export type AgentSettingsMutationDenyReason = +type AgentSettingsMutationDenyReason = | "start-guard-deny" | "reserved-main-delete" | "cron-action-busy" @@ -43,7 +43,7 @@ export type AgentSettingsMutationDenyReason = | "missing-skill-name" | "missing-skill-key"; -export type AgentSettingsMutationDecision = +type AgentSettingsMutationDecision = | { kind: "allow"; normalizedAgentId: string; diff --git a/src/features/agents/operations/chatInteractionWorkflow.ts b/src/features/agents/operations/chatInteractionWorkflow.ts index a647c13..1a400fd 100644 --- a/src/features/agents/operations/chatInteractionWorkflow.ts +++ b/src/features/agents/operations/chatInteractionWorkflow.ts @@ -1,6 +1,6 @@ import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; -export type StopRunIntent = +type StopRunIntent = | { kind: "deny"; reason: "not-connected" | "missing-session-key"; message: string } | { kind: "skip-busy" } | { kind: "allow"; sessionKey: string }; @@ -35,7 +35,7 @@ export const planStopRunIntent = (input: { }; }; -export type NewSessionIntent = +type NewSessionIntent = | { kind: "deny"; reason: "missing-agent" | "missing-session-key"; message: string } | { kind: "allow"; sessionKey: string }; @@ -64,7 +64,7 @@ export const planNewSessionIntent = (input: { }; }; -export type DraftFlushIntent = +type DraftFlushIntent = | { kind: "skip"; reason: "missing-agent-id" | "missing-pending-value" } | { kind: "flush"; agentId: string }; @@ -90,7 +90,7 @@ export const planDraftFlushIntent = (input: { }; }; -export type DraftTimerIntent = +type DraftTimerIntent = | { kind: "skip"; reason: "missing-agent-id" } | { kind: "schedule"; agentId: string; delayMs: number }; diff --git a/src/features/agents/operations/configMutationGatePolicy.ts b/src/features/agents/operations/configMutationGatePolicy.ts index 1f31e3c..1bff33e 100644 --- a/src/features/agents/operations/configMutationGatePolicy.ts +++ b/src/features/agents/operations/configMutationGatePolicy.ts @@ -1,6 +1,6 @@ import type { GatewayStatus } from "./gatewayRestartPolicy"; -export type ConfigMutationGateInput = { +type ConfigMutationGateInput = { status: GatewayStatus; hasRunningAgents: boolean; nextMutationRequiresIdleAgents: boolean; diff --git a/src/features/agents/operations/createAgentBootstrapOperation.ts b/src/features/agents/operations/createAgentBootstrapOperation.ts index e3f443d..17033ec 100644 --- a/src/features/agents/operations/createAgentBootstrapOperation.ts +++ b/src/features/agents/operations/createAgentBootstrapOperation.ts @@ -8,6 +8,7 @@ import { planCreateAgentBootstrapCommands, type CreateBootstrapCommand, } from "@/features/agents/operations/createAgentBootstrapWorkflow"; +import type { RuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport"; type CreateCompletion = { agentId: string; @@ -31,6 +32,7 @@ const resolveBootstrapErrorMessage = (error: unknown): string => { export async function applyCreateAgentBootstrapPermissions(params: { client: GatewayClient; + runtimeWriteTransport: RuntimeWriteTransport; agentId: string; sessionKey: string; draft: AgentPermissionsDraft; @@ -38,6 +40,7 @@ export async function applyCreateAgentBootstrapPermissions(params: { }): Promise { await updateAgentPermissionsViaStudio({ client: params.client, + runtimeWriteTransport: params.runtimeWriteTransport, agentId: params.agentId, sessionKey: params.sessionKey, draft: params.draft, diff --git a/src/features/agents/operations/createAgentBootstrapWorkflow.ts b/src/features/agents/operations/createAgentBootstrapWorkflow.ts index 8b5796f..d6dc7eb 100644 --- a/src/features/agents/operations/createAgentBootstrapWorkflow.ts +++ b/src/features/agents/operations/createAgentBootstrapWorkflow.ts @@ -1,4 +1,4 @@ -export type CreateBootstrapFacts = { +type CreateBootstrapFacts = { completion: { agentId: string; agentName: string }; createdAgent: { agentId: string; sessionKey: string } | null; bootstrapErrorMessage: string | null; diff --git a/src/features/agents/operations/cronCreateOperation.ts b/src/features/agents/operations/cronCreateOperation.ts index 3c2bc90..fcf4659 100644 --- a/src/features/agents/operations/cronCreateOperation.ts +++ b/src/features/agents/operations/cronCreateOperation.ts @@ -25,7 +25,7 @@ const resolveCreateAgentId = (agentId: string) => { const resolveCreateErrorMessage = (error: unknown) => error instanceof Error ? error.message : "Failed to create cron job."; -export type CronBusyState = { +type CronBusyState = { createBusy: boolean; runBusyJobId: string | null; deleteBusyJobId: string | null; diff --git a/src/features/agents/operations/fleetLifecycleWorkflow.ts b/src/features/agents/operations/fleetLifecycleWorkflow.ts index b95aaec..42497a3 100644 --- a/src/features/agents/operations/fleetLifecycleWorkflow.ts +++ b/src/features/agents/operations/fleetLifecycleWorkflow.ts @@ -1,8 +1,8 @@ import type { AgentState } from "@/features/agents/state/store"; -export type SummarySnapshotSeed = Pick; +type SummarySnapshotSeed = Pick; -export type SummarySnapshotIntent = +type SummarySnapshotIntent = | { kind: "skip" } | { kind: "fetch"; @@ -11,7 +11,7 @@ export type SummarySnapshotIntent = maxChars: number; }; -export type ReconcileEligibility = { +type ReconcileEligibility = { shouldCheck: boolean; reason: "ok" | "not-running" | "missing-run-id" | "not-session-created"; }; diff --git a/src/features/agents/operations/gatewayConfigSyncWorkflow.ts b/src/features/agents/operations/gatewayConfigSyncWorkflow.ts index 52b15fd..6718ea2 100644 --- a/src/features/agents/operations/gatewayConfigSyncWorkflow.ts +++ b/src/features/agents/operations/gatewayConfigSyncWorkflow.ts @@ -40,7 +40,7 @@ export const resolveSandboxRepairAgentIds = ( .map((entry) => entry.id); }; -export type SandboxRepairIntent = +type SandboxRepairIntent = | { kind: "skip"; reason: "not-connected" | "already-attempted" | "no-eligible-agents" } | { kind: "repair"; agentIds: string[] }; @@ -75,7 +75,7 @@ export const shouldRefreshGatewayConfigForSettingsRoute = (params: { return true; }; -export type GatewayModelsSyncIntent = { kind: "clear" } | { kind: "load" }; +type GatewayModelsSyncIntent = { kind: "clear" } | { kind: "load" }; export const resolveGatewayModelsSyncIntent = (params: { status: GatewayConnectionStatus; diff --git a/src/features/agents/operations/gatewayRestartPolicy.ts b/src/features/agents/operations/gatewayRestartPolicy.ts index 6de22d5..5dc80d4 100644 --- a/src/features/agents/operations/gatewayRestartPolicy.ts +++ b/src/features/agents/operations/gatewayRestartPolicy.ts @@ -1,6 +1,6 @@ export type GatewayStatus = "disconnected" | "connecting" | "connected"; -export type RestartObservation = { +type RestartObservation = { sawDisconnect: boolean; }; diff --git a/src/features/agents/operations/historyLifecycleWorkflow.ts b/src/features/agents/operations/historyLifecycleWorkflow.ts index 28f0eaa..c43adc1 100644 --- a/src/features/agents/operations/historyLifecycleWorkflow.ts +++ b/src/features/agents/operations/historyLifecycleWorkflow.ts @@ -1,6 +1,6 @@ import type { AgentState } from "@/features/agents/state/store"; -export type HistoryRequestIntent = +type HistoryRequestIntent = | { kind: "skip"; reason: "missing-agent" | "session-not-created" | "missing-session-key" | "in-flight"; @@ -15,7 +15,7 @@ export type HistoryRequestIntent = loadedAt: number; }; -export type HistoryResponseDisposition = +type HistoryResponseDisposition = | { kind: "drop"; reason: diff --git a/src/features/agents/operations/latestUpdateWorkflow.ts b/src/features/agents/operations/latestUpdateWorkflow.ts index 2ac8846..e23a15d 100644 --- a/src/features/agents/operations/latestUpdateWorkflow.ts +++ b/src/features/agents/operations/latestUpdateWorkflow.ts @@ -1,8 +1,8 @@ import { parseAgentIdFromSessionKey } from "@/lib/gateway/GatewayClient"; -export type LatestUpdateKind = "heartbeat" | "cron" | null; +type LatestUpdateKind = "heartbeat" | "cron" | null; -export type LatestUpdateIntent = +type LatestUpdateIntent = | { kind: "reset" } | { kind: "fetch-heartbeat"; diff --git a/src/features/agents/operations/mutationLifecycleWorkflow.ts b/src/features/agents/operations/mutationLifecycleWorkflow.ts index 51b0968..dabe271 100644 --- a/src/features/agents/operations/mutationLifecycleWorkflow.ts +++ b/src/features/agents/operations/mutationLifecycleWorkflow.ts @@ -66,7 +66,7 @@ export const buildMutatingMutationBlock = (block: MutationBlockState): MutationB }; }; -export type MutationPostRunIntent = +type MutationPostRunIntent = | { kind: "clear" } | { kind: "awaiting-restart"; patch: { phase: "awaiting-restart"; sawDisconnect: boolean } }; @@ -85,7 +85,7 @@ export const resolveMutationPostRunIntent = (params: { return { kind: "clear" }; }; -export type MutationSideEffectCommand = +type MutationSideEffectCommand = | { kind: "reload-agents" } | { kind: "clear-mutation-block" } | { kind: "set-mobile-pane"; pane: "chat" } @@ -107,7 +107,7 @@ export const buildMutationSideEffectCommands = (params: { return [{ kind: "patch-mutation-block", patch: postRunIntent.patch }]; }; -export type MutationTimeoutIntent = +type MutationTimeoutIntent = | { kind: "none" } | { kind: "timeout"; reason: "create-timeout" | "rename-timeout" | "delete-timeout" }; @@ -143,29 +143,29 @@ export const resolveMutationTimeoutIntent = (params: { export type MutationWorkflowKind = "rename-agent" | "delete-agent"; -export type MutationWorkflowResult = { +type MutationWorkflowResult = { disposition: "completed" | "awaiting-restart"; }; -export type AwaitingRestartPatch = { +type AwaitingRestartPatch = { phase: "awaiting-restart"; sawDisconnect: boolean; }; -export type MutationWorkflowPostRunEffects = { +type MutationWorkflowPostRunEffects = { shouldReloadAgents: boolean; shouldClearBlock: boolean; awaitingRestartPatch: AwaitingRestartPatch | null; }; -export type MutationWorkflowDeps = { +type MutationWorkflowDeps = { executeMutation: () => Promise; shouldAwaitRemoteRestart: () => Promise; }; -export type AgentConfigMutationLifecycleKind = MutationWorkflowKind; +type AgentConfigMutationLifecycleKind = MutationWorkflowKind; -export type AgentConfigMutationLifecycleDeps = { +type AgentConfigMutationLifecycleDeps = { enqueueConfigMutation: (params: { kind: ConfigMutationKind; label: string; @@ -212,7 +212,7 @@ export type CreateAgentMutationLifecycleDeps = { now?: () => number; }; -export type MutationStatusBlock = { +type MutationStatusBlock = { phase: "queued" | "mutating" | "awaiting-restart"; sawDisconnect: boolean; }; diff --git a/src/features/agents/operations/runtimeSyncControlWorkflow.ts b/src/features/agents/operations/runtimeSyncControlWorkflow.ts index 6dad7f6..2ce89ab 100644 --- a/src/features/agents/operations/runtimeSyncControlWorkflow.ts +++ b/src/features/agents/operations/runtimeSyncControlWorkflow.ts @@ -1,6 +1,6 @@ import type { AgentState } from "@/features/agents/state/store"; -export type RuntimeSyncStatus = "disconnected" | "connecting" | "connected"; +type RuntimeSyncStatus = "disconnected" | "connecting" | "connected"; export const RUNTIME_SYNC_RECONCILE_INTERVAL_MS = 3000; export const RUNTIME_SYNC_FOCUSED_HISTORY_INTERVAL_MS = 4500; @@ -16,11 +16,11 @@ type RuntimeSyncHistoryBootstrapAgent = Pick< type RuntimeSyncFocusedPollingAgent = Pick; -export type RuntimeSyncReconcilePollingIntent = +type RuntimeSyncReconcilePollingIntent = | { kind: "start"; intervalMs: number; runImmediately: true } | { kind: "stop"; reason: "not-connected" }; -export type RuntimeSyncFocusedHistoryPollingIntent = +type RuntimeSyncFocusedHistoryPollingIntent = | { kind: "start"; agentId: string; intervalMs: number; runImmediately: true } | { kind: "stop"; diff --git a/src/features/agents/operations/runtimeWriteTransport.ts b/src/features/agents/operations/runtimeWriteTransport.ts new file mode 100644 index 0000000..80b8f7f --- /dev/null +++ b/src/features/agents/operations/runtimeWriteTransport.ts @@ -0,0 +1,273 @@ +import { postStudioIntent } from "@/lib/controlplane/intents-client"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import { syncGatewaySessionSettings } from "@/lib/gateway/GatewayClient"; +import { createGatewayAgent, deleteGatewayAgent, renameGatewayAgent } from "@/lib/gateway/agentConfig"; +import { + readGatewayAgentExecApprovals, + upsertGatewayAgentExecApprovals, +} from "@/lib/gateway/execApprovals"; + +type RuntimeWriteExecutionRole = "conservative" | "collaborative" | "autonomous"; + +export type RuntimeWriteTransport = { + useDomainIntents?: boolean; + chatSend: (params: { + sessionKey: string; + message: string; + deliver: boolean; + idempotencyKey: string; + }) => Promise; + sessionSettingsSync: (params: { + sessionKey: string; + model?: string | null; + thinkingLevel?: string | null; + execHost?: "sandbox" | "gateway" | "node" | null; + execSecurity?: "deny" | "allowlist" | "full" | null; + execAsk?: "off" | "on-miss" | "always" | null; + }) => Promise; + agentCreate: (params: { name: string }) => Promise<{ id: string; name: string }>; + chatAbort: (params: { sessionKey: string }) => Promise; + sessionsReset: (params: { key: string }) => Promise; + agentRename: (params: { agentId: string; name: string }) => Promise; + agentDelete: (params: { agentId: string }) => Promise; + execApprovalResolve: (params: { id: string; decision: string }) => Promise; + execApprovalsSet: (params: { agentId: string; role: RuntimeWriteExecutionRole }) => Promise; + agentPermissionsUpdate: (params: { + agentId: string; + sessionKey: string; + commandMode: "off" | "ask" | "auto"; + webAccess: boolean; + fileTools: boolean; + }) => Promise; + agentWait: (params: { runId: string; timeoutMs?: number }) => Promise; +}; + +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +const requireNonEmpty = (value: string, fieldLabel: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${fieldLabel} is required.`); + } + return trimmed; +}; + +const unwrapIntentPayload = (result: unknown): T => { + if (isRecord(result) && "payload" in result) { + return result.payload as T; + } + return result as T; +}; + +const resolveExecApprovalsPolicyForRole = (params: { + role: RuntimeWriteExecutionRole; + 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 }; +}; + +export function createRuntimeWriteTransport(params: { + client: GatewayClient; + useDomainIntents: boolean; + postIntent?: (path: string, body: Record) => Promise; +}): RuntimeWriteTransport { + const postIntent = params.postIntent ?? postStudioIntent; + + return { + useDomainIntents: params.useDomainIntents, + chatSend: async (input) => { + const normalizedSessionKey = requireNonEmpty(input.sessionKey, "Session key"); + const normalizedIdempotencyKey = requireNonEmpty(input.idempotencyKey, "Idempotency key"); + const payload = { + ...input, + sessionKey: normalizedSessionKey, + idempotencyKey: normalizedIdempotencyKey, + }; + if (params.useDomainIntents) { + const result = await postIntent("/api/intents/chat-send", payload); + return unwrapIntentPayload(result); + } + return await params.client.call("chat.send", payload); + }, + sessionSettingsSync: async ({ + sessionKey, + model, + thinkingLevel, + execHost, + execSecurity, + execAsk, + }) => { + const normalizedSessionKey = requireNonEmpty(sessionKey, "Session key"); + const includeModel = model !== undefined; + const includeThinkingLevel = thinkingLevel !== undefined; + const includeExecHost = execHost !== undefined; + const includeExecSecurity = execSecurity !== undefined; + const includeExecAsk = execAsk !== undefined; + if ( + !includeModel && + !includeThinkingLevel && + !includeExecHost && + !includeExecSecurity && + !includeExecAsk + ) { + throw new Error("At least one session setting must be provided."); + } + if (params.useDomainIntents) { + const result = await postIntent("/api/intents/session-settings-sync", { + sessionKey: normalizedSessionKey, + ...(includeModel ? { model } : {}), + ...(includeThinkingLevel ? { thinkingLevel } : {}), + ...(includeExecHost ? { execHost } : {}), + ...(includeExecSecurity ? { execSecurity } : {}), + ...(includeExecAsk ? { execAsk } : {}), + }); + return unwrapIntentPayload(result); + } + return await syncGatewaySessionSettings({ + client: params.client, + sessionKey: normalizedSessionKey, + ...(includeModel ? { model } : {}), + ...(includeThinkingLevel ? { thinkingLevel } : {}), + ...(includeExecHost ? { execHost } : {}), + ...(includeExecSecurity ? { execSecurity } : {}), + ...(includeExecAsk ? { execAsk } : {}), + }); + }, + agentCreate: async ({ name }) => { + const normalizedName = requireNonEmpty(name, "Agent name"); + if (params.useDomainIntents) { + const payload = unwrapIntentPayload<{ agentId?: unknown; name?: unknown }>( + await postIntent("/api/intents/agent-create", { name: normalizedName }) + ); + const agentId = typeof payload?.agentId === "string" ? payload.agentId.trim() : ""; + if (!agentId) { + throw new Error("Agent create response missing agentId."); + } + const resolvedName = + typeof payload?.name === "string" && payload.name.trim() + ? payload.name.trim() + : normalizedName; + return { id: agentId, name: resolvedName }; + } + const created = await createGatewayAgent({ + client: params.client, + name: normalizedName, + }); + const createdName = + typeof created.name === "string" && created.name.trim() + ? created.name.trim() + : normalizedName; + return { id: created.id, name: createdName }; + }, + chatAbort: async ({ sessionKey }) => { + const normalizedSessionKey = requireNonEmpty(sessionKey, "Session key"); + if (params.useDomainIntents) { + await postIntent("/api/intents/chat-abort", { sessionKey: normalizedSessionKey }); + return; + } + await params.client.call("chat.abort", { sessionKey: normalizedSessionKey }); + }, + sessionsReset: async ({ key }) => { + const normalizedSessionKey = requireNonEmpty(key, "Session key"); + if (params.useDomainIntents) { + await postIntent("/api/intents/sessions-reset", { key: normalizedSessionKey }); + return; + } + await params.client.call("sessions.reset", { key: normalizedSessionKey }); + }, + agentRename: async ({ agentId, name }) => { + const normalizedAgentId = requireNonEmpty(agentId, "Agent id"); + const normalizedName = requireNonEmpty(name, "Agent name"); + if (params.useDomainIntents) { + await postIntent("/api/intents/agent-rename", { + agentId: normalizedAgentId, + name: normalizedName, + }); + return; + } + await renameGatewayAgent({ + client: params.client, + agentId: normalizedAgentId, + name: normalizedName, + }); + }, + agentDelete: async ({ agentId }) => { + const normalizedAgentId = requireNonEmpty(agentId, "Agent id"); + if (params.useDomainIntents) { + await postIntent("/api/intents/agent-delete", { agentId: normalizedAgentId }); + return; + } + await deleteGatewayAgent({ client: params.client, agentId: normalizedAgentId }); + }, + execApprovalResolve: async ({ id, decision }) => { + const normalizedId = requireNonEmpty(id, "Approval id"); + if (params.useDomainIntents) { + await postIntent("/api/intents/exec-approval-resolve", { id: normalizedId, decision }); + return; + } + await params.client.call("exec.approval.resolve", { id: normalizedId, decision }); + }, + execApprovalsSet: async ({ agentId, role }) => { + const normalizedAgentId = requireNonEmpty(agentId, "Agent id"); + + if (params.useDomainIntents) { + throw new Error( + "execApprovalsSet is not supported in domain intent mode; use agentPermissionsUpdate." + ); + } + + const existingPolicy = await readGatewayAgentExecApprovals({ + client: params.client, + agentId: normalizedAgentId, + }); + const allowlist = existingPolicy?.allowlist ?? []; + const nextPolicy = resolveExecApprovalsPolicyForRole({ role, allowlist }); + + await upsertGatewayAgentExecApprovals({ + client: params.client, + agentId: normalizedAgentId, + policy: nextPolicy, + }); + }, + agentPermissionsUpdate: async ({ agentId, sessionKey, commandMode, webAccess, fileTools }) => { + const normalizedAgentId = requireNonEmpty(agentId, "Agent id"); + const normalizedSessionKey = requireNonEmpty(sessionKey, "Session key"); + if (params.useDomainIntents) { + await postIntent("/api/intents/agent-permissions-update", { + agentId: normalizedAgentId, + sessionKey: normalizedSessionKey, + commandMode, + webAccess, + fileTools, + }); + return; + } + throw new Error("agentPermissionsUpdate is only available in domain intent mode."); + }, + agentWait: async ({ runId, timeoutMs }) => { + const normalizedRunId = requireNonEmpty(runId, "Run id"); + if (params.useDomainIntents) { + await postIntent("/api/intents/agent-wait", { + runId: normalizedRunId, + ...(typeof timeoutMs === "number" ? { timeoutMs } : {}), + }); + return; + } + await params.client.call("agent.wait", { + runId: normalizedRunId, + ...(typeof timeoutMs === "number" ? { timeoutMs } : {}), + }); + }, + }; +} diff --git a/src/features/agents/operations/specialLatestUpdateOperation.ts b/src/features/agents/operations/specialLatestUpdateOperation.ts index b1206bc..051ea21 100644 --- a/src/features/agents/operations/specialLatestUpdateOperation.ts +++ b/src/features/agents/operations/specialLatestUpdateOperation.ts @@ -42,7 +42,7 @@ const findLatestHeartbeatResponse = (messages: ChatHistoryMessage[]) => { return latestResponse; }; -export type SpecialLatestUpdateDeps = { +type SpecialLatestUpdateDeps = { callGateway: (method: string, params: unknown) => Promise; listCronJobs: () => Promise<{ jobs: CronJobSummary[] }>; resolveCronJobForAgent: (jobs: CronJobSummary[], agentId: string) => CronJobSummary | null; @@ -55,7 +55,7 @@ export type SpecialLatestUpdateDeps = { logError: (message: string) => void; }; -export type SpecialLatestUpdateOperation = { +type SpecialLatestUpdateOperation = { update: (agentId: string, agent: AgentState, message: string) => Promise; refreshHeartbeat: (agents: AgentState[]) => void; clearInFlight: (agentId: string) => void; diff --git a/src/features/agents/operations/studioBootstrapOperation.ts b/src/features/agents/operations/studioBootstrapOperation.ts index 3d10496..a3944a3 100644 --- a/src/features/agents/operations/studioBootstrapOperation.ts +++ b/src/features/agents/operations/studioBootstrapOperation.ts @@ -6,7 +6,6 @@ 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"; @@ -30,10 +29,11 @@ export async function runStudioBootstrapLoadOperation(params: { isDisconnectLikeError: (err: unknown) => boolean; preferredSelectedAgentId: string | null; hasCurrentSelection: boolean; + useDomainApiMode: boolean; logError?: (message: string, error: unknown) => void; }): Promise { try { - const result = isStudioDomainIntentModeEnabled() + const result = params.useDomainApiMode ? ( await fetchJson<{ result: Awaited> }>( "/api/runtime/fleet", @@ -130,7 +130,7 @@ export function executeStudioBootstrapLoadCommands(params: { } } -export type StudioFocusedPreferenceLoadCommand = +type StudioFocusedPreferenceLoadCommand = | { kind: "set-focused-preferences-loaded"; value: boolean } | { kind: "set-preferred-selected-agent-id"; agentId: string | null } | { kind: "set-focus-filter"; filter: FocusFilter } @@ -208,7 +208,7 @@ export function executeStudioFocusedPreferenceLoadCommands(params: { } } -export type StudioFocusedPatchCommand = { +type StudioFocusedPatchCommand = { kind: "schedule-settings-patch"; patch: StudioSettingsPatch; debounceMs: number; diff --git a/src/features/agents/operations/studioBootstrapWorkflow.ts b/src/features/agents/operations/studioBootstrapWorkflow.ts index 7d83df9..32d9b64 100644 --- a/src/features/agents/operations/studioBootstrapWorkflow.ts +++ b/src/features/agents/operations/studioBootstrapWorkflow.ts @@ -7,7 +7,7 @@ import { const FOCUSED_PATCH_DEBOUNCE_MS = 300; -export type BootstrapSelectionIntent = { +type BootstrapSelectionIntent = { initialSelectedAgentId: string | undefined; }; @@ -36,7 +36,7 @@ export function planBootstrapSelection(params: { }; } -export type FocusFilterPatchIntent = +type FocusFilterPatchIntent = | { kind: "skip"; reason: "missing-gateway-key" | "focus-filter-not-touched"; @@ -74,7 +74,7 @@ export function planFocusedFilterPatch(params: { }; } -export type FocusedSelectionPatchIntent = +type FocusedSelectionPatchIntent = | { kind: "skip"; reason: @@ -124,7 +124,7 @@ export function planFocusedSelectionPatch(params: { }; } -export type FocusedPreferenceRestoreIntent = { +type FocusedPreferenceRestoreIntent = { preferredSelectedAgentId: string | null; focusFilter: FocusFilter; }; diff --git a/src/features/agents/operations/useAgentSettingsMutationController.ts b/src/features/agents/operations/useAgentSettingsMutationController.ts index 6b54743..79c5d0a 100644 --- a/src/features/agents/operations/useAgentSettingsMutationController.ts +++ b/src/features/agents/operations/useAgentSettingsMutationController.ts @@ -17,6 +17,7 @@ import { import type { SettingsRouteTab } from "@/features/agents/operations/settingsRouteWorkflow"; import type { ConfigMutationKind } from "@/features/agents/operations/useConfigMutationQueue"; import { useGatewayRestartBlock } from "@/features/agents/operations/useGatewayRestartBlock"; +import type { RuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport"; import type { AgentState } from "@/features/agents/state/store"; import type { CronCreateDraft } from "@/lib/cron/createPayloadBuilder"; import { @@ -32,12 +33,9 @@ import { isGatewayDisconnectLikeError } from "@/lib/gateway/GatewayClient"; import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models"; import { readGatewayAgentSkillsAllowlist, - renameGatewayAgent, 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 { @@ -48,14 +46,15 @@ import { type SkillStatusReport, } from "@/lib/skills/types"; -export type RestartingMutationBlockState = MutationBlockState & { kind: MutationWorkflowKind }; -export type SkillSetupMessage = { kind: "success" | "error"; message: string }; -export type SkillSetupMessageMap = Record; +type RestartingMutationBlockState = MutationBlockState & { kind: MutationWorkflowKind }; +type SkillSetupMessage = { kind: "success" | "error"; message: string }; +type SkillSetupMessageMap = Record; type AgentForSettingsMutation = Pick; -export type UseAgentSettingsMutationControllerParams = { +type UseAgentSettingsMutationControllerParams = { client: GatewayClient; + runtimeWriteTransport: RuntimeWriteTransport; status: GatewayStatus; isLocalGateway: boolean; agents: AgentForSettingsMutation[]; @@ -77,10 +76,10 @@ export type UseAgentSettingsMutationControllerParams = { dispatchUpdateAgent: (agentId: string, patch: Partial) => void; setMobilePaneChat: () => void; setError: (message: string) => void; + useDomainIntents: boolean; }; export function useAgentSettingsMutationController(params: UseAgentSettingsMutationControllerParams) { - const useDomainIntents = isStudioDomainIntentModeEnabled(); const skillsLoadRequestIdRef = useRef(0); const [settingsSkillsReport, setSettingsSkillsReport] = useState(null); const [settingsSkillsLoading, setSettingsSkillsLoading] = useState(false); @@ -110,7 +109,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat const mutationContext: AgentSettingsMutationContext = useMemo( () => ({ - status: useDomainIntents ? "connected" : params.status, + status: params.useDomainIntents ? "connected" : params.status, hasCreateBlock: params.hasCreateBlock, hasRenameBlock: hasRenameMutationBlock, hasDeleteBlock: hasDeleteMutationBlock, @@ -126,7 +125,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat hasRenameMutationBlock, params.hasCreateBlock, params.status, - useDomainIntents, + params.useDomainIntents, ] ); @@ -384,25 +383,30 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat }, }); + const connectedStatus = params.status; + const settingsAgents = params.agents; + const loadAgents = params.loadAgents; + const setMobilePaneChat = params.setMobilePaneChat; + useEffect(() => { if (!restartingMutationBlock) return; if (restartingMutationBlock.kind !== "delete-agent") return; if (restartingMutationBlock.phase !== "awaiting-restart") return; - if (params.status !== "connected") return; + if (connectedStatus !== "connected") return; - const deletedAgentStillPresent = params.agents.some( + const deletedAgentStillPresent = settingsAgents.some( (entry) => entry.agentId === restartingMutationBlock.agentId ); if (!deletedAgentStillPresent) { setRestartingMutationBlock(null); - params.setMobilePaneChat(); + setMobilePaneChat(); return; } let cancelled = false; const refreshAgents = async () => { try { - await params.loadAgents(); + await loadAgents(); } catch (error) { if (!isGatewayDisconnectLikeError(error)) { console.error("Failed to refresh agents while awaiting delete restart.", error); @@ -421,11 +425,11 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat window.clearInterval(intervalId); }; }, [ - params.agents, - params.loadAgents, - params.setMobilePaneChat, - params.status, + connectedStatus, + loadAgents, restartingMutationBlock, + setMobilePaneChat, + settingsAgents, ]); const handleDeleteAgent = useCallback( @@ -456,16 +460,16 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat executeMutation: async () => { await deleteAgentViaStudio({ client: params.client, + runtimeWriteTransport: params.runtimeWriteTransport, agentId: decision.normalizedAgentId, fetchJson, logError: (message, error) => console.error(message, error), - useDomainIntents, }); params.clearInspectSidebar(); }, }); }, - [mutationContext, params, runRestartingMutationLifecycle, useDomainIntents] + [mutationContext, params, runRestartingMutationLifecycle] ); const handleCreateCronJob = useCallback( @@ -592,23 +596,15 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat agentName: name, label: `Rename ${agent.name}`, executeMutation: async () => { - if (useDomainIntents) { - await postStudioIntent("/api/intents/agent-rename", { - agentId: decision.normalizedAgentId, - name, - }); - } else { - await renameGatewayAgent({ - client: params.client, - agentId: decision.normalizedAgentId, - name, - }); - } + await params.runtimeWriteTransport.agentRename({ + agentId: decision.normalizedAgentId, + name, + }); params.dispatchUpdateAgent(decision.normalizedAgentId, { name }); }, }); }, - [mutationContext, params, runRestartingMutationLifecycle, useDomainIntents] + [mutationContext, params, runRestartingMutationLifecycle] ); const handleUpdateAgentPermissions = useCallback( @@ -633,11 +629,11 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat run: async () => { await updateAgentPermissionsViaStudio({ client: params.client, + runtimeWriteTransport: params.runtimeWriteTransport, agentId: decision.normalizedAgentId, sessionKey: agent.sessionKey, draft, loadAgents: async () => {}, - useDomainIntents, }); await params.loadAgents(); await params.refreshGatewayConfigSnapshot(); diff --git a/src/features/agents/operations/useConfigMutationQueue.ts b/src/features/agents/operations/useConfigMutationQueue.ts index fe77986..3959422 100644 --- a/src/features/agents/operations/useConfigMutationQueue.ts +++ b/src/features/agents/operations/useConfigMutationQueue.ts @@ -24,7 +24,7 @@ type QueuedConfigMutation = { reject: (error: unknown) => void; }; -export type ActiveConfigMutation = { +type ActiveConfigMutation = { kind: ConfigMutationKind; label: string; }; diff --git a/src/features/agents/operations/useGatewayConfigSyncController.ts b/src/features/agents/operations/useGatewayConfigSyncController.ts index 4c6c5d1..e258d3b 100644 --- a/src/features/agents/operations/useGatewayConfigSyncController.ts +++ b/src/features/agents/operations/useGatewayConfigSyncController.ts @@ -18,7 +18,7 @@ const defaultLogError = (message: string, err: unknown) => { console.error(message, err); }; -export type UseGatewayConfigSyncControllerParams = { +type UseGatewayConfigSyncControllerParams = { client: GatewayClient; status: GatewayConnectionStatus; settingsRouteActive: boolean; @@ -38,53 +38,60 @@ export type UseGatewayConfigSyncControllerParams = { logError?: (message: string, err: unknown) => void; }; -export type GatewayConfigSyncController = { +type GatewayConfigSyncController = { refreshGatewayConfigSnapshot: () => Promise; }; export function useGatewayConfigSyncController( params: UseGatewayConfigSyncControllerParams ): GatewayConfigSyncController { + const { + client, + status, + settingsRouteActive, + inspectSidebarAgentId, + gatewayConfigSnapshot, + setGatewayConfigSnapshot, + setGatewayModels, + setGatewayModelsError, + enqueueConfigMutation, + loadAgents, + isDisconnectLikeError, + } = params; const sandboxRepairAttemptedRef = useRef(false); const logError = params.logError ?? defaultLogError; const refreshGatewayConfigSnapshot = useCallback(async () => { - if (params.status !== "connected") return null; + if (status !== "connected") return null; try { - const snapshot = await params.client.call("config.get", {}); - params.setGatewayConfigSnapshot(snapshot); + const snapshot = await client.call("config.get", {}); + setGatewayConfigSnapshot(snapshot); return snapshot; } catch (err) { - if (!params.isDisconnectLikeError(err)) { + if (!isDisconnectLikeError(err)) { logError("Failed to refresh gateway config.", err); } return null; } - }, [ - params.client, - params.isDisconnectLikeError, - params.setGatewayConfigSnapshot, - params.status, - logError, - ]); + }, [client, isDisconnectLikeError, logError, setGatewayConfigSnapshot, status]); useEffect(() => { const repairIntent = resolveSandboxRepairIntent({ - status: params.status, + status, attempted: sandboxRepairAttemptedRef.current, - snapshot: params.gatewayConfigSnapshot, + snapshot: gatewayConfigSnapshot, }); if (repairIntent.kind !== "repair") return; sandboxRepairAttemptedRef.current = true; - void params.enqueueConfigMutation({ + void enqueueConfigMutation({ kind: "repair-sandbox-tool-allowlist", label: "Repair sandbox tool access", run: async () => { for (const agentId of repairIntent.agentIds) { await updateGatewayAgentOverrides({ - client: params.client, + client, agentId, overrides: { tools: { @@ -97,41 +104,30 @@ export function useGatewayConfigSyncController( }, }); } - await params.loadAgents(); + await loadAgents(); }, }); - }, [ - params.client, - params.enqueueConfigMutation, - params.gatewayConfigSnapshot, - params.loadAgents, - params.status, - ]); + }, [client, enqueueConfigMutation, gatewayConfigSnapshot, loadAgents, status]); useEffect(() => { if ( !shouldRefreshGatewayConfigForSettingsRoute({ - status: params.status, - settingsRouteActive: params.settingsRouteActive, - inspectSidebarAgentId: params.inspectSidebarAgentId, + status, + settingsRouteActive, + inspectSidebarAgentId, }) ) { return; } void refreshGatewayConfigSnapshot(); - }, [ - params.inspectSidebarAgentId, - params.settingsRouteActive, - params.status, - refreshGatewayConfigSnapshot, - ]); + }, [inspectSidebarAgentId, refreshGatewayConfigSnapshot, settingsRouteActive, status]); useEffect(() => { - const syncIntent = resolveGatewayModelsSyncIntent({ status: params.status }); + const syncIntent = resolveGatewayModelsSyncIntent({ status }); if (syncIntent.kind === "clear") { - params.setGatewayModels([]); - params.setGatewayModelsError(null); - params.setGatewayConfigSnapshot(null); + setGatewayModels([]); + setGatewayModelsError(null); + setGatewayConfigSnapshot(null); return; } @@ -139,31 +135,31 @@ export function useGatewayConfigSyncController( const loadModels = async () => { let configSnapshot: GatewayModelPolicySnapshot | null = null; try { - configSnapshot = await params.client.call("config.get", {}); + configSnapshot = await client.call("config.get", {}); if (!cancelled) { - params.setGatewayConfigSnapshot(configSnapshot); + setGatewayConfigSnapshot(configSnapshot); } } catch (err) { - if (!params.isDisconnectLikeError(err)) { + if (!isDisconnectLikeError(err)) { logError("Failed to load gateway config.", err); } } try { - const result = await params.client.call<{ models: GatewayModelChoice[] }>( + const result = await client.call<{ models: GatewayModelChoice[] }>( "models.list", {} ); if (cancelled) return; const catalog = Array.isArray(result.models) ? result.models : []; - params.setGatewayModels(buildGatewayModelChoices(catalog, configSnapshot)); - params.setGatewayModelsError(null); + setGatewayModels(buildGatewayModelChoices(catalog, configSnapshot)); + setGatewayModelsError(null); } catch (err) { if (cancelled) return; const message = err instanceof Error ? err.message : "Failed to load models."; - params.setGatewayModelsError(message); - params.setGatewayModels([]); - if (!params.isDisconnectLikeError(err)) { + setGatewayModelsError(message); + setGatewayModels([]); + if (!isDisconnectLikeError(err)) { logError("Failed to load gateway models.", err); } } @@ -174,13 +170,13 @@ export function useGatewayConfigSyncController( cancelled = true; }; }, [ - params.client, - params.isDisconnectLikeError, - params.setGatewayConfigSnapshot, - params.setGatewayModels, - params.setGatewayModelsError, - params.status, + client, + isDisconnectLikeError, logError, + setGatewayConfigSnapshot, + setGatewayModels, + setGatewayModelsError, + status, ]); return { diff --git a/src/features/agents/operations/useRuntimeSyncController.ts b/src/features/agents/operations/useRuntimeSyncController.ts index 12326ec..41f151d 100644 --- a/src/features/agents/operations/useRuntimeSyncController.ts +++ b/src/features/agents/operations/useRuntimeSyncController.ts @@ -26,9 +26,9 @@ import { } from "@/features/agents/state/runtimeEventBridge"; import type { AgentState } from "@/features/agents/state/store"; import { TRANSCRIPT_V2_ENABLED, logTranscriptDebugMetric } from "@/features/agents/state/transcript"; +import type { ControlPlaneOutboxEntry } from "@/lib/controlplane/contracts"; import { randomUUID } from "@/lib/uuid"; import { fetchJson } from "@/lib/http"; -import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode"; type RuntimeSyncDispatchAction = { type: "updateAgent"; @@ -41,7 +41,7 @@ type GatewayClientLike = { onGap?: (handler: (info: { expected: number; received: number }) => void) => () => void; }; -export type UseRuntimeSyncControllerParams = { +type UseRuntimeSyncControllerParams = { client: GatewayClientLike; status: "disconnected" | "connecting" | "connected"; agents: AgentState[]; @@ -50,32 +50,66 @@ export type UseRuntimeSyncControllerParams = { dispatch: (action: RuntimeSyncDispatchAction) => void; clearRunTracking: (runId: string) => void; isDisconnectLikeError: (error: unknown) => boolean; + useDomainApiReads: boolean; + ingestDomainOutboxEntries: (entries: ControlPlaneOutboxEntry[]) => void; defaultHistoryLimit?: number; maxHistoryLimit?: number; }; -export type RuntimeSyncController = { +type RuntimeSyncController = { loadSummarySnapshot: () => Promise; - loadAgentHistory: (agentId: string, options?: { limit?: number }) => Promise; + loadAgentHistory: ( + agentId: string, + options?: { limit?: number; beforeOutboxId?: number } + ) => Promise; loadMoreAgentHistory: (agentId: string) => void; reconcileRunningAgents: () => Promise; clearHistoryInFlight: (sessionKey: string) => void; }; +type DomainAgentHistoryResponse = { + entries?: unknown[]; + hasMore?: unknown; + nextBeforeOutboxId?: unknown; +}; + +const MAX_DOMAIN_HISTORY_DEDUPE_KEYS = 20_000; + +const resolveDomainOutboxDedupeKey = (entry: ControlPlaneOutboxEntry): string | null => { + const entryId = typeof entry?.id === "number" && Number.isFinite(entry.id) ? entry.id : null; + if (entryId === null) return null; + const createdAt = typeof entry.createdAt === "string" ? entry.createdAt.trim() : ""; + return `${entryId}:${createdAt}`; +}; + export function useRuntimeSyncController( params: UseRuntimeSyncControllerParams ): RuntimeSyncController { - const useDomainApiReads = isStudioDomainIntentModeEnabled(); - const agentsRef = useRef(params.agents); + const { + client, + status, + agents, + focusedAgentId, + focusedAgentRunning, + dispatch, + clearRunTracking, + isDisconnectLikeError, + useDomainApiReads, + ingestDomainOutboxEntries, + } = params; + const agentsRef = useRef(agents); const historyInFlightRef = useRef>(new Set()); const reconcileRunInFlightRef = useRef>(new Set()); + const domainHistoryCursorByAgentRef = useRef>(new Map()); + const seenDomainOutboxKeysRef = useRef>(new Set()); + const seenDomainOutboxKeyOrderRef = useRef([]); const defaultHistoryLimit = params.defaultHistoryLimit ?? RUNTIME_SYNC_DEFAULT_HISTORY_LIMIT; const maxHistoryLimit = params.maxHistoryLimit ?? RUNTIME_SYNC_MAX_HISTORY_LIMIT; useEffect(() => { - agentsRef.current = params.agents; - }, [params.agents]); + agentsRef.current = agents; + }, [agents]); const clearHistoryInFlight = useCallback((sessionKey: string) => { const key = sessionKey.trim(); @@ -90,7 +124,7 @@ export function useRuntimeSyncController( cache: "no-store", }); } catch (error) { - if (!params.isDisconnectLikeError(error)) { + if (!isDisconnectLikeError(error)) { console.error("Failed to load domain runtime summary.", error); } } @@ -105,11 +139,11 @@ export function useRuntimeSyncController( const activeAgents = snapshotAgents.filter((agent) => agent.sessionCreated); try { const [statusSummary, previewResult] = await Promise.all([ - params.client.call("status", {}), - params.client.call("sessions.preview", { + client.call("status", {}), + client.call("sessions.preview", { keys: summaryIntent.keys, limit: summaryIntent.limit, - maxChars: summaryIntent.maxChars, + maxChars: summaryIntent.maxChars, }), ]); for (const entry of buildSummarySnapshotPatches({ @@ -117,61 +151,104 @@ export function useRuntimeSyncController( statusSummary, previewResult, })) { - params.dispatch({ + dispatch({ type: "updateAgent", agentId: entry.agentId, patch: entry.patch, }); } } catch (error) { - if (!params.isDisconnectLikeError(error)) { + if (!isDisconnectLikeError(error)) { console.error("Failed to load summary snapshot.", error); } } - }, [params.client, params.dispatch, params.isDisconnectLikeError, useDomainApiReads]); + }, [client, dispatch, isDisconnectLikeError, useDomainApiReads]); const loadAgentHistoryViaDomainApi = useCallback( - async (agentId: string, limit: number) => { + async (agentId: string, limit: number, beforeOutboxId?: number) => { const encodedAgentId = encodeURIComponent(agentId.trim()); if (!encodedAgentId) return; - const result = await fetchJson<{ entries?: unknown[] }>( - `/api/runtime/agents/${encodedAgentId}/history?limit=${limit}`, + const query = new URLSearchParams(); + query.set("limit", String(limit)); + if ( + typeof beforeOutboxId === "number" && + Number.isFinite(beforeOutboxId) && + beforeOutboxId > 0 + ) { + query.set("beforeOutboxId", String(Math.floor(beforeOutboxId))); + } + const result = await fetchJson( + `/api/runtime/agents/${encodedAgentId}/history?${query.toString()}`, { cache: "no-store" } ); - const entries = Array.isArray(result.entries) ? result.entries : []; - params.dispatch({ + const entries = Array.isArray(result.entries) ? (result.entries as ControlPlaneOutboxEntry[]) : []; + const unseen: ControlPlaneOutboxEntry[] = []; + for (const entry of entries) { + const dedupeKey = resolveDomainOutboxDedupeKey(entry); + if (!dedupeKey) continue; + if (seenDomainOutboxKeysRef.current.has(dedupeKey)) continue; + seenDomainOutboxKeysRef.current.add(dedupeKey); + seenDomainOutboxKeyOrderRef.current.push(dedupeKey); + unseen.push(entry); + } + if (seenDomainOutboxKeyOrderRef.current.length > MAX_DOMAIN_HISTORY_DEDUPE_KEYS) { + const overflow = seenDomainOutboxKeyOrderRef.current.length - MAX_DOMAIN_HISTORY_DEDUPE_KEYS; + const dropped = seenDomainOutboxKeyOrderRef.current.splice(0, overflow); + for (const key of dropped) { + seenDomainOutboxKeysRef.current.delete(key); + } + } + if (unseen.length > 0) { + ingestDomainOutboxEntries(unseen); + } + const hasMore = result.hasMore === true; + const nextBeforeOutboxId = + typeof result.nextBeforeOutboxId === "number" && + Number.isFinite(result.nextBeforeOutboxId) && + result.nextBeforeOutboxId > 0 + ? Math.floor(result.nextBeforeOutboxId) + : null; + const normalizedAgentId = agentId.trim(); + if (normalizedAgentId) { + domainHistoryCursorByAgentRef.current.set(normalizedAgentId, nextBeforeOutboxId); + } + dispatch({ type: "updateAgent", agentId, patch: { historyLoadedAt: Date.now(), historyFetchLimit: limit, historyFetchedCount: entries.length, - historyMaybeTruncated: false, + historyMaybeTruncated: hasMore, }, }); }, - [params.dispatch] + [dispatch, ingestDomainOutboxEntries] ); const loadAgentHistory = useCallback( - async (agentId: string, options?: { limit?: number }) => { + async (agentId: string, options?: { limit?: number; beforeOutboxId?: 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; + const beforeOutboxId = + typeof options?.beforeOutboxId === "number" && Number.isFinite(options.beforeOutboxId) + ? Math.max(1, Math.floor(options.beforeOutboxId)) + : undefined; try { - await loadAgentHistoryViaDomainApi(agentId, limit); + await loadAgentHistoryViaDomainApi(agentId, limit, beforeOutboxId); } catch (error) { - if (!params.isDisconnectLikeError(error)) { + if (!isDisconnectLikeError(error)) { console.error("Failed to load domain runtime history.", error); } } return; } const commands = await runHistorySyncOperation({ - client: params.client, + client, agentId, requestedLimit: options?.limit, getAgent: (targetAgentId) => @@ -185,25 +262,33 @@ export function useRuntimeSyncController( }); executeHistorySyncCommands({ commands, - dispatch: params.dispatch, + dispatch, logMetric: (metric, meta) => logTranscriptDebugMetric(metric, meta), - isDisconnectLikeError: params.isDisconnectLikeError, + isDisconnectLikeError, logError: (message, error) => console.error(message, error), }); }, [ + client, defaultHistoryLimit, + dispatch, + isDisconnectLikeError, loadAgentHistoryViaDomainApi, maxHistoryLimit, - params.client, - params.dispatch, - params.isDisconnectLikeError, useDomainApiReads, ] ); const loadMoreAgentHistory = useCallback( (agentId: string) => { + if (useDomainApiReads) { + const agent = agentsRef.current.find((entry) => entry.agentId === agentId) ?? null; + const limit = agent?.historyFetchLimit ?? defaultHistoryLimit; + const beforeOutboxId = domainHistoryCursorByAgentRef.current.get(agentId) ?? null; + if (beforeOutboxId === null) return; + void loadAgentHistory(agentId, { limit, beforeOutboxId }); + return; + } const agent = agentsRef.current.find((entry) => entry.agentId === agentId) ?? null; const nextLimit = resolveRuntimeSyncLoadMoreHistoryLimit({ currentLimit: agent?.historyFetchLimit ?? null, @@ -212,14 +297,14 @@ export function useRuntimeSyncController( }); void loadAgentHistory(agentId, { limit: nextLimit }); }, - [defaultHistoryLimit, loadAgentHistory, maxHistoryLimit] + [defaultHistoryLimit, loadAgentHistory, maxHistoryLimit, useDomainApiReads] ); const reconcileRunningAgents = useCallback(async () => { - if (params.status !== "connected") return; + if (status !== "connected") return; if (useDomainApiReads) return; const commands = await runAgentReconcileOperation({ - client: params.client, + client, agents: agentsRef.current, getLatestAgent: (agentId) => agentsRef.current.find((entry) => entry.agentId === agentId) ?? null, @@ -235,12 +320,12 @@ export function useRuntimeSyncController( if (!normalized) return; reconcileRunInFlightRef.current.delete(normalized); }, - isDisconnectLikeError: params.isDisconnectLikeError, + isDisconnectLikeError, }); executeAgentReconcileCommands({ commands, - dispatch: params.dispatch, - clearRunTracking: params.clearRunTracking, + dispatch, + clearRunTracking, requestHistoryRefresh: (agentId) => { void loadAgentHistory(agentId); }, @@ -248,23 +333,23 @@ export function useRuntimeSyncController( logWarn: (message, error) => console.warn(message, error), }); }, [ + clearRunTracking, + client, + dispatch, + isDisconnectLikeError, loadAgentHistory, - params.clearRunTracking, - params.client, - params.dispatch, - params.isDisconnectLikeError, - params.status, + status, useDomainApiReads, ]); useEffect(() => { - if (params.status !== "connected") return; + if (status !== "connected") return; void loadSummarySnapshot(); - }, [loadSummarySnapshot, params.status]); + }, [loadSummarySnapshot, status]); useEffect(() => { const reconcileIntent = resolveRuntimeSyncReconcilePollingIntent({ - status: params.status, + status, }); if (reconcileIntent.kind === "stop") return; void reconcileRunningAgents(); @@ -274,23 +359,23 @@ export function useRuntimeSyncController( return () => { window.clearInterval(timer); }; - }, [params.status, reconcileRunningAgents]); + }, [reconcileRunningAgents, status]); useEffect(() => { const bootstrapAgentIds = resolveRuntimeSyncBootstrapHistoryAgentIds({ - status: params.status, - agents: params.agents, + status, + agents, }); for (const agentId of bootstrapAgentIds) { void loadAgentHistory(agentId); } - }, [loadAgentHistory, params.agents, params.status]); + }, [agents, loadAgentHistory, status]); useEffect(() => { const pollingIntent = resolveRuntimeSyncFocusedHistoryPollingIntent({ - status: params.status, - focusedAgentId: params.focusedAgentId, - focusedAgentRunning: params.focusedAgentRunning, + status, + focusedAgentId, + focusedAgentRunning, }); if (pollingIntent.kind === "stop") return; void loadAgentHistory(pollingIntent.agentId); @@ -305,12 +390,12 @@ export function useRuntimeSyncController( return () => { window.clearInterval(timer); }; - }, [loadAgentHistory, params.focusedAgentId, params.focusedAgentRunning, params.status]); + }, [focusedAgentId, focusedAgentRunning, loadAgentHistory, status]); useEffect(() => { if (useDomainApiReads) return; - if (!params.client.onGap) return; - return params.client.onGap((info) => { + if (!client.onGap) return; + return client.onGap((info) => { const recoveryIntent = resolveRuntimeSyncGapRecoveryIntent(); console.warn(`Gateway event gap expected ${info.expected}, received ${info.received}.`); if (recoveryIntent.refreshSummarySnapshot) { @@ -320,7 +405,7 @@ export function useRuntimeSyncController( void reconcileRunningAgents(); } }); - }, [loadSummarySnapshot, params.client, reconcileRunningAgents, useDomainApiReads]); + }, [client, loadSummarySnapshot, reconcileRunningAgents, useDomainApiReads]); return { loadSummarySnapshot, diff --git a/src/features/agents/operations/useSettingsRouteController.ts b/src/features/agents/operations/useSettingsRouteController.ts index 331e02e..385e3df 100644 --- a/src/features/agents/operations/useSettingsRouteController.ts +++ b/src/features/agents/operations/useSettingsRouteController.ts @@ -90,81 +90,96 @@ const executeSettingsRouteCommands = ( export function useSettingsRouteController( params: UseSettingsRouteControllerParams ): SettingsRouteController { + const { + settingsRouteActive, + settingsRouteAgentId, + status, + agentsLoadedOnce, + selectedAgentId, + focusedAgentId, + personalityHasUnsavedChanges, + activeTab, + inspectSidebar, + agents, + flushPendingDraft, + dispatchSelectAgent, + setInspectSidebar, + setMobilePaneChat, + setPersonalityHasUnsavedChanges, + push, + replace, + confirmDiscard, + } = params; + const applyCommands = useCallback( (commands: SettingsRouteNavCommand[]) => { executeSettingsRouteCommands(commands, { - dispatchSelectAgent: params.dispatchSelectAgent, - setInspectSidebar: params.setInspectSidebar, - setMobilePaneChat: params.setMobilePaneChat, - setPersonalityHasUnsavedChanges: params.setPersonalityHasUnsavedChanges, - flushPendingDraft: params.flushPendingDraft, - push: params.push, - replace: params.replace, + dispatchSelectAgent, + setInspectSidebar, + setMobilePaneChat, + setPersonalityHasUnsavedChanges, + flushPendingDraft, + push, + replace, }); }, [ - params.dispatchSelectAgent, - params.flushPendingDraft, - params.push, - params.replace, - params.setInspectSidebar, - params.setMobilePaneChat, - params.setPersonalityHasUnsavedChanges, + dispatchSelectAgent, + flushPendingDraft, + push, + replace, + setInspectSidebar, + setMobilePaneChat, + setPersonalityHasUnsavedChanges, ] ); const handleBackToChat = useCallback(() => { const needsDiscardConfirmation = shouldConfirmDiscardPersonalityChanges({ - settingsRouteActive: params.settingsRouteActive, - activeTab: params.activeTab, - personalityHasUnsavedChanges: params.personalityHasUnsavedChanges, + settingsRouteActive, + activeTab, + personalityHasUnsavedChanges, }); - const discardConfirmed = needsDiscardConfirmation ? params.confirmDiscard() : true; + const discardConfirmed = needsDiscardConfirmation ? confirmDiscard() : true; const commands = planBackToChatCommands({ - settingsRouteActive: params.settingsRouteActive, - activeTab: params.activeTab, - personalityHasUnsavedChanges: params.personalityHasUnsavedChanges, + settingsRouteActive, + activeTab, + personalityHasUnsavedChanges, discardConfirmed, }); applyCommands(commands); - }, [ - applyCommands, - params.activeTab, - params.confirmDiscard, - params.personalityHasUnsavedChanges, - params.settingsRouteActive, - ]); + }, [activeTab, applyCommands, confirmDiscard, personalityHasUnsavedChanges, settingsRouteActive]); const handleSettingsRouteTabChange = useCallback( (nextTab: SettingsRouteTab) => { - const currentTab = params.inspectSidebar?.tab ?? "personality"; + const currentTab = inspectSidebar?.tab ?? "personality"; const needsDiscardConfirmation = currentTab === "personality" && nextTab !== "personality" && shouldConfirmDiscardPersonalityChanges({ - settingsRouteActive: params.settingsRouteActive, + settingsRouteActive, activeTab: currentTab, - personalityHasUnsavedChanges: params.personalityHasUnsavedChanges, + personalityHasUnsavedChanges, }); - const discardConfirmed = needsDiscardConfirmation ? params.confirmDiscard() : true; + const discardConfirmed = needsDiscardConfirmation ? confirmDiscard() : true; const commands = planSettingsTabChangeCommands({ nextTab, - currentInspectSidebar: params.inspectSidebar, - settingsRouteAgentId: params.settingsRouteAgentId, - settingsRouteActive: params.settingsRouteActive, - personalityHasUnsavedChanges: params.personalityHasUnsavedChanges, + currentInspectSidebar: inspectSidebar, + settingsRouteAgentId, + settingsRouteActive, + personalityHasUnsavedChanges, discardConfirmed, }); applyCommands(commands); }, [ applyCommands, - params.confirmDiscard, - params.inspectSidebar, - params.personalityHasUnsavedChanges, - params.settingsRouteActive, - params.settingsRouteAgentId, + confirmDiscard, + inspectSidebar, + personalityHasUnsavedChanges, + settingsRouteActive, + settingsRouteAgentId, ] ); @@ -172,79 +187,79 @@ export function useSettingsRouteController( (agentId: string) => { const commands = planOpenSettingsRouteCommands({ agentId, - currentInspectSidebar: params.inspectSidebar, - focusedAgentId: params.focusedAgentId, + currentInspectSidebar: inspectSidebar, + focusedAgentId, }); applyCommands(commands); }, - [applyCommands, params.focusedAgentId, params.inspectSidebar] + [applyCommands, focusedAgentId, inspectSidebar] ); const handleFleetSelectAgent = useCallback( (agentId: string) => { const commands = planFleetSelectCommands({ agentId, - currentInspectSidebar: params.inspectSidebar, - focusedAgentId: params.focusedAgentId, + currentInspectSidebar: inspectSidebar, + focusedAgentId, }); applyCommands(commands); }, - [applyCommands, params.focusedAgentId, params.inspectSidebar] + [applyCommands, focusedAgentId, inspectSidebar] ); useEffect(() => { - const routeAgentId = (params.settingsRouteAgentId ?? "").trim(); + const routeAgentId = (settingsRouteAgentId ?? "").trim(); const hasRouteAgent = routeAgentId - ? params.agents.some((agent) => agent.agentId === routeAgentId) + ? agents.some((agent) => agent.agentId === routeAgentId) : false; const commands = planSettingsRouteSyncCommands({ - settingsRouteActive: params.settingsRouteActive, - settingsRouteAgentId: params.settingsRouteAgentId, - status: params.status, - agentsLoadedOnce: params.agentsLoadedOnce, - selectedAgentId: params.selectedAgentId, + settingsRouteActive, + settingsRouteAgentId, + status, + agentsLoadedOnce, + selectedAgentId, hasRouteAgent, - currentInspectSidebar: params.inspectSidebar, + currentInspectSidebar: inspectSidebar, }); applyCommands(commands); }, [ applyCommands, - params.agents, - params.agentsLoadedOnce, - params.inspectSidebar, - params.selectedAgentId, - params.settingsRouteActive, - params.settingsRouteAgentId, - params.status, + agents, + agentsLoadedOnce, + inspectSidebar, + selectedAgentId, + settingsRouteActive, + settingsRouteAgentId, + status, ]); useEffect(() => { - const hasSelectedAgentInAgents = params.selectedAgentId - ? params.agents.some((agent) => agent.agentId === params.selectedAgentId) + const hasSelectedAgentInAgents = selectedAgentId + ? agents.some((agent) => agent.agentId === selectedAgentId) : false; - const hasInspectSidebarAgent = params.inspectSidebar?.agentId - ? params.agents.some((agent) => agent.agentId === params.inspectSidebar?.agentId) + const hasInspectSidebarAgent = inspectSidebar?.agentId + ? agents.some((agent) => agent.agentId === inspectSidebar?.agentId) : false; const commands = planNonRouteSelectionSyncCommands({ - settingsRouteActive: params.settingsRouteActive, - selectedAgentId: params.selectedAgentId, - focusedAgentId: params.focusedAgentId, + settingsRouteActive, + selectedAgentId, + focusedAgentId, hasSelectedAgentInAgents, - currentInspectSidebar: params.inspectSidebar, + currentInspectSidebar: inspectSidebar, hasInspectSidebarAgent, }); applyCommands(commands); }, [ applyCommands, - params.agents, - params.focusedAgentId, - params.inspectSidebar, - params.selectedAgentId, - params.settingsRouteActive, + agents, + focusedAgentId, + inspectSidebar, + selectedAgentId, + settingsRouteActive, ]); return { diff --git a/src/features/agents/state/gatewayEventIngressWorkflow.ts b/src/features/agents/state/gatewayEventIngressWorkflow.ts index 9b000b6..e5578a0 100644 --- a/src/features/agents/state/gatewayEventIngressWorkflow.ts +++ b/src/features/agents/state/gatewayEventIngressWorkflow.ts @@ -11,7 +11,7 @@ export type CronTranscriptIntent = { activityAtMs: number | null; }; -export type GatewayEventIngressDecision = { +type GatewayEventIngressDecision = { approvalEffects: ExecApprovalEventEffects | null; cronDedupeKeyToRecord: string | null; cronTranscriptIntent: CronTranscriptIntent | null; diff --git a/src/features/agents/state/gatewayRuntimeEventHandler.ts b/src/features/agents/state/gatewayRuntimeEventHandler.ts index f107ada..fc9f1bc 100644 --- a/src/features/agents/state/gatewayRuntimeEventHandler.ts +++ b/src/features/agents/state/gatewayRuntimeEventHandler.ts @@ -34,7 +34,7 @@ import { import { planRuntimeChatEvent } from "@/features/agents/state/runtimeChatEventWorkflow"; import { planRuntimeAgentEvent } from "@/features/agents/state/runtimeAgentEventWorkflow"; -export type GatewayRuntimeEventHandlerDeps = { +type GatewayRuntimeEventHandlerDeps = { getStatus: () => "disconnected" | "connecting" | "connected"; getAgents: () => AgentState[]; dispatch: (action: RuntimeCoordinatorDispatchAction) => void; @@ -65,7 +65,7 @@ export type GatewayRuntimeEventHandlerDeps = { updateSpecialLatestUpdate: (agentId: string, agent: AgentState, message: string) => void; }; -export type GatewayRuntimeEventHandler = { +type GatewayRuntimeEventHandler = { handleEvent: (event: EventFrame) => void; clearRunTracking: (runId?: string | null) => void; dispose: () => void; diff --git a/src/features/agents/state/runtimeAgentEventWorkflow.ts b/src/features/agents/state/runtimeAgentEventWorkflow.ts index 77f04c2..ada3cea 100644 --- a/src/features/agents/state/runtimeAgentEventWorkflow.ts +++ b/src/features/agents/state/runtimeAgentEventWorkflow.ts @@ -65,7 +65,7 @@ export type RuntimeAgentWorkflowInput = { lifecycleFallbackDelayMs: number; }; -export type RuntimeAgentWorkflowResult = { +type RuntimeAgentWorkflowResult = { commands: RuntimeAgentWorkflowCommand[]; }; diff --git a/src/features/agents/state/runtimeChatEventWorkflow.ts b/src/features/agents/state/runtimeChatEventWorkflow.ts index a967016..151acdc 100644 --- a/src/features/agents/state/runtimeChatEventWorkflow.ts +++ b/src/features/agents/state/runtimeChatEventWorkflow.ts @@ -45,7 +45,7 @@ export type RuntimeChatWorkflowInput = { thinkingStartedAtMs: number | null; }; -export type RuntimeChatWorkflowResult = { +type RuntimeChatWorkflowResult = { commands: RuntimeChatWorkflowCommand[]; }; diff --git a/src/features/agents/state/runtimeEventBridge.ts b/src/features/agents/state/runtimeEventBridge.ts index a5f3d73..30fd4c1 100644 --- a/src/features/agents/state/runtimeEventBridge.ts +++ b/src/features/agents/state/runtimeEventBridge.ts @@ -39,7 +39,7 @@ type LifecycleTransitionIgnore = { kind: "ignore"; }; -export type LifecycleTransition = +type LifecycleTransition = | LifecycleTransitionStart | LifecycleTransitionTerminal | LifecycleTransitionIgnore; @@ -99,7 +99,7 @@ export type SummaryStatusSnapshot = { }; }; -export type SummaryPreviewItem = { +type SummaryPreviewItem = { role: "user" | "assistant" | "tool" | "system" | "other"; text: string; timestamp?: number | string; @@ -123,7 +123,7 @@ export type SummarySnapshotPatch = { export type ChatHistoryMessage = Record; -export type HistoryLinesResult = { +type HistoryLinesResult = { lines: string[]; lastAssistant: string | null; lastAssistantAt: number | null; @@ -132,7 +132,7 @@ export type HistoryLinesResult = { lastUserAt: number | null; }; -export type HistorySyncPatchInput = { +type HistorySyncPatchInput = { messages: ChatHistoryMessage[]; currentLines: string[]; loadedAt: number; @@ -140,7 +140,7 @@ export type HistorySyncPatchInput = { runId: string | null; }; -export type GatewayEventKind = +type GatewayEventKind = | "summary-refresh" | "runtime-chat" | "runtime-agent" diff --git a/src/features/agents/state/runtimeEventCoordinatorWorkflow.ts b/src/features/agents/state/runtimeEventCoordinatorWorkflow.ts index 32bffed..a08ee43 100644 --- a/src/features/agents/state/runtimeEventCoordinatorWorkflow.ts +++ b/src/features/agents/state/runtimeEventCoordinatorWorkflow.ts @@ -20,7 +20,7 @@ import { } from "@/features/agents/state/runtimeTerminalWorkflow"; import { formatMetaMarkdown } from "@/lib/text/message-extract"; -export type RuntimeEventCoordinatorState = { +type RuntimeEventCoordinatorState = { runtimeTerminalState: RuntimeTerminalState; chatRunSeen: Set; assistantStreamByRun: Map; diff --git a/src/features/agents/state/runtimeEventPolicy.ts b/src/features/agents/state/runtimeEventPolicy.ts index efad353..3da5242 100644 --- a/src/features/agents/state/runtimeEventPolicy.ts +++ b/src/features/agents/state/runtimeEventPolicy.ts @@ -15,7 +15,7 @@ export type RuntimePolicyIntent = | { kind: "queueLatestUpdate"; agentId: string; message: string } | { kind: "scheduleSummaryRefresh"; delayMs: number; includeHeartbeatRefresh: boolean }; -export type RuntimeChatPolicyInput = { +type RuntimeChatPolicyInput = { agentId: string; state: ChatEventPayload["state"]; runId: string | null; @@ -39,7 +39,7 @@ export type RuntimeChatPolicyInput = { latestUpdateMessage: string | null; }; -export type RuntimeAgentPolicyInput = { +type RuntimeAgentPolicyInput = { runId: string; stream: string; phase: string; @@ -48,7 +48,7 @@ export type RuntimeAgentPolicyInput = { isClosedRun: boolean; }; -export type RuntimeSummaryPolicyInput = { +type RuntimeSummaryPolicyInput = { event: string; status: ConnectionStatus; }; diff --git a/src/features/agents/state/runtimeTerminalWorkflow.ts b/src/features/agents/state/runtimeTerminalWorkflow.ts index 3f9bc5b..f64e8f5 100644 --- a/src/features/agents/state/runtimeTerminalWorkflow.ts +++ b/src/features/agents/state/runtimeTerminalWorkflow.ts @@ -45,7 +45,7 @@ type LifecycleTerminalFallbackFireDecisionInput = { runId?: string | null; }; -export type LifecycleTerminalDecisionInput = +type LifecycleTerminalDecisionInput = | LifecycleTerminalEventDecisionInput | LifecycleTerminalFallbackFireDecisionInput; diff --git a/src/features/agents/state/sessionSettingsMutations.ts b/src/features/agents/state/sessionSettingsMutations.ts index ed35628..21466f4 100644 --- a/src/features/agents/state/sessionSettingsMutations.ts +++ b/src/features/agents/state/sessionSettingsMutations.ts @@ -4,6 +4,7 @@ import { type GatewayClient, type GatewaySessionsPatchResult, } from "@/lib/gateway/GatewayClient"; +import type { RuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport"; type SessionSettingField = "model" | "thinkingLevel"; @@ -33,10 +34,11 @@ type SessionSettingsDispatchAction = type SessionSettingsDispatch = (action: SessionSettingsDispatchAction) => void; -export type ApplySessionSettingMutationParams = { +type ApplySessionSettingMutationParams = { agents: AgentSessionState[]; dispatch: SessionSettingsDispatch; client: GatewayClient; + runtimeWriteTransport?: RuntimeWriteTransport; agentId: string; sessionKey: string; field: SessionSettingField; @@ -58,6 +60,7 @@ export const applySessionSettingMutation = async ({ agents, dispatch, client, + runtimeWriteTransport, agentId, sessionKey, field, @@ -75,11 +78,16 @@ export const applySessionSettingMutation = async ({ }, }); try { - const result = await syncGatewaySessionSettings({ - client, - sessionKey, - ...(field === "model" ? { model: value ?? null } : { thinkingLevel: value ?? null }), - }); + const result = (runtimeWriteTransport + ? await runtimeWriteTransport.sessionSettingsSync({ + sessionKey, + ...(field === "model" ? { model: value ?? null } : { thinkingLevel: value ?? null }), + }) + : await syncGatewaySessionSettings({ + client, + sessionKey, + ...(field === "model" ? { model: value ?? null } : { thinkingLevel: value ?? null }), + })) as GatewaySessionsPatchResult; const patch: { model?: string | null; thinkingLevel?: string | null; diff --git a/src/features/agents/state/store.tsx b/src/features/agents/state/store.tsx index 6c802a4..d414db0 100644 --- a/src/features/agents/state/store.tsx +++ b/src/features/agents/state/store.tsx @@ -108,7 +108,7 @@ export const buildNewSessionAgentPatch = (agent: AgentState): Partial { - const raw = process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE?.trim().toLowerCase() ?? ""; - if (!raw) return true; - return !FALSE_VALUES.has(raw); -}; diff --git a/src/lib/controlplane/exec-approvals.ts b/src/lib/controlplane/exec-approvals.ts index 946842b..d46ac1e 100644 --- a/src/lib/controlplane/exec-approvals.ts +++ b/src/lib/controlplane/exec-approvals.ts @@ -1,8 +1,8 @@ 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"; +type GatewayExecApprovalSecurity = "deny" | "allowlist" | "full"; +type GatewayExecApprovalAsk = "off" | "on-miss" | "always"; export type ExecutionRoleId = "conservative" | "collaborative" | "autonomous"; type ExecAllowlistEntry = { @@ -66,30 +66,30 @@ const resolvePolicyForRole = (params: { const isRetryableSetError = (err: unknown): boolean => { if (!(err instanceof ControlPlaneGatewayError)) return false; const message = err.message.toLowerCase(); + if (err.code.trim().toUpperCase() !== "INVALID_REQUEST") return false; + if (!message.includes("exec approvals")) return false; return ( - err.code.trim().toUpperCase() === "INVALID_REQUEST" && - (message.includes("re-run exec.approvals.get") || message.includes("changed since last load")) + message.includes("re-run exec.approvals.get") || + message.includes("reload and retry") || + message.includes("base hash unavailable") || + message.includes("base hash required") || + message.includes("changed since last load") || + message.includes("exec approvals changed") ); }; -export const upsertAgentExecApprovalsPolicyViaRuntime = async (params: { - runtime: ControlPlaneRuntime; - agentId: string; - role: ExecutionRoleId; -}): Promise => { - const agentId = params.agentId.trim(); - if (!agentId) { - throw new Error("Agent id is required."); - } - - const snapshot = await params.runtime.callGateway("exec.approvals.get", {}); +const buildNextExecApprovalsFile = ( + snapshotFile: ExecApprovalsFile | undefined, + agentId: string, + role: ExecutionRoleId +): ExecApprovalsFile => { const baseFile: ExecApprovalsFile = - snapshot.file && typeof snapshot.file === "object" + snapshotFile && typeof snapshotFile === "object" ? { version: 1, - socket: snapshot.file.socket, - defaults: snapshot.file.defaults, - agents: { ...(snapshot.file.agents ?? {}) }, + socket: snapshotFile.socket, + defaults: snapshotFile.defaults, + agents: { ...(snapshotFile.agents ?? {}) }, } : { version: 1, agents: {} }; @@ -100,7 +100,7 @@ export const upsertAgentExecApprovalsPolicyViaRuntime = async (params: { ) ?? [] : []; const policy = resolvePolicyForRole({ - role: params.role, + role, allowlist: existingAllowlist.map((entry) => ({ pattern: entry.pattern })), }); @@ -117,11 +117,25 @@ export const upsertAgentExecApprovalsPolicyViaRuntime = async (params: { }; } - const nextFile: ExecApprovalsFile = { + return { ...baseFile, version: 1, agents: nextAgents, }; +}; + +export const upsertAgentExecApprovalsPolicyViaRuntime = async (params: { + runtime: ControlPlaneRuntime; + agentId: string; + role: ExecutionRoleId; +}): Promise => { + const agentId = params.agentId.trim(); + if (!agentId) { + throw new Error("Agent id is required."); + } + + const snapshot = await params.runtime.callGateway("exec.approvals.get", {}); + const nextFile = buildNextExecApprovalsFile(snapshot.file, agentId, params.role); const setPayload = { file: nextFile, ...(snapshot.exists ? { baseHash: snapshot.hash } : {}) }; try { @@ -129,8 +143,9 @@ export const upsertAgentExecApprovalsPolicyViaRuntime = async (params: { } catch (err) { if (!isRetryableSetError(err)) throw err; const retrySnapshot = await params.runtime.callGateway("exec.approvals.get", {}); + const retryNextFile = buildNextExecApprovalsFile(retrySnapshot.file, agentId, params.role); await params.runtime.callGateway("exec.approvals.set", { - file: nextFile, + file: retryNextFile, ...(retrySnapshot.exists ? { baseHash: retrySnapshot.hash } : {}), }); } diff --git a/src/lib/controlplane/intent-route.ts b/src/lib/controlplane/intent-route.ts index 9e7df8f..d1cf998 100644 --- a/src/lib/controlplane/intent-route.ts +++ b/src/lib/controlplane/intent-route.ts @@ -1,7 +1,9 @@ import { NextResponse } from "next/server"; import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter"; -import { getControlPlaneRuntime, isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime"; +import { serializeRuntimeInitFailure } from "@/lib/controlplane/runtime-init-errors"; +import { bootstrapDomainRuntime } from "@/lib/controlplane/runtime-route-bootstrap"; +import type { ControlPlaneRuntime } from "@/lib/controlplane/runtime"; const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === "object" && !Array.isArray(value)); @@ -19,22 +21,25 @@ const isConfigConflict = (error: ControlPlaneGatewayError): boolean => { }; export const ensureDomainIntentRuntime = async (): Promise< - ReturnType | Response + ControlPlaneRuntime | Response > => { - if (!isStudioDomainApiModeEnabled()) { + const bootstrap = await bootstrapDomainRuntime(); + if (bootstrap.kind === "mode-disabled") { 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"; + if (bootstrap.kind === "runtime-init-failed") { return NextResponse.json( - { error: message, code: "GATEWAY_UNAVAILABLE", reason: "gateway_unavailable" }, + serializeRuntimeInitFailure(bootstrap.failure), { status: 503 } ); } - return runtime; + if (bootstrap.kind === "start-failed") { + return NextResponse.json( + { error: bootstrap.message, code: "GATEWAY_UNAVAILABLE", reason: "gateway_unavailable" }, + { status: 503 } + ); + } + return bootstrap.runtime; }; export const parseIntentBody = async (request: Request): Promise | Response> => { diff --git a/src/lib/controlplane/openclaw-adapter.ts b/src/lib/controlplane/openclaw-adapter.ts index 02ed1e4..190986a 100644 --- a/src/lib/controlplane/openclaw-adapter.ts +++ b/src/lib/controlplane/openclaw-adapter.ts @@ -16,8 +16,8 @@ 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 CONNECT_CLIENT_ID = "gateway-client"; -const CONNECT_CLIENT_MODE = "backend"; +const CONNECT_CLIENT_ID = "openclaw-control-ui"; +const CONNECT_CLIENT_MODE = "webchat"; const DEFAULT_METHOD_ALLOWLIST = new Set([ "status", @@ -319,11 +319,17 @@ export class OpenClawGatewayAdapter { client: { id: CONNECT_CLIENT_ID, version: "dev", - platform: "node", + platform: "web", mode: CONNECT_CLIENT_MODE, }, role: "operator", - scopes: ["operator.admin", "operator.approvals", "operator.pairing"], + scopes: [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", + ], caps: [], auth: { token }, }, diff --git a/src/lib/controlplane/projection-store.ts b/src/lib/controlplane/projection-store.ts index a02384b..3107a03 100644 --- a/src/lib/controlplane/projection-store.ts +++ b/src/lib/controlplane/projection-store.ts @@ -15,11 +15,20 @@ const RUNTIME_DB_DIRNAME = "openclaw-studio"; const RUNTIME_DB_FILENAME = "runtime.db"; const DEFAULT_STATUS = "stopped" as const; +const NO_AGENT_SENTINEL = ""; +const DEFAULT_BACKFILL_BATCH_LIMIT = 500; +const AGENT_SESSION_KEY_RE = /^agent:([^:]+):(.+)$/i; type OutboxRow = { id: number; event_json: string; created_at: string; + agent_id: string | null; +}; + +type LegacyBackfillRow = { + id: number; + event_json: string; }; type ProjectionRow = { @@ -28,10 +37,43 @@ type ProjectionRow = { as_of: string | null; }; +type OutboxColumnInfo = { + name: string; +}; + const parseDomainEvent = (raw: string): ControlPlaneDomainEvent => { return JSON.parse(raw) as ControlPlaneDomainEvent; }; +const isObject = (value: unknown): value is Record => + Boolean(value && typeof value === "object"); + +const parseAgentIdFromSessionKey = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const raw = value.trim(); + if (!raw) return null; + const match = raw.match(AGENT_SESSION_KEY_RE); + if (!match) return null; + const agentId = match[1]?.trim().toLowerCase() ?? ""; + const rest = match[2]?.trim() ?? ""; + if (!agentId || !rest) return null; + return agentId; +}; + +const resolveAgentIdFromControlPlaneEvent = (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().toLowerCase() : ""; + if (directAgentId) return directAgentId; + return ( + parseAgentIdFromSessionKey(payload.sessionKey) ?? + parseAgentIdFromSessionKey(payload.key) ?? + parseAgentIdFromSessionKey(payload.runSessionKey) + ); +}; + const toOutboxEntry = (row: OutboxRow): ControlPlaneOutboxEntry => { return { id: row.id, @@ -43,16 +85,26 @@ const toOutboxEntry = (row: OutboxRow): ControlPlaneOutboxEntry => { const resolveControlPlaneRuntimeDbPath = (): string => path.join(resolveStateDir(), RUNTIME_DB_DIRNAME, RUNTIME_DB_FILENAME); +export type BackfillAgentOutboxResult = { + scannedRows: number; + updatedRows: number; + exhausted: boolean; +}; + 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 readOutboxBeforeStmt: Database.Statement<[number, number], OutboxRow>; + private readonly readAgentOutboxBeforeStmt: Database.Statement<[string, number, number], OutboxRow>; private readonly readOutboxByIdStmt: Database.Statement<[number], OutboxRow | undefined>; + private readonly readBackfillCandidatesStmt: Database.Statement<[number, number], LegacyBackfillRow>; 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 insertOutboxStmt: Database.Statement<[string, string, string, string]>; private readonly updateProcessedOutboxStmt: Database.Statement<[number, string]>; + private readonly updateOutboxAgentIdIfNullStmt: Database.Statement<[string, number]>; private readonly upsertStatusProjectionStmt: Database.Statement< [string, string | null, string, string] >; @@ -61,6 +113,7 @@ export class SQLiteControlPlaneProjectionStore { event: ControlPlaneDomainEvent, eventKey: string ) => ControlPlaneOutboxEntry; + private readonly backfillOutboxAgentIdsTx: (rows: LegacyBackfillRow[]) => number; constructor(dbPath: string = resolveControlPlaneRuntimeDbPath()) { const dir = path.dirname(dbPath); @@ -77,10 +130,19 @@ export class SQLiteControlPlaneProjectionStore { ); 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 ?" + "SELECT id, event_json, created_at, agent_id FROM outbox WHERE id > ? ORDER BY id ASC LIMIT ?" + ); + this.readOutboxBeforeStmt = this.db.prepare( + "SELECT id, event_json, created_at, agent_id FROM outbox WHERE id < ? ORDER BY id DESC LIMIT ?" + ); + this.readAgentOutboxBeforeStmt = this.db.prepare( + "SELECT id, event_json, created_at, agent_id FROM outbox WHERE agent_id = ? AND id < ? ORDER BY id DESC LIMIT ?" ); this.readOutboxByIdStmt = this.db.prepare( - "SELECT id, event_json, created_at FROM outbox WHERE id = ?" + "SELECT id, event_json, created_at, agent_id FROM outbox WHERE id = ?" + ); + this.readBackfillCandidatesStmt = this.db.prepare( + "SELECT id, event_json FROM outbox WHERE agent_id IS NULL AND id < ? ORDER BY id DESC LIMIT ?" ); this.readProcessedStmt = this.db.prepare( "SELECT outbox_id FROM processed_events WHERE event_key = ?" @@ -89,11 +151,14 @@ export class SQLiteControlPlaneProjectionStore { "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 (?, ?, ?)" + "INSERT INTO outbox (event_type, event_json, created_at, agent_id) VALUES (?, ?, ?, ?)" ); this.updateProcessedOutboxStmt = this.db.prepare( "UPDATE processed_events SET outbox_id = ? WHERE event_key = ?" ); + this.updateOutboxAgentIdIfNullStmt = this.db.prepare( + "UPDATE outbox SET agent_id = ? WHERE id = ? AND agent_id IS NULL" + ); this.upsertStatusProjectionStmt = this.db.prepare(` INSERT INTO runtime_projection (id, status, reason, as_of, updated_at) VALUES (1, ?, ?, ?, ?) @@ -113,11 +178,15 @@ export class SQLiteControlPlaneProjectionStore { this.applyEventTx = this.db.transaction((event: ControlPlaneDomainEvent, eventKey: string) => { const existing = this.readProcessedStmt.get(eventKey); + const nextAgentId = resolveAgentIdFromControlPlaneEvent(event) ?? NO_AGENT_SENTINEL; 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}`); } + if (row.agent_id == null) { + this.updateOutboxAgentIdIfNullStmt.run(nextAgentId, row.id); + } return toOutboxEntry(row); } @@ -130,7 +199,7 @@ export class SQLiteControlPlaneProjectionStore { this.upsertGatewayProjectionStmt.run(event.asOf, now); } - const info = this.insertOutboxStmt.run(event.type, JSON.stringify(event), now); + const info = this.insertOutboxStmt.run(event.type, JSON.stringify(event), now, nextAgentId); const outboxId = Number(info.lastInsertRowid); this.updateProcessedOutboxStmt.run(outboxId, eventKey); @@ -140,6 +209,21 @@ export class SQLiteControlPlaneProjectionStore { } return toOutboxEntry(row); }); + + this.backfillOutboxAgentIdsTx = this.db.transaction((rows: LegacyBackfillRow[]) => { + let updatedRows = 0; + for (const row of rows) { + let nextAgentId = NO_AGENT_SENTINEL; + try { + nextAgentId = resolveAgentIdFromControlPlaneEvent(parseDomainEvent(row.event_json)) ?? NO_AGENT_SENTINEL; + } catch (error) { + console.error("Failed to parse outbox event while backfilling agent history index.", error); + } + const result = this.updateOutboxAgentIdIfNullStmt.run(nextAgentId, row.id); + updatedRows += Number(result.changes ?? 0); + } + return updatedRows; + }); } applyDomainEvent( @@ -155,6 +239,64 @@ export class SQLiteControlPlaneProjectionStore { return this.readOutboxAfterStmt.all(safeLastSeen, safeLimit).map(toOutboxEntry); } + readOutboxBefore(beforeOutboxId: number, limit: number = 500): ControlPlaneOutboxEntry[] { + const safeBeforeOutboxId = + Number.isFinite(beforeOutboxId) && beforeOutboxId > 0 ? Math.floor(beforeOutboxId) : 0; + if (safeBeforeOutboxId <= 0) return []; + const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 500; + return this.readOutboxBeforeStmt + .all(safeBeforeOutboxId, safeLimit) + .reverse() + .map(toOutboxEntry); + } + + readAgentOutboxBefore( + agentId: string, + beforeOutboxId: number, + limit: number = 500 + ): ControlPlaneOutboxEntry[] { + const normalizedAgentId = agentId.trim().toLowerCase(); + if (!normalizedAgentId) return []; + const safeBeforeOutboxId = + Number.isFinite(beforeOutboxId) && beforeOutboxId > 0 ? Math.floor(beforeOutboxId) : 0; + if (safeBeforeOutboxId <= 0) return []; + const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 500; + return this.readAgentOutboxBeforeStmt + .all(normalizedAgentId, safeBeforeOutboxId, safeLimit) + .reverse() + .map(toOutboxEntry); + } + + backfillAgentOutboxBefore( + beforeOutboxId: number, + limit: number = DEFAULT_BACKFILL_BATCH_LIMIT + ): BackfillAgentOutboxResult { + const safeBeforeOutboxId = + Number.isFinite(beforeOutboxId) && beforeOutboxId > 0 ? Math.floor(beforeOutboxId) : 0; + if (safeBeforeOutboxId <= 0) { + return { + scannedRows: 0, + updatedRows: 0, + exhausted: true, + }; + } + const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : DEFAULT_BACKFILL_BATCH_LIMIT; + const candidates = this.readBackfillCandidatesStmt.all(safeBeforeOutboxId, safeLimit); + if (candidates.length === 0) { + return { + scannedRows: 0, + updatedRows: 0, + exhausted: true, + }; + } + const updatedRows = this.backfillOutboxAgentIdsTx(candidates); + return { + scannedRows: candidates.length, + updatedRows, + exhausted: candidates.length < safeLimit, + }; + } + outboxHead(): number { const row = this.readOutboxHeadStmt.get(); return row?.head ?? 0; @@ -209,9 +351,18 @@ export class SQLiteControlPlaneProjectionStore { CREATE INDEX IF NOT EXISTS idx_outbox_id ON outbox(id); `); + + const columns = this.db.prepare("PRAGMA table_info(outbox)").all() as OutboxColumnInfo[]; + const hasAgentId = columns.some((column) => column.name === "agent_id"); + if (!hasAgentId) { + this.db.exec("ALTER TABLE outbox ADD COLUMN agent_id TEXT"); + } + + this.db.exec("CREATE INDEX IF NOT EXISTS idx_outbox_agent_id_id ON outbox(agent_id, id DESC)"); + const version = Number(this.db.pragma("user_version", { simple: true })); - if (version < 1) { - this.db.pragma("user_version = 1"); + if (version < 2) { + this.db.pragma("user_version = 2"); } } } diff --git a/src/lib/controlplane/read-model.ts b/src/lib/controlplane/read-model.ts deleted file mode 100644 index b9f9875..0000000 --- a/src/lib/controlplane/read-model.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ControlPlaneDomainEvent, ControlPlaneOutboxEntry } from "@/lib/controlplane/contracts"; - -const AGENT_SESSION_KEY_RE = /^agent:([^:]+):/; - -const isObject = (value: unknown): value is Record => - 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); -}; diff --git a/src/lib/controlplane/runtime-init-errors.ts b/src/lib/controlplane/runtime-init-errors.ts new file mode 100644 index 0000000..815edf6 --- /dev/null +++ b/src/lib/controlplane/runtime-init-errors.ts @@ -0,0 +1,107 @@ +type RuntimeInitFailureCode = + | "CONTROLPLANE_RUNTIME_INIT_FAILED" + | "NATIVE_MODULE_MISMATCH"; + +type RuntimeInitFailureReason = "runtime_init_failed" | "native_module_mismatch"; + +type RuntimeInitFailureRemediation = { + summary: string; + commands: string[]; +}; + +export type RuntimeInitFailure = { + code: RuntimeInitFailureCode; + reason: RuntimeInitFailureReason; + message: string; + remediation?: RuntimeInitFailureRemediation; +}; + +const NATIVE_MISMATCH_COMMANDS = ["npm rebuild better-sqlite3", "npm install"] as const; + +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +const resolveErrorMessage = (error: unknown, fallback: string): string => { + if (error instanceof Error) { + const message = error.message.trim(); + if (message) return message; + } + if (isRecord(error) && typeof error.message === "string") { + const message = error.message.trim(); + if (message) return message; + } + return fallback; +}; + +const resolveErrorCode = (error: unknown): string => { + if (!isRecord(error)) return ""; + const code = error.code; + if (typeof code !== "string") return ""; + return code.trim().toUpperCase(); +}; + +const isNativeAddonAbiMismatch = (error: unknown, message: string): boolean => { + const code = resolveErrorCode(error); + const normalized = message.toLowerCase(); + const hasNodeModuleVersionSignal = + normalized.includes("node_module_version") || + normalized.includes("compiled against a different node.js version"); + const hasBetterSqliteSignal = + normalized.includes("better_sqlite3.node") || normalized.includes("better-sqlite3"); + if (!hasNodeModuleVersionSignal || !hasBetterSqliteSignal) return false; + return code.length === 0 || code === "ERR_DLOPEN_FAILED"; +}; + +const isMissingBetterSqliteModule = (error: unknown, message: string): boolean => { + const code = resolveErrorCode(error); + const normalized = message.toLowerCase(); + if (!normalized.includes("better-sqlite3")) return false; + if (code === "MODULE_NOT_FOUND") return true; + return normalized.includes("cannot find module"); +}; + +export const classifyRuntimeInitError = (error: unknown): RuntimeInitFailure => { + const message = resolveErrorMessage(error, "controlplane_runtime_init_failed"); + if (isNativeAddonAbiMismatch(error, message)) { + return { + code: "NATIVE_MODULE_MISMATCH", + reason: "native_module_mismatch", + message, + remediation: { + summary: + "Native dependency binary does not match the current Node.js runtime ABI.", + commands: [...NATIVE_MISMATCH_COMMANDS], + }, + }; + } + if (isMissingBetterSqliteModule(error, message)) { + return { + code: "CONTROLPLANE_RUNTIME_INIT_FAILED", + reason: "runtime_init_failed", + message, + remediation: { + summary: "Native dependency module is missing from node_modules.", + commands: ["npm install", "npm rebuild better-sqlite3"], + }, + }; + } + return { + code: "CONTROLPLANE_RUNTIME_INIT_FAILED", + reason: "runtime_init_failed", + message, + }; +}; + +export const serializeRuntimeInitFailure = ( + failure: RuntimeInitFailure +): { + error: string; + code: RuntimeInitFailureCode; + reason: RuntimeInitFailureReason; + remediation?: RuntimeInitFailureRemediation; +} => ({ + error: failure.message, + code: failure.code, + reason: failure.reason, + ...(failure.remediation ? { remediation: failure.remediation } : {}), +}); diff --git a/src/lib/controlplane/runtime-route-bootstrap.ts b/src/lib/controlplane/runtime-route-bootstrap.ts new file mode 100644 index 0000000..efb3c70 --- /dev/null +++ b/src/lib/controlplane/runtime-route-bootstrap.ts @@ -0,0 +1,42 @@ +import { + getControlPlaneRuntime, + isStudioDomainApiModeEnabled, + type ControlPlaneRuntime, +} from "@/lib/controlplane/runtime"; +import { + classifyRuntimeInitError, + type RuntimeInitFailure, +} from "@/lib/controlplane/runtime-init-errors"; + +type DomainRuntimeBootstrapResult = + | { kind: "mode-disabled" } + | { kind: "runtime-init-failed"; failure: RuntimeInitFailure } + | { kind: "start-failed"; message: string; runtime: ControlPlaneRuntime } + | { kind: "ready"; runtime: ControlPlaneRuntime }; + +const resolveErrorMessage = (error: unknown, fallback: string): string => + error instanceof Error ? error.message : fallback; + +export async function bootstrapDomainRuntime(): Promise { + if (!isStudioDomainApiModeEnabled()) { + return { kind: "mode-disabled" }; + } + + let runtime: ControlPlaneRuntime; + try { + runtime = getControlPlaneRuntime(); + } catch (error) { + return { + kind: "runtime-init-failed", + failure: classifyRuntimeInitError(error), + }; + } + + try { + await runtime.ensureStarted(); + return { kind: "ready", runtime }; + } catch (error) { + const message = resolveErrorMessage(error, "controlplane_start_failed"); + return { kind: "start-failed", message, runtime }; + } +} diff --git a/src/lib/controlplane/runtime.ts b/src/lib/controlplane/runtime.ts index 7433ae0..837910b 100644 --- a/src/lib/controlplane/runtime.ts +++ b/src/lib/controlplane/runtime.ts @@ -4,7 +4,10 @@ import type { ControlPlaneRuntimeSnapshot, } from "@/lib/controlplane/contracts"; import { OpenClawGatewayAdapter, type OpenClawAdapterOptions } from "@/lib/controlplane/openclaw-adapter"; -import { SQLiteControlPlaneProjectionStore } from "@/lib/controlplane/projection-store"; +import { + SQLiteControlPlaneProjectionStore, + type BackfillAgentOutboxResult, +} from "@/lib/controlplane/projection-store"; const DOMAIN_MODE_FALSE_VALUES = new Set(["0", "false", "no", "off"]); @@ -20,7 +23,7 @@ const readDomainApiMode = (env: NodeJS.ProcessEnv = process.env): boolean => { return !DOMAIN_MODE_FALSE_VALUES.has(raw); }; -export type ControlPlaneRuntimeOptions = { +type ControlPlaneRuntimeOptions = { adapterOptions?: OpenClawAdapterOptions; dbPath?: string; }; @@ -38,10 +41,6 @@ export class ControlPlaneRuntime { }); } - isDomainApiModeEnabled(env: NodeJS.ProcessEnv = process.env): boolean { - return readDomainApiMode(env); - } - async ensureStarted(): Promise { await this.adapter.start(); } @@ -58,6 +57,18 @@ export class ControlPlaneRuntime { return this.store.readOutboxAfter(lastSeenId, limit); } + eventsBeforeForAgent( + agentId: string, + beforeOutboxId: number, + limit?: number + ): ControlPlaneOutboxEntry[] { + return this.store.readAgentOutboxBefore(agentId, beforeOutboxId, limit); + } + + backfillAgentHistoryIndex(beforeOutboxId: number, limit?: number): BackfillAgentOutboxResult { + return this.store.backfillAgentOutboxBefore(beforeOutboxId, limit); + } + subscribe(handler: (entry: ControlPlaneOutboxEntry) => void): () => void { this.eventSubscribers.add(handler); return () => { @@ -76,7 +87,11 @@ export class ControlPlaneRuntime { private handleDomainEvent(event: ControlPlaneDomainEvent): void { const entry = this.store.applyDomainEvent(event); for (const subscriber of this.eventSubscribers) { - subscriber(entry); + try { + subscriber(entry); + } catch (err) { + console.error("Control-plane event subscriber failed.", err); + } } } } diff --git a/src/lib/cron/types.ts b/src/lib/cron/types.ts index c797e36..1e651b8 100644 --- a/src/lib/cron/types.ts +++ b/src/lib/cron/types.ts @@ -8,7 +8,7 @@ export type CronSchedule = export type CronSessionTarget = "main" | "isolated"; export type CronWakeMode = "next-heartbeat" | "now"; -export type CronDeliveryMode = "none" | "announce"; +type CronDeliveryMode = "none" | "announce"; export type CronDelivery = { mode: CronDeliveryMode; channel?: string; @@ -57,7 +57,7 @@ export type CronJobSummary = { delivery?: CronDelivery; }; -export type CronJobsResult = { +type CronJobsResult = { jobs: CronJobSummary[]; }; @@ -130,7 +130,7 @@ export const formatCronJobDisplay = (job: CronJobSummary) => { return lines.join("\n"); }; -export type CronListParams = { +type CronListParams = { includeDisabled?: boolean; }; @@ -139,7 +139,7 @@ export type CronRunResult = | { ok: true; ran: false; reason: "not-due" } | { ok: false }; -export type CronRemoveResult = { ok: true; removed: boolean } | { ok: false; removed: false }; +type CronRemoveResult = { ok: true; removed: boolean } | { ok: false; removed: false }; export type CronJobRestoreInput = { name: string; diff --git a/src/lib/dom/index.ts b/src/lib/dom/index.ts index 8e45296..08f4131 100644 --- a/src/lib/dom/index.ts +++ b/src/lib/dom/index.ts @@ -1,4 +1,4 @@ -export type RafBatcher = { +type RafBatcher = { schedule: () => void; cancel: () => void; }; @@ -21,7 +21,7 @@ export const createRafBatcher = (flush: () => void): RafBatcher => { }; }; -export type ScrollMetrics = { +type ScrollMetrics = { scrollTop: number; scrollHeight: number; clientHeight: number; diff --git a/src/lib/gateway/GatewayClient.ts b/src/lib/gateway/GatewayClient.ts index 557617b..f086048 100644 --- a/src/lib/gateway/GatewayClient.ts +++ b/src/lib/gateway/GatewayClient.ts @@ -15,14 +15,14 @@ import { resolveStudioProxyGatewayUrl } from "@/lib/gateway/proxy-url"; import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode"; import { GatewayResponseError } from "@/lib/gateway/errors"; -export type ReqFrame = { +type ReqFrame = { type: "req"; id: string; method: string; params: unknown; }; -export type ResFrame = { +type ResFrame = { type: "res"; id: string; ok: boolean; @@ -49,7 +49,7 @@ export type EventFrame = { stateVersion?: GatewayStateVersion; }; -export type GatewayFrame = ReqFrame | ResFrame | EventFrame; +type GatewayFrame = ReqFrame | ResFrame | EventFrame; export const parseGatewayFrame = (raw: string): GatewayFrame | null => { try { @@ -114,7 +114,7 @@ type GapHandler = (info: GatewayGapInfo) => void; export type GatewayStatus = "disconnected" | "connecting" | "connected"; -export type GatewayConnectOptions = { +type GatewayConnectOptions = { gatewayUrl: string; token?: string; authScopeKey?: string; @@ -123,7 +123,7 @@ export type GatewayConnectOptions = { }; export { GatewayResponseError } from "@/lib/gateway/errors"; -export type { GatewayErrorPayload } from "@/lib/gateway/errors"; +; export class GatewayClient { private client: GatewayBrowserClient | null = null; @@ -333,7 +333,7 @@ export type GatewaySessionsPatchResult = { }; }; -export type SyncGatewaySessionSettingsParams = { +type SyncGatewaySessionSettingsParams = { client: GatewayClient; sessionKey: string; model?: string | null; @@ -405,7 +405,7 @@ const formatGatewayError = (error: unknown) => { return "Unknown gateway error."; }; -export type GatewayConnectionState = { +type GatewayConnectionState = { client: GatewayClient; status: GatewayStatus; gatewayUrl: string; @@ -598,15 +598,23 @@ export const useGatewayConnection = ( }, [client, gatewayUrl, settingsCoordinator, token]); useEffect(() => { + if (domainApiModeEnabled === true) return; if (didAutoConnect.current) return; if (!settingsLoaded) return; if (!gatewayUrl.trim()) return; didAutoConnect.current = true; void connect(); - }, [connect, gatewayUrl, settingsLoaded]); + }, [connect, domainApiModeEnabled, gatewayUrl, settingsLoaded]); // Auto-retry on disconnect (gateway busy, network blip, etc.) useEffect(() => { + if (domainApiModeEnabled === true) { + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + return; + } const attempt = retryAttemptRef.current; const delay = resolveGatewayAutoRetryDelayMs({ status, @@ -629,7 +637,22 @@ export const useGatewayConnection = ( retryTimerRef.current = null; } }; - }, [connect, connectErrorCode, error, gatewayUrl, status]); + }, [connect, connectErrorCode, domainApiModeEnabled, error, gatewayUrl, status]); + + useEffect(() => { + if (domainApiModeEnabled !== true) return; + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + retryAttemptRef.current = 0; + didAutoConnect.current = false; + setError(null); + setConnectErrorCode(null); + if (status === "disconnected") return; + wasManualDisconnectRef.current = true; + client.disconnect(); + }, [client, domainApiModeEnabled, status]); // Reset retry count on successful connection useEffect(() => { diff --git a/src/lib/gateway/agentConfig.ts b/src/lib/gateway/agentConfig.ts index a56eabb..eaba566 100644 --- a/src/lib/gateway/agentConfig.ts +++ b/src/lib/gateway/agentConfig.ts @@ -1,11 +1,11 @@ import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient"; -export type AgentHeartbeatActiveHours = { +type AgentHeartbeatActiveHours = { start: string; end: string; }; -export type AgentHeartbeat = { +type AgentHeartbeat = { every: string; target: string; includeReasoning: boolean; @@ -13,17 +13,17 @@ export type AgentHeartbeat = { activeHours?: AgentHeartbeatActiveHours | null; }; -export type AgentHeartbeatResult = { +type AgentHeartbeatResult = { heartbeat: AgentHeartbeat; hasOverride: boolean; }; -export type AgentHeartbeatUpdatePayload = { +type AgentHeartbeatUpdatePayload = { override: boolean; heartbeat: AgentHeartbeat; }; -export type AgentHeartbeatSummary = { +type AgentHeartbeatSummary = { id: string; agentId: string; source: "override" | "default"; @@ -31,13 +31,13 @@ export type AgentHeartbeatSummary = { heartbeat: AgentHeartbeat; }; -export type HeartbeatListResult = { +type HeartbeatListResult = { heartbeats: AgentHeartbeatSummary[]; }; -export type HeartbeatWakeResult = { ok: true } | { ok: false }; +type HeartbeatWakeResult = { ok: true } | { ok: false }; -export type GatewayConfigSnapshot = { +type GatewayConfigSnapshot = { config?: Record; hash?: string; exists?: boolean; @@ -55,12 +55,12 @@ const isRecord = (value: unknown): value is Record => export type ConfigAgentEntry = Record & { id: string }; -export type GatewayAgentSandboxOverrides = { +type GatewayAgentSandboxOverrides = { mode?: "off" | "non-main" | "all"; workspaceAccess?: "none" | "ro" | "rw"; }; -export type GatewayAgentToolsOverrides = { +type GatewayAgentToolsOverrides = { profile?: "minimal" | "coding" | "messaging" | "full"; allow?: string[]; alsoAllow?: string[]; @@ -73,7 +73,7 @@ export type GatewayAgentToolsOverrides = { }; }; -export type GatewayAgentOverrides = { +type GatewayAgentOverrides = { sandbox?: GatewayAgentSandboxOverrides; tools?: GatewayAgentToolsOverrides; }; @@ -485,7 +485,7 @@ export const removeGatewayHeartbeatOverride = async (params: { return resolveHeartbeatSettings(nextConfig, params.agentId); }; -export type AgentSkillsAccessMode = "all" | "none" | "allowlist"; +type AgentSkillsAccessMode = "all" | "none" | "allowlist"; const resolveRequiredAgentId = (agentId: string): string => { const trimmed = agentId.trim(); diff --git a/src/lib/gateway/errors.ts b/src/lib/gateway/errors.ts index ebe1627..362d66b 100644 --- a/src/lib/gateway/errors.ts +++ b/src/lib/gateway/errors.ts @@ -1,4 +1,4 @@ -export type GatewayErrorPayload = { +type GatewayErrorPayload = { code: string; message: string; details?: unknown; diff --git a/src/lib/gateway/execApprovals.ts b/src/lib/gateway/execApprovals.ts index 15dc86c..aadb147 100644 --- a/src/lib/gateway/execApprovals.ts +++ b/src/lib/gateway/execApprovals.ts @@ -1,7 +1,7 @@ import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient"; -export type GatewayExecApprovalSecurity = "deny" | "allowlist" | "full"; -export type GatewayExecApprovalAsk = "off" | "on-miss" | "always"; +type GatewayExecApprovalSecurity = "deny" | "allowlist" | "full"; +type GatewayExecApprovalAsk = "off" | "on-miss" | "always"; type ExecAllowlistEntry = { id?: string; diff --git a/src/lib/gateway/openclaw/GatewayBrowserClient.ts b/src/lib/gateway/openclaw/GatewayBrowserClient.ts index 7965d27..234b6a3 100644 --- a/src/lib/gateway/openclaw/GatewayBrowserClient.ts +++ b/src/lib/gateway/openclaw/GatewayBrowserClient.ts @@ -317,7 +317,7 @@ async function signDevicePayload(privateKeyBase64Url: string, payload: string) { return base64UrlEncode(sig); } -export type GatewayEventFrame = { +type GatewayEventFrame = { type: "event"; event: string; payload?: unknown; @@ -325,7 +325,7 @@ export type GatewayEventFrame = { stateVersion?: { presence: number; health: number }; }; -export type GatewayResponseFrame = { +type GatewayResponseFrame = { type: "res"; id: string; ok: boolean; @@ -352,7 +352,7 @@ type Pending = { reject: (err: unknown) => void; }; -export type GatewayBrowserClientOptions = { +type GatewayBrowserClientOptions = { url: string; token?: string; password?: string; diff --git a/src/lib/skills/presentation.ts b/src/lib/skills/presentation.ts index 3b9ddb1..5a47ac9 100644 --- a/src/lib/skills/presentation.ts +++ b/src/lib/skills/presentation.ts @@ -4,9 +4,9 @@ import type { SkillStatusEntry, } from "@/lib/skills/types"; -export type SkillSourceGroupId = "workspace" | "built-in" | "installed" | "extra" | "other"; +type SkillSourceGroupId = "workspace" | "built-in" | "installed" | "extra" | "other"; -export type SkillSourceGroup = { +type SkillSourceGroup = { id: SkillSourceGroupId; label: string; skills: SkillStatusEntry[]; @@ -20,7 +20,7 @@ export type SkillReadinessState = export type AgentSkillDisplayState = "ready" | "setup-required" | "not-supported"; -export type AgentSkillsAccessMode = "all" | "none" | "selected"; +type AgentSkillsAccessMode = "all" | "none" | "selected"; const GROUP_DEFINITIONS: Array<{ id: Exclude; label: string }> = [ { id: "workspace", label: "Workspace Skills" }, diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts index 491e7a4..5d6cab6 100644 --- a/src/lib/skills/types.ts +++ b/src/lib/skills/types.ts @@ -49,13 +49,13 @@ export type SkillStatusReport = { skills: SkillStatusEntry[]; }; -export type SkillInstallRequest = { +type SkillInstallRequest = { name: string; installId: string; timeoutMs?: number; }; -export type SkillInstallResult = { +type SkillInstallResult = { ok: boolean; message: string; stdout: string; @@ -64,13 +64,13 @@ export type SkillInstallResult = { warnings?: string[]; }; -export type SkillUpdateRequest = { +type SkillUpdateRequest = { skillKey: string; enabled?: boolean; apiKey?: string; }; -export type SkillUpdateResult = { +type SkillUpdateResult = { ok: boolean; skillKey: string; config: Record; diff --git a/src/lib/ssh/agent-state.ts b/src/lib/ssh/agent-state.ts index bc39f66..ee9ae16 100644 --- a/src/lib/ssh/agent-state.ts +++ b/src/lib/ssh/agent-state.ts @@ -1,13 +1,13 @@ import { runSshJson } from "@/lib/ssh/gateway-host"; -export type GatewayAgentStateMove = { from: string; to: string }; +type GatewayAgentStateMove = { from: string; to: string }; -export type TrashAgentStateResult = { +type TrashAgentStateResult = { trashDir: string; moved: GatewayAgentStateMove[]; }; -export type RestoreAgentStateResult = { +type RestoreAgentStateResult = { restored: GatewayAgentStateMove[]; }; diff --git a/src/lib/studio/coordinator.ts b/src/lib/studio/coordinator.ts index e16978b..6a95abf 100644 --- a/src/lib/studio/coordinator.ts +++ b/src/lib/studio/coordinator.ts @@ -15,7 +15,7 @@ export type StudioSettingsResponse = { type FocusedPatch = Record | null>; type AvatarsPatch = Record | null>; -export type StudioSettingsCoordinatorTransport = { +type StudioSettingsCoordinatorTransport = { fetchSettings: () => Promise; updateSettings: (patch: StudioSettingsPatch) => Promise; }; diff --git a/src/lib/text/message-extract.ts b/src/lib/text/message-extract.ts index 0d6923b..e5de616 100644 --- a/src/lib/text/message-extract.ts +++ b/src/lib/text/message-extract.ts @@ -30,7 +30,7 @@ const TOOL_CALL_PREFIX = "[[tool]]"; const TOOL_RESULT_PREFIX = "[[tool-result]]"; const META_PREFIX = "[[meta]]"; -export type AgentInstructionParams = { +type AgentInstructionParams = { message: string; }; diff --git a/tests/e2e/agent-avatar.spec.ts b/tests/e2e/agent-avatar.spec.ts index bf7064e..9366a56 100644 --- a/tests/e2e/agent-avatar.spec.ts +++ b/tests/e2e/agent-avatar.spec.ts @@ -1,34 +1,17 @@ import { expect, test } from "@playwright/test"; +import { stubStudioRoute } from "./helpers/studioRoute"; +import { stubRuntimeRoutes } from "./helpers/runtimeRoute"; test.beforeEach(async ({ page }) => { - await page.route("**/api/studio", async (route, request) => { - if (request.method() === "PUT") { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - settings: { version: 1, gateway: null, focused: {}, avatars: {} }, - }), - }); - return; - } - if (request.method() !== "GET") { - await route.fallback(); - return; - } - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - settings: { version: 1, gateway: null, focused: {}, avatars: {} }, - }), - }); - }); + await stubStudioRoute(page); + await stubRuntimeRoutes(page); }); test("empty focused view shows zero agents when disconnected", async ({ page }) => { await page.goto("/"); + await expect(page.getByText("No agents available.").first()).toBeVisible(); await expect(page.getByTestId("studio-menu-toggle")).toBeVisible(); - await expect(page.getByRole("button", { name: "Connect" }).first()).toBeVisible(); + await page.getByTestId("studio-menu-toggle").click(); + await expect(page.getByTestId("gateway-settings-toggle")).toBeVisible(); }); diff --git a/tests/e2e/agent-ia-split.spec.ts b/tests/e2e/agent-ia-split.spec.ts index c6d0eec..47ed8cd 100644 --- a/tests/e2e/agent-ia-split.spec.ts +++ b/tests/e2e/agent-ia-split.spec.ts @@ -1,29 +1,10 @@ import { expect, test } from "@playwright/test"; +import { stubStudioRoute } from "./helpers/studioRoute"; +import { stubRuntimeRoutes } from "./helpers/runtimeRoute"; test.beforeEach(async ({ page }) => { - await page.route("**/api/studio", async (route, request) => { - if (request.method() === "PUT") { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - settings: { version: 1, gateway: null, focused: {}, avatars: {} }, - }), - }); - return; - } - if (request.method() !== "GET") { - await route.fallback(); - return; - } - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - settings: { version: 1, gateway: null, focused: {}, avatars: {} }, - }), - }); - }); + await stubStudioRoute(page); + await stubRuntimeRoutes(page); }); test("shows_connection_settings_control_in_header", async ({ page }) => { diff --git a/tests/e2e/agent-inspect-panel.spec.ts b/tests/e2e/agent-inspect-panel.spec.ts index 63a9a5f..7c1e62d 100644 --- a/tests/e2e/agent-inspect-panel.spec.ts +++ b/tests/e2e/agent-inspect-panel.spec.ts @@ -1,8 +1,10 @@ import { expect, test } from "@playwright/test"; import { stubStudioRoute } from "./helpers/studioRoute"; +import { stubRuntimeRoutes } from "./helpers/runtimeRoute"; test.beforeEach(async ({ page }) => { await stubStudioRoute(page); + await stubRuntimeRoutes(page); }); test("connection panel reflects disconnected state", async ({ page }) => { @@ -10,7 +12,7 @@ test("connection panel reflects disconnected state", async ({ page }) => { await page.getByTestId("studio-menu-toggle").click(); await page.getByTestId("gateway-settings-toggle").click(); - await expect(page.getByLabel("Upstream URL")).toBeVisible(); + await expect(page.getByLabel(/Upstream (gateway )?URL/i)).toBeVisible(); await expect( page.getByRole("button", { name: /^(Connect|Disconnect)$/ }) ).toBeVisible(); diff --git a/tests/e2e/connection-settings.spec.ts b/tests/e2e/connection-settings.spec.ts index 248ead3..eff1374 100644 --- a/tests/e2e/connection-settings.spec.ts +++ b/tests/e2e/connection-settings.spec.ts @@ -1,15 +1,17 @@ import { expect, test } from "@playwright/test"; import { stubStudioRoute } from "./helpers/studioRoute"; +import { stubRuntimeRoutes } from "./helpers/runtimeRoute"; test("connection settings persist to the studio settings API", async ({ page }) => { await stubStudioRoute(page); + await stubRuntimeRoutes(page); await page.goto("/"); await page.getByTestId("studio-menu-toggle").click(); await page.getByTestId("gateway-settings-toggle").click(); - await expect(page.getByLabel("Upstream URL")).toBeVisible(); + await expect(page.getByLabel(/Upstream (gateway )?URL/i)).toBeVisible(); - await page.getByLabel("Upstream URL").fill("ws://gateway.example:18789"); + await page.getByLabel(/Upstream (gateway )?URL/i).fill("ws://gateway.example:18789"); await page.getByLabel("Upstream token").fill("token-123"); const request = await page.waitForRequest((req) => { diff --git a/tests/e2e/fleet-sidebar.spec.ts b/tests/e2e/fleet-sidebar.spec.ts index 189972a..526733d 100644 --- a/tests/e2e/fleet-sidebar.spec.ts +++ b/tests/e2e/fleet-sidebar.spec.ts @@ -1,21 +1,27 @@ import { expect, test } from "@playwright/test"; import { stubStudioRoute } from "./helpers/studioRoute"; +import { stubRuntimeRoutes } from "./helpers/runtimeRoute"; test.beforeEach(async ({ page }) => { await stubStudioRoute(page); + await stubRuntimeRoutes(page); }); test("shows_disconnected_connect_surface", async ({ page }) => { await page.goto("/"); - await expect(page.getByLabel("Upstream URL")).toBeVisible(); - await expect(page.getByRole("button", { name: /^(Connect|Connecting…)$/ })).toBeVisible(); + await page.getByTestId("studio-menu-toggle").click(); + await page.getByTestId("gateway-settings-toggle").click(); + await expect(page.getByLabel(/Upstream (gateway )?URL/i)).toBeVisible(); + await expect(page.getByRole("button", { name: /^(Connect|Disconnect|Connecting…)$/ })).toBeVisible(); }); test("persists_gateway_fields_to_studio_settings", async ({ page }) => { await page.goto("/"); - await page.getByLabel("Upstream URL").fill("ws://gateway.example:18789"); + await page.getByTestId("studio-menu-toggle").click(); + await page.getByTestId("gateway-settings-toggle").click(); + await page.getByLabel(/Upstream (gateway )?URL/i).fill("ws://gateway.example:18789"); await page.getByLabel("Upstream token").fill("token-123"); const request = await page.waitForRequest((req) => { diff --git a/tests/e2e/focused-smoke.spec.ts b/tests/e2e/focused-smoke.spec.ts index 17f0e7f..b225498 100644 --- a/tests/e2e/focused-smoke.spec.ts +++ b/tests/e2e/focused-smoke.spec.ts @@ -1,32 +1,14 @@ import { expect, test } from "@playwright/test"; +import { stubStudioRoute } from "./helpers/studioRoute"; +import { stubRuntimeRoutes } from "./helpers/runtimeRoute"; test("loads focused studio empty state", async ({ page }) => { - await page.route("**/api/studio", async (route, request) => { - if (request.method() === "PUT") { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - settings: { version: 1, gateway: null, focused: {}, avatars: {} }, - }), - }); - return; - } - if (request.method() !== "GET") { - await route.fallback(); - return; - } - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - settings: { version: 1, gateway: null, focused: {}, avatars: {} }, - }), - }); - }); + await stubStudioRoute(page); + await stubRuntimeRoutes(page); await page.goto("/"); await expect(page.getByTestId("studio-menu-toggle")).toBeVisible(); - await expect(page.getByRole("button", { name: "Connect" }).first()).toBeVisible(); + await page.getByTestId("studio-menu-toggle").click(); + await expect(page.getByTestId("gateway-settings-toggle")).toBeVisible(); }); diff --git a/tests/e2e/helpers/runtimeRoute.ts b/tests/e2e/helpers/runtimeRoute.ts new file mode 100644 index 0000000..2177ac4 --- /dev/null +++ b/tests/e2e/helpers/runtimeRoute.ts @@ -0,0 +1,163 @@ +import type { Page } from "@playwright/test"; + +type RuntimeRouteFixture = { + fleetResult?: { + seeds: Array<{ + agentId: string; + name: string; + model: string; + modelProvider: string; + mode: "focused"; + status: "idle" | "running"; + sessionKey: string | null; + sessionCreated: boolean; + sessionSettingsSynced: boolean; + busy: boolean; + latestUpdateAt: string | null; + latestUpdate: string; + latestTranscriptLine: string; + queuedMessageCount: number; + latestDoneReason: string | null; + avatarSeed: string; + customInstructions: string; + supportsImageInput: boolean; + supportsStreaming: boolean; + supportsSkillSetup: boolean; + toolCallingEnabled: boolean; + showThinkingTraces: boolean; + thinkingLevel: string | null; + runStartedAt: string | null; + runningSince: string | null; + updatedAt: string | null; + awaitingUserInput: boolean; + cronScheduleText: string | null; + cronNextRunAt: string | null; + cronEnabled: boolean; + cronJobId: string | null; + modelMenuAvailable: boolean; + supportsModelPicker: boolean; + supportsSessionSettings: boolean; + permissions: { + securityLevel: "deny" | "allowlist" | "full"; + askForApprovals: "off" | "on-miss" | "always"; + defaultMode: "deny" | "allowlist" | "full"; + defaultAskForApprovals: "off" | "on-miss" | "always"; + }; + historyLoadedAt: number | null; + historyFetchLimit: number | null; + historyFetchedCount: number | null; + historyMaybeTruncated: boolean; + historyStaleAt: number | null; + detailLastLoadedAt: number | null; + historyError: string | null; + sessionLabel: string | null; + statusHint: string | null; + canPauseForApproval: boolean; + pausedForApproval: boolean; + pausedRunId: string | null; + pendingExecApprovalsCount: number; + pendingExecApprovalsReady: boolean; + approvalsCoverage: "unknown" | "covered" | "uncovered"; + meta: { + createdAt: string | null; + updatedAt: string | null; + }; + }>; + sessionCreatedAgentIds: string[]; + sessionSettingsSyncedAgentIds: string[]; + summaryPatches: Array<{ agentId: string; patch: Record }>; + suggestedSelectedAgentId: string | null; + configSnapshot: Record | null; + }; +}; + +const DEFAULT_FLEET_RESULT: RuntimeRouteFixture["fleetResult"] = { + seeds: [], + sessionCreatedAgentIds: [], + sessionSettingsSyncedAgentIds: [], + summaryPatches: [], + suggestedSelectedAgentId: null, + configSnapshot: null, +}; + +export const stubRuntimeRoutes = async (page: Page, fixture: RuntimeRouteFixture = {}) => { + await page.route("**/api/runtime/fleet", async (route, request) => { + if (request.method() !== "POST") { + await route.fallback(); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + enabled: true, + result: fixture.fleetResult ?? DEFAULT_FLEET_RESULT, + }), + }); + }); + + await page.route("**/api/runtime/summary", async (route, request) => { + if (request.method() !== "GET") { + await route.fallback(); + return; + } + const asOf = new Date().toISOString(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + enabled: true, + summary: { + status: "connected", + reason: null, + asOf, + outboxHead: 0, + }, + freshness: { + source: "gateway", + stale: false, + asOf, + }, + }), + }); + }); + + await page.route("**/api/runtime/agents/*/history*", async (route, request) => { + if (request.method() !== "GET") { + await route.fallback(); + return; + } + const asOf = new Date().toISOString(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + enabled: true, + entries: [], + hasMore: false, + nextBeforeOutboxId: null, + freshness: { + source: "gateway", + stale: false, + asOf, + }, + }), + }); + }); + + await page.route("**/api/runtime/stream", async (route, request) => { + if (request.method() !== "GET") { + await route.fallback(); + return; + } + await route.fulfill({ + status: 200, + contentType: "text/event-stream; charset=utf-8", + body: ": heartbeat\n\n", + headers: { + "cache-control": "no-cache", + connection: "keep-alive", + }, + }); + }); +}; diff --git a/tests/e2e/helpers/studioRoute.ts b/tests/e2e/helpers/studioRoute.ts index 4dc0a00..e38eac2 100644 --- a/tests/e2e/helpers/studioRoute.ts +++ b/tests/e2e/helpers/studioRoute.ts @@ -1,12 +1,17 @@ import type { Page, Route, Request } from "@playwright/test"; -export type StudioSettingsFixture = { +type StudioSettingsFixture = { version: 1; gateway: { url: string; token: string } | null; focused: Record; avatars: Record>; }; +type StudioRouteEnvelopeFixture = { + localGatewayDefaults?: { url: string; token: string } | null; + domainApiModeEnabled?: boolean; +}; + const DEFAULT_SETTINGS: StudioSettingsFixture = { version: 1, gateway: null, @@ -14,20 +19,28 @@ const DEFAULT_SETTINGS: StudioSettingsFixture = { avatars: {}, }; -const createStudioRoute = (initial: StudioSettingsFixture = DEFAULT_SETTINGS) => { +const createStudioRoute = ( + initial: StudioSettingsFixture = DEFAULT_SETTINGS, + envelope: StudioRouteEnvelopeFixture = {} +) => { let settings: StudioSettingsFixture = { version: 1, gateway: initial.gateway ?? null, focused: { ...(initial.focused ?? {}) }, avatars: { ...(initial.avatars ?? {}) }, }; + const responseEnvelope = () => ({ + settings, + localGatewayDefaults: envelope.localGatewayDefaults ?? null, + domainApiModeEnabled: envelope.domainApiModeEnabled ?? true, + }); return async (route: Route, request: Request) => { if (request.method() === "GET") { await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ settings }), + body: JSON.stringify(responseEnvelope()), }); return; } @@ -94,14 +107,15 @@ const createStudioRoute = (initial: StudioSettingsFixture = DEFAULT_SETTINGS) => await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ settings }), + body: JSON.stringify(responseEnvelope()), }); }; }; export const stubStudioRoute = async ( page: Page, - initial: StudioSettingsFixture = DEFAULT_SETTINGS + initial: StudioSettingsFixture = DEFAULT_SETTINGS, + envelope?: StudioRouteEnvelopeFixture ) => { - await page.route("**/api/studio", createStudioRoute(initial)); + await page.route("**/api/studio", createStudioRoute(initial, envelope)); }; diff --git a/tests/e2e/invalid-route-redirect.spec.ts b/tests/e2e/invalid-route-redirect.spec.ts index c79e946..236f4ea 100644 --- a/tests/e2e/invalid-route-redirect.spec.ts +++ b/tests/e2e/invalid-route-redirect.spec.ts @@ -1,8 +1,10 @@ import { expect, test } from "@playwright/test"; import { stubStudioRoute } from "./helpers/studioRoute"; +import { stubRuntimeRoutes } from "./helpers/runtimeRoute"; test.beforeEach(async ({ page }) => { await stubStudioRoute(page); + await stubRuntimeRoutes(page); }); test("redirects unknown app routes to root", async ({ page }) => { diff --git a/tests/e2e/settings-route-disconnected.spec.ts b/tests/e2e/settings-route-disconnected.spec.ts index 66cd2f3..523622e 100644 --- a/tests/e2e/settings-route-disconnected.spec.ts +++ b/tests/e2e/settings-route-disconnected.spec.ts @@ -1,20 +1,22 @@ import { expect, test } from "@playwright/test"; import { stubStudioRoute } from "./helpers/studioRoute"; +import { stubRuntimeRoutes } from "./helpers/runtimeRoute"; test.beforeEach(async ({ page }) => { await stubStudioRoute(page); + await stubRuntimeRoutes(page); }); test("settings route shows connect UI while disconnected and can return to chat", async ({ page }) => { await page.goto("/agents/main/settings"); - await expect(page.getByRole("button", { name: "Back to chat" })).toBeVisible(); - await expect(page.getByLabel("Upstream URL")).toBeVisible(); - - await page.getByRole("button", { name: "Back to chat" }).click(); await expect .poll(() => new URL(page.url()).pathname, { - message: "Expected back button to return to chat route.", + message: "Expected settings route without agents to resolve to chat route.", }) .toBe("/"); + + await page.getByTestId("studio-menu-toggle").click(); + await page.getByTestId("gateway-settings-toggle").click(); + await expect(page.getByLabel(/Upstream (gateway )?URL/i)).toBeVisible(); }); diff --git a/tests/unit/agentPermissionsRoleHelpers.test.ts b/tests/unit/agentPermissionsRoleHelpers.test.ts index b6c1c65..64b3ed4 100644 --- a/tests/unit/agentPermissionsRoleHelpers.test.ts +++ b/tests/unit/agentPermissionsRoleHelpers.test.ts @@ -1,62 +1,29 @@ import { describe, expect, it } from "vitest"; import { - resolveExecApprovalsPolicyForRole, - resolveRuntimeToolOverridesForRole, resolveSessionExecSettingsForRole, + resolveToolGroupOverrides, } from "@/features/agents/operations/agentPermissionsOperation"; describe("permissions role helpers", () => { - it("maps roles to exec approvals policy while preserving allowlist", () => { - const allowlist = [{ pattern: "a" }, { pattern: "b" }]; - - expect(resolveExecApprovalsPolicyForRole({ role: "conservative", allowlist })).toBeNull(); - - const collaborative = resolveExecApprovalsPolicyForRole({ - role: "collaborative", - allowlist, - }); - expect(collaborative).toEqual({ - security: "allowlist", - ask: "always", - allowlist, - }); - expect(collaborative?.allowlist).toBe(allowlist); - - const autonomous = resolveExecApprovalsPolicyForRole({ - role: "autonomous", - allowlist, - }); - expect(autonomous).toEqual({ - security: "full", - ask: "off", - allowlist, - }); - expect(autonomous?.allowlist).toBe(allowlist); - }); - it("updates tool overrides using allow when existing tools.allow is present", () => { const existingTools = { allow: ["group:web"], deny: ["group:runtime"] }; - const collaborative = resolveRuntimeToolOverridesForRole({ - role: "collaborative", + const collaborative = resolveToolGroupOverrides({ existingTools, + runtimeEnabled: true, + webEnabled: true, + fsEnabled: false, }); expect(collaborative.tools.allow).toEqual(expect.arrayContaining(["group:web", "group:runtime"])); expect(collaborative.tools).not.toHaveProperty("alsoAllow"); expect(collaborative.tools.deny).not.toEqual(expect.arrayContaining(["group:runtime"])); - const autonomous = resolveRuntimeToolOverridesForRole({ - role: "autonomous", - existingTools, - }); - expect(autonomous.tools.allow).toEqual(expect.arrayContaining(["group:web", "group:runtime"])); - expect(autonomous.tools).not.toHaveProperty("alsoAllow"); - expect(autonomous.tools.deny).not.toEqual(expect.arrayContaining(["group:runtime"])); - - const conservative = resolveRuntimeToolOverridesForRole({ - role: "conservative", + const conservative = resolveToolGroupOverrides({ existingTools, + runtimeEnabled: false, + webEnabled: true, + fsEnabled: false, }); expect(conservative.tools.allow).toEqual(expect.arrayContaining(["group:web"])); expect(conservative.tools.allow).not.toEqual(expect.arrayContaining(["group:runtime"])); @@ -66,16 +33,20 @@ describe("permissions role helpers", () => { it("updates tool overrides using alsoAllow when tools.allow is absent", () => { const existingTools = { alsoAllow: ["group:web"], deny: [] as string[] }; - const collaborative = resolveRuntimeToolOverridesForRole({ - role: "collaborative", + const collaborative = resolveToolGroupOverrides({ existingTools, + runtimeEnabled: true, + webEnabled: true, + fsEnabled: false, }); expect(collaborative.tools.alsoAllow).toEqual(expect.arrayContaining(["group:web", "group:runtime"])); expect(collaborative.tools).not.toHaveProperty("allow"); - const conservative = resolveRuntimeToolOverridesForRole({ - role: "conservative", + const conservative = resolveToolGroupOverrides({ existingTools, + runtimeEnabled: false, + webEnabled: true, + fsEnabled: false, }); expect(conservative.tools.alsoAllow).toEqual(expect.arrayContaining(["group:web"])); expect(conservative.tools.alsoAllow).not.toEqual(expect.arrayContaining(["group:runtime"])); @@ -105,9 +76,11 @@ describe("permissions role helpers", () => { }); it("treats missing tools config as empty lists and still enforces group:runtime semantics", () => { - const collaborative = resolveRuntimeToolOverridesForRole({ - role: "collaborative", + const collaborative = resolveToolGroupOverrides({ existingTools: null, + runtimeEnabled: true, + webEnabled: false, + fsEnabled: false, }); expect(collaborative.tools.alsoAllow).toEqual(expect.arrayContaining(["group:runtime"])); expect(collaborative.tools).not.toHaveProperty("allow"); diff --git a/tests/unit/controlPlaneExecApprovals.test.ts b/tests/unit/controlPlaneExecApprovals.test.ts new file mode 100644 index 0000000..bdfa223 --- /dev/null +++ b/tests/unit/controlPlaneExecApprovals.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from "vitest"; + +import { upsertAgentExecApprovalsPolicyViaRuntime } from "@/lib/controlplane/exec-approvals"; +import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter"; +import type { ControlPlaneRuntime } from "@/lib/controlplane/runtime"; + +describe("control-plane exec approvals policy upsert", () => { + it("rebuilds retry payload from latest snapshot on stale base-hash conflicts", async () => { + let getCount = 0; + let setCount = 0; + + const runtime = { + callGateway: vi.fn(async (method: string, params: unknown) => { + if (method === "exec.approvals.get") { + getCount += 1; + if (getCount === 1) { + return { + path: "/tmp/approvals.json", + exists: true, + hash: "hash-1", + file: { + version: 1, + agents: { + "agent-1": { + security: "allowlist", + ask: "always", + allowlist: [{ pattern: "/bin/old" }], + }, + "agent-2": { + security: "allowlist", + ask: "always", + allowlist: [{ pattern: "/bin/shared" }], + }, + }, + }, + }; + } + return { + path: "/tmp/approvals.json", + exists: true, + hash: "hash-2", + file: { + version: 1, + agents: { + "agent-1": { + security: "allowlist", + ask: "always", + allowlist: [{ pattern: "/bin/new" }], + }, + "agent-2": { + security: "full", + ask: "off", + allowlist: [{ pattern: "/bin/shared" }, { pattern: "/bin/extra" }], + }, + "agent-3": { + security: "allowlist", + ask: "always", + allowlist: [{ pattern: "/bin/third" }], + }, + }, + }, + }; + } + + if (method === "exec.approvals.set") { + setCount += 1; + const payload = params as { + baseHash?: string; + file?: { agents?: Record }; + }; + if (setCount === 1) { + expect(payload.baseHash).toBe("hash-1"); + throw new ControlPlaneGatewayError({ + code: "INVALID_REQUEST", + message: "exec approvals changed since last load; re-run exec.approvals.get and retry", + }); + } + expect(payload.baseHash).toBe("hash-2"); + expect(payload.file?.agents?.["agent-1"]).toBeUndefined(); + expect(payload.file?.agents?.["agent-2"]).toEqual({ + security: "full", + ask: "off", + allowlist: [{ pattern: "/bin/shared" }, { pattern: "/bin/extra" }], + }); + expect(payload.file?.agents?.["agent-3"]).toEqual({ + security: "allowlist", + ask: "always", + allowlist: [{ pattern: "/bin/third" }], + }); + return { ok: true }; + } + + throw new Error(`unexpected method: ${method}`); + }), + } as unknown as ControlPlaneRuntime; + + await upsertAgentExecApprovalsPolicyViaRuntime({ + runtime, + agentId: "agent-1", + role: "conservative", + }); + + expect(setCount).toBe(2); + }); + + it("retries for reload-and-retry INVALID_REQUEST messages from openclaw node host", async () => { + let setCount = 0; + const runtime = { + callGateway: vi.fn(async (method: string, params: unknown) => { + if (method === "exec.approvals.get") { + return { + path: "/tmp/approvals.json", + exists: true, + hash: setCount === 0 ? "hash-1" : "hash-2", + file: { + version: 1, + agents: { + "agent-1": { + security: "allowlist", + ask: "always", + allowlist: [{ pattern: "/bin/tool" }], + }, + }, + }, + }; + } + if (method === "exec.approvals.set") { + setCount += 1; + const payload = params as { baseHash?: string }; + if (setCount === 1) { + expect(payload.baseHash).toBe("hash-1"); + throw new ControlPlaneGatewayError({ + code: "INVALID_REQUEST", + message: "INVALID_REQUEST: exec approvals base hash required; reload and retry", + }); + } + expect(payload.baseHash).toBe("hash-2"); + return { ok: true }; + } + throw new Error(`unexpected method: ${method}`); + }), + } as unknown as ControlPlaneRuntime; + + await upsertAgentExecApprovalsPolicyViaRuntime({ + runtime, + agentId: "agent-1", + role: "autonomous", + }); + + expect(setCount).toBe(2); + }); +}); diff --git a/tests/unit/controlPlaneRuntime.test.ts b/tests/unit/controlPlaneRuntime.test.ts index 1680179..8a50ebe 100644 --- a/tests/unit/controlPlaneRuntime.test.ts +++ b/tests/unit/controlPlaneRuntime.test.ts @@ -142,6 +142,7 @@ describe("control-plane runtime", () => { }); it("parses STUDIO_DOMAIN_API_MODE values", () => { + delete process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE; process.env.STUDIO_DOMAIN_API_MODE = "true"; expect(isStudioDomainApiModeEnabled()).toBe(true); process.env.STUDIO_DOMAIN_API_MODE = "1"; diff --git a/tests/unit/execApprovalResolveOperation.test.ts b/tests/unit/execApprovalResolveOperation.test.ts index e4ad8e7..b8b8729 100644 --- a/tests/unit/execApprovalResolveOperation.test.ts +++ b/tests/unit/execApprovalResolveOperation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { AgentState } from "@/features/agents/state/store"; import type { PendingExecApproval } from "@/features/agents/approvals/types"; +import { createRuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport"; import { GatewayResponseError } from "@/lib/gateway/errors"; import { resolveExecApprovalViaStudio } from "@/features/agents/approvals/execApprovalResolveOperation"; @@ -62,7 +63,10 @@ describe("execApprovalResolveOperation", () => { const onAllowed = vi.fn(); await resolveExecApprovalViaStudio({ - client: { call }, + runtimeWriteTransport: createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: false, + }), approvalId: "appr-1", decision: "allow-once", getAgents: () => [agent], @@ -134,7 +138,10 @@ describe("execApprovalResolveOperation", () => { const onAllowed = vi.fn(); await resolveExecApprovalViaStudio({ - client: { call }, + runtimeWriteTransport: createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: false, + }), approvalId: "appr-1", decision: "allow-once", getAgents: () => [agent], @@ -194,7 +201,10 @@ describe("execApprovalResolveOperation", () => { const onAllowed = vi.fn(); await resolveExecApprovalViaStudio({ - client: { call }, + runtimeWriteTransport: createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: false, + }), approvalId: "appr-1", decision: "deny", getAgents: () => [agent], @@ -248,7 +258,10 @@ describe("execApprovalResolveOperation", () => { const unscopedApprovals = createState([]); await resolveExecApprovalViaStudio({ - client: { call }, + runtimeWriteTransport: createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: true, + }), approvalId: "appr-1", decision: "deny", getAgents: () => [], @@ -261,7 +274,6 @@ describe("execApprovalResolveOperation", () => { setUnscopedPendingExecApprovals: unscopedApprovals.set, requestHistoryRefresh: vi.fn(), isDisconnectLikeError: () => false, - useDomainIntents: true, }); expect(fetchMock).toHaveBeenCalledWith( diff --git a/tests/unit/gatewayProxy.test.ts b/tests/unit/gatewayProxy.test.ts index 53455ef..6bd8017 100644 --- a/tests/unit/gatewayProxy.test.ts +++ b/tests/unit/gatewayProxy.test.ts @@ -1,5 +1,7 @@ // @vitest-environment node +import { EventEmitter } from "node:events"; + import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocket, WebSocketServer } from "ws"; @@ -549,4 +551,162 @@ describe("createGatewayProxy", () => { ]); } }); + + it("suppresses expected close-before-open upstream race errors", async () => { + class FakeUpstreamSocket extends EventEmitter { + readyState: number = WebSocket.CONNECTING; + + send() {} + + close() { + this.readyState = WebSocket.CLOSED; + this.emit("error", new Error("WebSocket was closed before the connection was established")); + this.emit("close", { code: 1000, reason: "closed" }); + } + } + + const logError = vi.fn(); + const log = vi.fn(); + let upstreamSocket: FakeUpstreamSocket | null = null; + const { createGatewayProxy } = await import("../../server/gateway-proxy"); + const proxyHttp = await import("node:http").then((m) => m.createServer()); + const proxy = createGatewayProxy({ + loadUpstreamSettings: async () => ({ url: "ws://127.0.0.1:65535", token: "token-123" }), + allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", + log, + logError, + createUpstreamWebSocket: () => { + upstreamSocket = new FakeUpstreamSocket(); + return upstreamSocket; + }, + }); + proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); + await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); + const proxyAddr = proxyHttp.address(); + if (!proxyAddr || typeof proxyAddr === "string") { + throw new Error("expected proxy server to have a port"); + } + + const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); + try { + await waitForEvent(browser, "open"); + browser.send( + JSON.stringify({ + type: "req", + id: "connect-suppress", + method: "connect", + params: { auth: {} }, + }) + ); + await closeWebSocket(browser); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(upstreamSocket).not.toBeNull(); + expect(logError).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith("Suppressed upstream close-before-open race."); + } finally { + await closeHttpServer(proxyHttp); + } + }); + + it("logs and forwards unexpected upstream socket errors", async () => { + class FakeUpstreamSocket extends EventEmitter { + readyState: number = WebSocket.CONNECTING; + + send() {} + + close() { + this.readyState = WebSocket.CLOSED; + this.emit("close", { code: 1000, reason: "closed" }); + } + } + + const logError = vi.fn(); + const upstreamSocket = new FakeUpstreamSocket(); + const { createGatewayProxy } = await import("../../server/gateway-proxy"); + const proxyHttp = await import("node:http").then((m) => m.createServer()); + const proxy = createGatewayProxy({ + loadUpstreamSettings: async () => ({ url: "ws://127.0.0.1:65534", token: "token-123" }), + allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", + logError, + createUpstreamWebSocket: () => upstreamSocket, + }); + proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); + await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); + const proxyAddr = proxyHttp.address(); + if (!proxyAddr || typeof proxyAddr === "string") { + throw new Error("expected proxy server to have a port"); + } + + const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); + try { + await waitForEvent(browser, "open"); + browser.send( + JSON.stringify({ + type: "req", + id: "connect-forward-error", + method: "connect", + params: { auth: {} }, + }) + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + upstreamSocket.emit("error", new Error("socket boom")); + + const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); + const response = JSON.parse(String(rawMessage ?? "")); + expect(response).toMatchObject({ + type: "res", + id: "connect-forward-error", + ok: false, + error: { code: "studio.upstream_error" }, + }); + expect(logError).toHaveBeenCalledTimes(1); + } finally { + await Promise.all([closeWebSocket(browser), closeHttpServer(proxyHttp)]); + } + }); + + it("returns structured upstream error when upstream websocket creation throws", async () => { + const logError = vi.fn(); + const { createGatewayProxy } = await import("../../server/gateway-proxy"); + const proxyHttp = await import("node:http").then((m) => m.createServer()); + const proxy = createGatewayProxy({ + loadUpstreamSettings: async () => ({ url: "ws://127.0.0.1:65534", token: "token-123" }), + allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", + logError, + createUpstreamWebSocket: () => { + throw new Error("constructor failed"); + }, + }); + proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); + await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); + const proxyAddr = proxyHttp.address(); + if (!proxyAddr || typeof proxyAddr === "string") { + throw new Error("expected proxy server to have a port"); + } + + const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); + try { + await waitForEvent(browser, "open"); + browser.send( + JSON.stringify({ + type: "req", + id: "connect-creation-throw", + method: "connect", + params: { auth: {} }, + }) + ); + + const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); + const response = JSON.parse(String(rawMessage ?? "")); + expect(response).toMatchObject({ + type: "res", + id: "connect-creation-throw", + ok: false, + error: { code: "studio.upstream_error" }, + }); + expect(logError).toHaveBeenCalledTimes(1); + } finally { + await Promise.all([closeWebSocket(browser), closeHttpServer(proxyHttp)]); + } + }); }); diff --git a/tests/unit/intentRoutes.test.ts b/tests/unit/intentRoutes.test.ts index d45fac2..9ed4bba 100644 --- a/tests/unit/intentRoutes.test.ts +++ b/tests/unit/intentRoutes.test.ts @@ -299,6 +299,46 @@ describe("intent routes", () => { expect(body.reason).toBe("gateway_unavailable"); }); + it("chat-send returns native mismatch remediation when runtime init fails", async () => { + vi.doMock("@/lib/controlplane/runtime", () => ({ + isStudioDomainApiModeEnabled: () => true, + getControlPlaneRuntime: () => { + const error = new Error( + "The module '/tmp/better_sqlite3.node' was compiled against a different Node.js version using NODE_MODULE_VERSION 141." + ) as Error & { code: string }; + error.code = "ERR_DLOPEN_FAILED"; + throw error; + }, + })); + 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; + remediation?: { commands?: string[] }; + }; + expect(body.code).toBe("NATIVE_MODULE_MISMATCH"); + expect(body.reason).toBe("native_module_mismatch"); + expect(body.remediation?.commands).toEqual([ + "npm rebuild better-sqlite3", + "npm install", + ]); + }); + it("chat-send returns 404 when domain mode is disabled", async () => { vi.doMock("@/lib/controlplane/runtime", () => ({ isStudioDomainApiModeEnabled: () => false, diff --git a/tests/unit/openclawAdapter.test.ts b/tests/unit/openclawAdapter.test.ts index 3973db0..b82d1f5 100644 --- a/tests/unit/openclawAdapter.test.ts +++ b/tests/unit/openclawAdapter.test.ts @@ -78,8 +78,8 @@ describe("OpenClawGatewayAdapter", () => { "Control-plane gateway connection closed." ); expect(Date.now() - startedAt).toBeLessThan(2_000); - expect(observedConnectClientId).toBe("gateway-client"); - expect(observedConnectClientMode).toBe("backend"); + expect(observedConnectClientId).toBe("openclaw-control-ui"); + expect(observedConnectClientMode).toBe("webchat"); await adapter.stop(); }); diff --git a/tests/unit/runtimeRouteBootstrap.test.ts b/tests/unit/runtimeRouteBootstrap.test.ts new file mode 100644 index 0000000..588bc57 --- /dev/null +++ b/tests/unit/runtimeRouteBootstrap.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("runtime route bootstrap", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("returns mode-disabled when domain mode is off", async () => { + vi.doMock("@/lib/controlplane/runtime", () => ({ + isStudioDomainApiModeEnabled: () => false, + getControlPlaneRuntime: vi.fn(), + })); + + const { bootstrapDomainRuntime } = await import("@/lib/controlplane/runtime-route-bootstrap"); + const result = await bootstrapDomainRuntime(); + expect(result).toEqual({ kind: "mode-disabled" }); + }); + + it("returns runtime-init-failed when runtime creation throws", async () => { + vi.doMock("@/lib/controlplane/runtime", () => ({ + isStudioDomainApiModeEnabled: () => true, + getControlPlaneRuntime: () => { + throw new Error("runtime init failed"); + }, + })); + + const { bootstrapDomainRuntime } = await import("@/lib/controlplane/runtime-route-bootstrap"); + const result = await bootstrapDomainRuntime(); + expect(result).toEqual({ + kind: "runtime-init-failed", + failure: { + code: "CONTROLPLANE_RUNTIME_INIT_FAILED", + reason: "runtime_init_failed", + message: "runtime init failed", + }, + }); + }); + + it("classifies better-sqlite3 ABI mismatch as native module mismatch", async () => { + vi.doMock("@/lib/controlplane/runtime", () => ({ + isStudioDomainApiModeEnabled: () => true, + getControlPlaneRuntime: () => { + const error = new Error( + "The module '/tmp/better_sqlite3.node' was compiled against a different Node.js version using NODE_MODULE_VERSION 141." + ) as Error & { code: string }; + error.code = "ERR_DLOPEN_FAILED"; + throw error; + }, + })); + + const { bootstrapDomainRuntime } = await import("@/lib/controlplane/runtime-route-bootstrap"); + const result = await bootstrapDomainRuntime(); + expect(result.kind).toBe("runtime-init-failed"); + if (result.kind !== "runtime-init-failed") { + throw new Error("expected runtime-init-failed result"); + } + expect(result.failure.code).toBe("NATIVE_MODULE_MISMATCH"); + expect(result.failure.reason).toBe("native_module_mismatch"); + expect(result.failure.remediation?.commands).toEqual([ + "npm rebuild better-sqlite3", + "npm install", + ]); + }); + + it("returns start-failed when startup fails", async () => { + const runtime = { + ensureStarted: async () => { + throw new Error("start failed"); + }, + }; + vi.doMock("@/lib/controlplane/runtime", () => ({ + isStudioDomainApiModeEnabled: () => true, + getControlPlaneRuntime: () => runtime, + })); + + const { bootstrapDomainRuntime } = await import("@/lib/controlplane/runtime-route-bootstrap"); + const result = await bootstrapDomainRuntime(); + expect(result.kind).toBe("start-failed"); + if (result.kind !== "start-failed") { + throw new Error("expected start-failed result"); + } + expect(result.message).toBe("start failed"); + expect(result.runtime).toBe(runtime); + }); + + it("returns ready when runtime startup succeeds", async () => { + const runtime = { + ensureStarted: async () => {}, + }; + vi.doMock("@/lib/controlplane/runtime", () => ({ + isStudioDomainApiModeEnabled: () => true, + getControlPlaneRuntime: () => runtime, + })); + + const { bootstrapDomainRuntime } = await import("@/lib/controlplane/runtime-route-bootstrap"); + const result = await bootstrapDomainRuntime(); + expect(result.kind).toBe("ready"); + if (result.kind !== "ready") { + throw new Error("expected ready result"); + } + expect(result.runtime).toBe(runtime); + }); +}); diff --git a/tests/unit/runtimeRoutes.test.ts b/tests/unit/runtimeRoutes.test.ts index ad0a419..8da56a2 100644 --- a/tests/unit/runtimeRoutes.test.ts +++ b/tests/unit/runtimeRoutes.test.ts @@ -164,6 +164,35 @@ describe("runtime routes", () => { expect(body.reason).toBe("runtime_init_failed"); }); + it("summary route returns native mismatch remediation when runtime init fails on ABI drift", async () => { + vi.resetModules(); + vi.doMock("@/lib/controlplane/runtime", () => ({ + isStudioDomainApiModeEnabled: () => true, + getControlPlaneRuntime: () => { + const error = new Error( + "The module '/tmp/better_sqlite3.node' was compiled against a different Node.js version using NODE_MODULE_VERSION 141." + ) as Error & { code: string }; + error.code = "ERR_DLOPEN_FAILED"; + throw error; + }, + })); + + const mod = await import("@/app/api/runtime/summary/route"); + const response = await mod.GET(); + expect(response.status).toBe(503); + const body = await response.json() as { + code: string; + reason: string; + remediation?: { commands?: string[] }; + }; + expect(body.code).toBe("NATIVE_MODULE_MISMATCH"); + expect(body.reason).toBe("native_module_mismatch"); + expect(body.remediation?.commands).toEqual([ + "npm rebuild better-sqlite3", + "npm install", + ]); + }); + it("summary route returns 404 when domain mode is disabled", async () => { vi.resetModules(); vi.doMock("@/lib/controlplane/runtime", () => ({ @@ -1182,6 +1211,91 @@ describe("runtime routes", () => { expect(body.result.sessionCreatedAgentIds).toEqual(["alpha", "beta"]); }); + it("runtime fleet route degrades when hydration fails with missing scope", async () => { + vi.resetModules(); + vi.doMock("@/lib/controlplane/runtime", () => ({ + isStudioDomainApiModeEnabled: () => true, + getControlPlaneRuntime: () => ({ + ensureStarted: async () => {}, + snapshot: () => ({ + status: "connected", + reason: null, + asOf: "2026-02-28T02:40:00.000Z", + outboxHead: 3, + }), + eventsAfter: () => [ + { + id: 3, + event: { + type: "gateway.event", + event: "runtime.delta", + seq: 12, + payload: { + sessionKey: "agent:alpha:main", + }, + asOf: "2026-02-28T02:40:03.000Z", + }, + createdAt: "2026-02-28T02:40:03.000Z", + }, + ], + callGateway: vi.fn(), + }), + })); + vi.doMock("@/lib/studio/settings-store", () => ({ + loadStudioSettings: () => ({ + version: 1, + gateway: { url: "ws://localhost:3000/ws", token: "" }, + localGatewayDefaults: { url: "", token: "" }, + focused: {}, + avatars: {}, + }), + })); + vi.doMock("@/lib/controlplane/degraded-read", async () => { + const actual = await vi.importActual( + "@/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" }, + })), + }; + }); + vi.doMock("@/features/agents/operations/agentFleetHydration", () => ({ + hydrateAgentFleetFromGateway: vi.fn(async () => { + const error = new Error("missing scope: operator.read") as Error & { code: string }; + error.code = "INVALID_REQUEST"; + throw error; + }), + })); + + 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 { + enabled: boolean; + degraded: boolean; + code: string; + reason: string; + result: { seeds: Array<{ agentId: string }>; sessionCreatedAgentIds: string[] }; + }; + expect(body.enabled).toBe(true); + expect(body.degraded).toBe(true); + expect(body.code).toBe("INSUFFICIENT_SCOPE"); + expect(body.reason).toBe("insufficient_scope"); + expect(body.result.seeds.map((entry) => entry.agentId)).toEqual(["alpha"]); + expect(body.result.sessionCreatedAgentIds).toEqual(["alpha"]); + }); + it("runtime fleet route returns 503 when runtime initialization fails", async () => { vi.resetModules(); vi.doMock("@/lib/controlplane/runtime", () => ({ diff --git a/tests/unit/runtimeWriteTransport.test.ts b/tests/unit/runtimeWriteTransport.test.ts new file mode 100644 index 0000000..1619049 --- /dev/null +++ b/tests/unit/runtimeWriteTransport.test.ts @@ -0,0 +1,440 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { postStudioIntent } from "@/lib/controlplane/intents-client"; +import { createGatewayAgent, deleteGatewayAgent, renameGatewayAgent } from "@/lib/gateway/agentConfig"; +import { + readGatewayAgentExecApprovals, + upsertGatewayAgentExecApprovals, +} from "@/lib/gateway/execApprovals"; +import { createRuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport"; + +vi.mock("@/lib/controlplane/intents-client", () => ({ + postStudioIntent: vi.fn(async () => ({ ok: true })), +})); + +vi.mock("@/lib/gateway/agentConfig", async () => { + const actual = await vi.importActual( + "@/lib/gateway/agentConfig" + ); + return { + ...actual, + createGatewayAgent: vi.fn(async () => ({ id: "agent-1", name: "Agent One" })), + deleteGatewayAgent: vi.fn(async () => ({ removed: true, removedBindings: 0 })), + renameGatewayAgent: vi.fn(async () => ({ id: "agent-1", name: "Agent One" })), + }; +}); + +vi.mock("@/lib/gateway/execApprovals", async () => { + const actual = await vi.importActual( + "@/lib/gateway/execApprovals" + ); + return { + ...actual, + readGatewayAgentExecApprovals: vi.fn(async () => null), + upsertGatewayAgentExecApprovals: vi.fn(async () => undefined), + }; +}); + +describe("runtimeWriteTransport", () => { + const mockedPostStudioIntent = vi.mocked(postStudioIntent); + const mockedCreateGatewayAgent = vi.mocked(createGatewayAgent); + const mockedDeleteGatewayAgent = vi.mocked(deleteGatewayAgent); + const mockedRenameGatewayAgent = vi.mocked(renameGatewayAgent); + const mockedReadGatewayAgentExecApprovals = vi.mocked(readGatewayAgentExecApprovals); + const mockedUpsertGatewayAgentExecApprovals = vi.mocked(upsertGatewayAgentExecApprovals); + + beforeEach(() => { + mockedPostStudioIntent.mockReset(); + mockedPostStudioIntent.mockResolvedValue({ ok: true }); + mockedCreateGatewayAgent.mockReset(); + mockedCreateGatewayAgent.mockResolvedValue({ id: "agent-1", name: "Agent One" }); + mockedDeleteGatewayAgent.mockReset(); + mockedDeleteGatewayAgent.mockResolvedValue({ removed: true, removedBindings: 0 }); + mockedRenameGatewayAgent.mockReset(); + mockedRenameGatewayAgent.mockResolvedValue({ id: "agent-1", name: "Agent One" }); + mockedReadGatewayAgentExecApprovals.mockReset(); + mockedReadGatewayAgentExecApprovals.mockResolvedValue(null); + mockedUpsertGatewayAgentExecApprovals.mockReset(); + mockedUpsertGatewayAgentExecApprovals.mockResolvedValue(undefined); + }); + + it("routes chat send through domain intent and unwraps payload envelopes", async () => { + mockedPostStudioIntent.mockResolvedValue({ + ok: true, + payload: { runId: "run-1", status: "started" }, + }); + const call = vi.fn(async () => { + throw new Error("gateway chat.send should not be called"); + }); + const transport = createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: true, + }); + + const result = await transport.chatSend({ + sessionKey: "agent:agent-1:main", + message: "hello", + deliver: false, + idempotencyKey: "run-1", + }); + + expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/chat-send", { + sessionKey: "agent:agent-1:main", + message: "hello", + deliver: false, + idempotencyKey: "run-1", + }); + expect(call).not.toHaveBeenCalled(); + expect(result).toEqual({ runId: "run-1", status: "started" }); + }); + + it("routes chat send through gateway rpc when domain mode is disabled", async () => { + const call = vi.fn(async () => ({ runId: "run-1", status: "started" })); + const transport = createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: false, + }); + + const result = await transport.chatSend({ + sessionKey: "agent:agent-1:main", + message: "hello", + deliver: false, + idempotencyKey: "run-1", + }); + + expect(call).toHaveBeenCalledWith("chat.send", { + sessionKey: "agent:agent-1:main", + message: "hello", + deliver: false, + idempotencyKey: "run-1", + }); + expect(mockedPostStudioIntent).not.toHaveBeenCalled(); + expect(result).toEqual({ runId: "run-1", status: "started" }); + }); + + it("routes abort and reset actions by mode", async () => { + const domainCall = vi.fn(async () => ({})); + const domainTransport = createRuntimeWriteTransport({ + client: { call: domainCall } as never, + useDomainIntents: true, + }); + + await domainTransport.chatAbort({ sessionKey: "agent:1" }); + await domainTransport.sessionsReset({ key: "agent:1" }); + await domainTransport.sessionSettingsSync({ + sessionKey: "agent:1", + model: "openai/gpt-5", + }); + + expect(mockedPostStudioIntent).toHaveBeenNthCalledWith(1, "/api/intents/chat-abort", { + sessionKey: "agent:1", + }); + expect(mockedPostStudioIntent).toHaveBeenNthCalledWith(2, "/api/intents/sessions-reset", { + key: "agent:1", + }); + expect(mockedPostStudioIntent).toHaveBeenNthCalledWith(3, "/api/intents/session-settings-sync", { + sessionKey: "agent:1", + model: "openai/gpt-5", + }); + expect(domainCall).not.toHaveBeenCalled(); + + mockedPostStudioIntent.mockReset(); + const gatewayCall = vi.fn(async () => ({})); + const gatewayTransport = createRuntimeWriteTransport({ + client: { call: gatewayCall } as never, + useDomainIntents: false, + }); + + await gatewayTransport.chatAbort({ sessionKey: "agent:2" }); + await gatewayTransport.sessionsReset({ key: "agent:2" }); + await gatewayTransport.sessionSettingsSync({ + sessionKey: "agent:2", + thinkingLevel: "high", + }); + + expect(gatewayCall).toHaveBeenNthCalledWith(1, "chat.abort", { sessionKey: "agent:2" }); + expect(gatewayCall).toHaveBeenNthCalledWith(2, "sessions.reset", { key: "agent:2" }); + expect(gatewayCall).toHaveBeenNthCalledWith(3, "sessions.patch", { + key: "agent:2", + thinkingLevel: "high", + }); + expect(mockedPostStudioIntent).not.toHaveBeenCalled(); + }); + + it("routes rename and delete by mode", async () => { + const call = vi.fn(async () => ({})); + const gatewayTransport = createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: false, + }); + await gatewayTransport.agentRename({ agentId: "agent-1", name: "Agent One" }); + await gatewayTransport.agentDelete({ agentId: "agent-1" }); + + expect(mockedRenameGatewayAgent).toHaveBeenCalledWith({ + client: { call }, + agentId: "agent-1", + name: "Agent One", + }); + expect(mockedDeleteGatewayAgent).toHaveBeenCalledWith({ + client: { call }, + agentId: "agent-1", + }); + + mockedRenameGatewayAgent.mockReset(); + mockedDeleteGatewayAgent.mockReset(); + const domainTransport = createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: true, + }); + await domainTransport.agentRename({ agentId: "agent-2", name: "Agent Two" }); + await domainTransport.agentDelete({ agentId: "agent-2" }); + + expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/agent-rename", { + agentId: "agent-2", + name: "Agent Two", + }); + expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/agent-delete", { + agentId: "agent-2", + }); + expect(mockedRenameGatewayAgent).not.toHaveBeenCalled(); + expect(mockedDeleteGatewayAgent).not.toHaveBeenCalled(); + }); + + it("routes create agent by mode", async () => { + const call = vi.fn(async () => ({})); + const gatewayTransport = createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: false, + }); + await expect(gatewayTransport.agentCreate({ name: "Agent One" })).resolves.toEqual({ + id: "agent-1", + name: "Agent One", + }); + expect(mockedCreateGatewayAgent).toHaveBeenCalledWith({ + client: { call }, + name: "Agent One", + }); + + mockedCreateGatewayAgent.mockReset(); + mockedPostStudioIntent.mockReset(); + mockedPostStudioIntent.mockResolvedValue({ + ok: true, + payload: { ok: true, agentId: "agent-2", name: "Agent Two" }, + }); + const domainTransport = createRuntimeWriteTransport({ + client: { call: vi.fn(async () => ({})) } as never, + useDomainIntents: true, + }); + await expect(domainTransport.agentCreate({ name: "Agent Two" })).resolves.toEqual({ + id: "agent-2", + name: "Agent Two", + }); + expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/agent-create", { + name: "Agent Two", + }); + expect(mockedCreateGatewayAgent).not.toHaveBeenCalled(); + }); + + it("normalizes rename inputs consistently across modes", async () => { + const gatewayTransport = createRuntimeWriteTransport({ + client: { call: vi.fn(async () => ({})) } as never, + useDomainIntents: false, + }); + await gatewayTransport.agentRename({ agentId: " agent-1 ", name: " Agent One " }); + expect(mockedRenameGatewayAgent).toHaveBeenCalledWith({ + client: expect.any(Object), + agentId: "agent-1", + name: "Agent One", + }); + + mockedRenameGatewayAgent.mockReset(); + mockedPostStudioIntent.mockReset(); + const domainTransport = createRuntimeWriteTransport({ + client: { call: vi.fn(async () => ({})) } as never, + useDomainIntents: true, + }); + await domainTransport.agentRename({ agentId: " agent-2 ", name: " Agent Two " }); + expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/agent-rename", { + agentId: "agent-2", + name: "Agent Two", + }); + expect(mockedRenameGatewayAgent).not.toHaveBeenCalled(); + }); + + it("routes exec approval resolve by mode", async () => { + const call = vi.fn(async () => ({})); + const gatewayTransport = createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: false, + }); + + await gatewayTransport.execApprovalResolve({ id: "approval-1", decision: "allow" }); + expect(call).toHaveBeenCalledWith("exec.approval.resolve", { + id: "approval-1", + decision: "allow", + }); + + call.mockReset(); + mockedPostStudioIntent.mockReset(); + const domainTransport = createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: true, + }); + await domainTransport.execApprovalResolve({ id: "approval-2", decision: "deny" }); + expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/exec-approval-resolve", { + id: "approval-2", + decision: "deny", + }); + expect(call).not.toHaveBeenCalled(); + }); + + it("sets exec approval policy in gateway mode using existing allowlist", async () => { + const call = vi.fn(async () => ({})); + mockedReadGatewayAgentExecApprovals.mockResolvedValue({ + security: "allowlist", + ask: "always", + allowlist: [{ pattern: "/tmp/**" }], + }); + const transport = createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: false, + }); + + await transport.execApprovalsSet({ + agentId: " agent-1 ", + role: "autonomous", + }); + + expect(mockedReadGatewayAgentExecApprovals).toHaveBeenCalledWith({ + client: { call }, + agentId: "agent-1", + }); + expect(mockedUpsertGatewayAgentExecApprovals).toHaveBeenCalledWith({ + client: { call }, + agentId: "agent-1", + policy: { + security: "full", + ask: "off", + allowlist: [{ pattern: "/tmp/**" }], + }, + }); + }); + + it("rejects exec approvals set in domain mode and directs caller to permissions intent", async () => { + const call = vi.fn(async () => ({})); + const transport = createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: true, + }); + + await expect( + transport.execApprovalsSet({ agentId: "agent-1", role: "collaborative" }) + ).rejects.toThrow( + "execApprovalsSet is not supported in domain intent mode; use agentPermissionsUpdate." + ); + expect(mockedPostStudioIntent).not.toHaveBeenCalled(); + expect(mockedReadGatewayAgentExecApprovals).not.toHaveBeenCalled(); + expect(mockedUpsertGatewayAgentExecApprovals).not.toHaveBeenCalled(); + expect(call).not.toHaveBeenCalled(); + }); + + it("routes agent permissions update through domain intent", async () => { + const transport = createRuntimeWriteTransport({ + client: { call: vi.fn(async () => ({})) } as never, + useDomainIntents: true, + }); + await transport.agentPermissionsUpdate({ + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + commandMode: "ask", + webAccess: true, + fileTools: false, + }); + expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/agent-permissions-update", { + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + commandMode: "ask", + webAccess: true, + fileTools: false, + }); + }); + + it("rejects exec approval set when agent id is empty", async () => { + const transport = createRuntimeWriteTransport({ + client: { call: vi.fn(async () => ({})) } as never, + useDomainIntents: false, + }); + + await expect(transport.execApprovalsSet({ agentId: " ", role: "conservative" })).rejects.toThrow( + "Agent id is required." + ); + expect(mockedReadGatewayAgentExecApprovals).not.toHaveBeenCalled(); + expect(mockedUpsertGatewayAgentExecApprovals).not.toHaveBeenCalled(); + }); + + it("rejects agent permissions update when domain mode is disabled", async () => { + const transport = createRuntimeWriteTransport({ + client: { call: vi.fn(async () => ({})) } as never, + useDomainIntents: false, + }); + await expect( + transport.agentPermissionsUpdate({ + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + commandMode: "off", + webAccess: false, + fileTools: false, + }) + ).rejects.toThrow("agentPermissionsUpdate is only available in domain intent mode."); + }); + + it("fails fast on required identifiers before transport calls", async () => { + const call = vi.fn(async () => ({})); + const transport = createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: true, + }); + + await expect( + transport.chatSend({ + sessionKey: " ", + message: "hello", + deliver: false, + idempotencyKey: "run-1", + }) + ).rejects.toThrow("Session key is required."); + await expect(transport.agentWait({ runId: " " })).rejects.toThrow("Run id is required."); + await expect(transport.execApprovalResolve({ id: " ", decision: "allow" })).rejects.toThrow( + "Approval id is required." + ); + await expect(transport.agentDelete({ agentId: " " })).rejects.toThrow("Agent id is required."); + await expect(transport.agentRename({ agentId: "agent-1", name: " " })).rejects.toThrow( + "Agent name is required." + ); + await expect(transport.agentCreate({ name: " " })).rejects.toThrow("Agent name is required."); + + expect(call).not.toHaveBeenCalled(); + expect(mockedPostStudioIntent).not.toHaveBeenCalled(); + }); + + it("routes agent wait through mode-specific transport with timeout passthrough", async () => { + const gatewayCall = vi.fn(async () => ({})); + const gatewayTransport = createRuntimeWriteTransport({ + client: { call: gatewayCall } as never, + useDomainIntents: false, + }); + await gatewayTransport.agentWait({ runId: "run-1", timeoutMs: 2500 }); + expect(gatewayCall).toHaveBeenCalledWith("agent.wait", { runId: "run-1", timeoutMs: 2500 }); + + mockedPostStudioIntent.mockReset(); + const domainTransport = createRuntimeWriteTransport({ + client: { call: vi.fn(async () => ({})) } as never, + useDomainIntents: true, + }); + await domainTransport.agentWait({ runId: "run-2", timeoutMs: 3000 }); + expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/agent-wait", { + runId: "run-2", + timeoutMs: 3000, + }); + }); +}); diff --git a/tests/unit/sessionSettingsMutations.test.ts b/tests/unit/sessionSettingsMutations.test.ts index 05b91a3..9e63ade 100644 --- a/tests/unit/sessionSettingsMutations.test.ts +++ b/tests/unit/sessionSettingsMutations.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { applySessionSettingMutation } from "@/features/agents/state/sessionSettingsMutations"; +import { createRuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport"; import type { GatewayClient } from "@/lib/gateway/GatewayClient"; import { GatewayResponseError } from "@/lib/gateway/errors"; @@ -188,4 +189,39 @@ describe("session settings mutations helper", () => { ); expect(failureLines).toHaveLength(0); }); + + it("routes session mutation through intent transport in domain mode", async () => { + const dispatch = vi.fn(); + const call = vi.fn(async () => { + throw new Error("gateway client should not be used"); + }); + 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); + + await applySessionSettingMutation({ + agents: [{ agentId: "agent-1", sessionCreated: true }], + dispatch, + client: { call } as unknown as GatewayClient, + runtimeWriteTransport: createRuntimeWriteTransport({ + client: { call } as never, + useDomainIntents: true, + }), + agentId: "agent-1", + sessionKey: "agent:1:studio:abc", + field: "model", + value: "openai/gpt-5", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/intents/session-settings-sync", + expect.objectContaining({ method: "POST" }) + ); + expect(call).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); }); diff --git a/tests/unit/useAgentSettingsMutationController.test.ts b/tests/unit/useAgentSettingsMutationController.test.ts index 7041e79..2b5d494 100644 --- a/tests/unit/useAgentSettingsMutationController.test.ts +++ b/tests/unit/useAgentSettingsMutationController.test.ts @@ -12,6 +12,7 @@ import { deleteAgentViaStudio } from "@/features/agents/operations/deleteAgentOp import { performCronCreateFlow } from "@/features/agents/operations/cronCreateOperation"; import { updateAgentPermissionsViaStudio } from "@/features/agents/operations/agentPermissionsOperation"; import { runAgentConfigMutationLifecycle } from "@/features/agents/operations/mutationLifecycleWorkflow"; +import { createRuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport"; import { runCronJobNow, removeCronJob } from "@/lib/cron/types"; import { shouldAwaitDisconnectRestartForRemoteMutation } from "@/lib/gateway/gatewayReloadMode"; import { @@ -163,8 +164,15 @@ const renderController = (overrides?: Partial ({})), }; + const runtimeWriteTransport = createRuntimeWriteTransport({ + client: client as never, + useDomainIntents: overrides?.useDomainIntents ?? false, + }); - const params: Parameters[0] = { + const paramsBase: Omit< + Parameters[0], + "runtimeWriteTransport" | "useDomainIntents" + > = { client: client as never, status: "connected", isLocalGateway: false, @@ -184,6 +192,11 @@ const renderController = (overrides?: Partial[0] = { + ...paramsBase, + runtimeWriteTransport, + useDomainIntents: overrides?.useDomainIntents ?? false, + }; const valueRef: { current: ControllerValue | null } = { current: null }; const Probe = ({ onValue }: { onValue: (next: ControllerValue) => void }) => { @@ -234,7 +247,6 @@ describe("useAgentSettingsMutationController", () => { const mockedUpdateSkill = vi.mocked(updateSkill); beforeEach(() => { - process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false"; restartBlockHookParams = null; mockedDeleteAgentViaStudio.mockReset(); mockedPerformCronCreateFlow.mockReset(); @@ -278,7 +290,6 @@ 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 () => { @@ -293,7 +304,6 @@ describe("useAgentSettingsMutationController", () => { }); 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(); @@ -304,7 +314,7 @@ describe("useAgentSettingsMutationController", () => { }); mockedDeleteAgentViaStudio.mockResolvedValue({ trashed: { trashDir: "", moved: [] }, restored: null }); - const ctx = renderController({ status: "disconnected" }); + const ctx = renderController({ status: "disconnected", useDomainIntents: true }); await act(async () => { await ctx.getValue().handleDeleteAgent("agent-1"); @@ -314,7 +324,7 @@ describe("useAgentSettingsMutationController", () => { expect(mockedDeleteAgentViaStudio).toHaveBeenCalledWith( expect.objectContaining({ agentId: "agent-1", - useDomainIntents: true, + runtimeWriteTransport: expect.anything(), }) ); }); diff --git a/tests/unit/useGatewayConnection.test.ts b/tests/unit/useGatewayConnection.test.ts index 807d441..e0edc74 100644 --- a/tests/unit/useGatewayConnection.test.ts +++ b/tests/unit/useGatewayConnection.test.ts @@ -15,10 +15,18 @@ const setupAndImportHook = async (gatewayUrl: string | null) => { vi.resetModules(); vi.spyOn(console, "info").mockImplementation(() => {}); - const captured: { url: string | null; token: unknown; authScopeKey: unknown } = { + const captured: { + url: string | null; + token: unknown; + authScopeKey: unknown; + startCount: number; + stopCount: number; + } = { url: null, token: null, authScopeKey: null, + startCount: 0, + stopCount: 0, }; vi.doMock("../../src/lib/gateway/openclaw/GatewayBrowserClient", () => { @@ -44,11 +52,13 @@ const setupAndImportHook = async (gatewayUrl: string | null) => { } start() { + captured.startCount += 1; this.connected = true; this.opts.onHello?.({ type: "hello-ok", protocol: 1 }); } stop() { + captured.stopCount += 1; this.connected = false; this.opts.onClose?.({ code: 1000, reason: "stopped" }); } @@ -226,6 +236,100 @@ describe("useGatewayConnection", () => { expect(screen.getByTestId("token")).toHaveTextContent(""); }); + it("does_not_auto_connect_when_domain_mode_is_enabled", async () => { + const { useGatewayConnection, captured } = await setupAndImportHook(null); + const coordinator = { + loadSettings: async () => null, + loadSettingsEnvelope: async () => ({ + settings: { + version: 1, + gateway: { url: "wss://remote.example", token: "" }, + focused: {}, + avatars: {}, + }, + localGatewayDefaults: null, + domainApiModeEnabled: true, + }), + schedulePatch: () => {}, + flushPending: async () => {}, + }; + + const Probe = () => { + const state = useGatewayConnection(coordinator); + return createElement( + "div", + { "data-testid": "domainApiModeEnabled" }, + state.domainApiModeEnabled === null ? "null" : String(state.domainApiModeEnabled) + ); + }; + + render(createElement(Probe)); + + await waitFor(() => { + expect(screen.getByTestId("domainApiModeEnabled")).toHaveTextContent("true"); + }); + expect(captured.url).toBeNull(); + expect(captured.startCount).toBe(0); + expect(captured.stopCount).toBe(0); + }); + + it("disconnects legacy websocket when domain mode flips to enabled", async () => { + const { useGatewayConnection, captured } = await setupAndImportHook(null); + const legacyCoordinator = { + loadSettings: async () => null, + loadSettingsEnvelope: async () => ({ + settings: { + version: 1, + gateway: { url: "wss://legacy.example", token: "" }, + focused: {}, + avatars: {}, + }, + localGatewayDefaults: null, + domainApiModeEnabled: false, + }), + schedulePatch: () => {}, + flushPending: async () => {}, + }; + const domainCoordinator = { + ...legacyCoordinator, + loadSettingsEnvelope: async () => ({ + settings: { + version: 1, + gateway: { url: "wss://legacy.example", token: "" }, + focused: {}, + avatars: {}, + }, + localGatewayDefaults: null, + domainApiModeEnabled: true, + }), + }; + + const Probe = ({ coordinator }: { coordinator: typeof legacyCoordinator }) => { + const state = useGatewayConnection(coordinator); + return createElement( + "div", + { "data-testid": "domainApiModeEnabled" }, + state.domainApiModeEnabled === null ? "null" : String(state.domainApiModeEnabled) + ); + }; + + const view = render(createElement(Probe, { coordinator: legacyCoordinator })); + + await waitFor(() => { + expect(captured.startCount).toBe(1); + }); + expect(captured.stopCount).toBe(0); + + view.rerender(createElement(Probe, { coordinator: domainCoordinator })); + + await waitFor(() => { + expect(screen.getByTestId("domainApiModeEnabled")).toHaveTextContent("true"); + }); + await waitFor(() => { + expect(captured.stopCount).toBeGreaterThanOrEqual(1); + }); + }); + it("persists gateway url changes without sending token", async () => { const { useGatewayConnection } = await setupAndImportHook(null); const schedulePatch = vi.fn(); diff --git a/tests/unit/useRuntimeSyncController.test.ts b/tests/unit/useRuntimeSyncController.test.ts index 0caf85c..618d198 100644 --- a/tests/unit/useRuntimeSyncController.test.ts +++ b/tests/unit/useRuntimeSyncController.test.ts @@ -71,6 +71,7 @@ type RenderControllerContext = { unmount: () => void; dispatch: ReturnType; clearRunTracking: ReturnType; + ingestDomainOutboxEntries: ReturnType; call: ReturnType; onGap: ReturnType; getGapHandler: () => ((info: GatewayGapInfo) => void) | null; @@ -82,6 +83,7 @@ const renderController = ( ): RenderControllerContext => { const dispatch = vi.fn(); const clearRunTracking = vi.fn(); + const ingestDomainOutboxEntries = vi.fn(); const call = vi.fn(async (method: string) => { if (method === "status") { return { sessions: { recent: [], byAgent: [] } }; @@ -98,7 +100,10 @@ const renderController = ( return unsubscribeGap; }); - let currentParams: Parameters[0] = { + const currentParamsBase: Omit< + Parameters[0], + "useDomainApiReads" | "ingestDomainOutboxEntries" + > = { client: { call, onGap, @@ -114,6 +119,11 @@ const renderController = ( maxHistoryLimit: 5000, ...(overrides ?? {}), }; + let currentParams: Parameters[0] = { + ...currentParamsBase, + useDomainApiReads: overrides?.useDomainApiReads ?? false, + ingestDomainOutboxEntries: overrides?.ingestDomainOutboxEntries ?? ingestDomainOutboxEntries, + }; const valueRef: { current: RuntimeSyncControllerValue | null } = { current: null }; @@ -151,6 +161,9 @@ const renderController = ( currentParams = { ...currentParams, ...nextOverrides, + useDomainApiReads: nextOverrides.useDomainApiReads ?? currentParams.useDomainApiReads, + ingestDomainOutboxEntries: + nextOverrides.ingestDomainOutboxEntries ?? currentParams.ingestDomainOutboxEntries, }; rendered.rerender( createElement(Probe, { @@ -166,6 +179,7 @@ const renderController = ( }, dispatch, clearRunTracking, + ingestDomainOutboxEntries, call, onGap, getGapHandler: () => gapHandler, @@ -181,7 +195,6 @@ describe("useRuntimeSyncController", () => { beforeEach(() => { vi.useFakeTimers(); - process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false"; mockedRunHistorySyncOperation.mockReset(); mockedRunHistorySyncOperation.mockResolvedValue([]); mockedExecuteHistorySyncCommands.mockReset(); @@ -193,7 +206,6 @@ 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 () => { @@ -368,8 +380,8 @@ 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"; + it("uses domain runtime APIs, ingests history entries, and paginates with beforeOutboxId", async () => { + let historyCallCount = 0; const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.includes("/api/runtime/summary")) { @@ -382,11 +394,74 @@ describe("useRuntimeSyncController", () => { { 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" }, - }); + if (url.includes("/api/runtime/agents/agent-1/history")) { + historyCallCount += 1; + if (historyCallCount === 1 || historyCallCount === 2) { + return new Response( + JSON.stringify({ + enabled: true, + entries: [ + { + id: 5, + event: { + type: "gateway.event", + event: "runtime.delta", + seq: 5, + payload: { sessionKey: "agent:agent-1:main", delta: "a" }, + asOf: "2026-03-01T00:00:05.000Z", + }, + createdAt: "2026-03-01T00:00:05.000Z", + }, + { + id: 6, + event: { + type: "gateway.event", + event: "runtime.delta", + seq: 6, + payload: { sessionKey: "agent:agent-1:main", delta: "b" }, + asOf: "2026-03-01T00:00:06.000Z", + }, + createdAt: "2026-03-01T00:00:06.000Z", + }, + ], + hasMore: true, + nextBeforeOutboxId: 5, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response( + JSON.stringify({ + enabled: true, + entries: [ + { + id: 3, + event: { + type: "gateway.event", + event: "runtime.delta", + seq: 3, + payload: { sessionKey: "agent:agent-1:main", delta: "older-a" }, + asOf: "2026-03-01T00:00:03.000Z", + }, + createdAt: "2026-03-01T00:00:03.000Z", + }, + { + id: 4, + event: { + type: "gateway.event", + event: "runtime.delta", + seq: 4, + payload: { sessionKey: "agent:agent-1:main", delta: "older-b" }, + asOf: "2026-03-01T00:00:04.000Z", + }, + createdAt: "2026-03-01T00:00:04.000Z", + }, + ], + hasMore: false, + nextBeforeOutboxId: null, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); } return new Response(JSON.stringify({}), { status: 200, @@ -395,22 +470,145 @@ describe("useRuntimeSyncController", () => { }); vi.stubGlobal("fetch", fetchMock); + const ingestDomainOutboxEntries = vi.fn(); const ctx = renderController({ - focusedAgentId: "agent-1", - focusedAgentRunning: true, + status: "disconnected", + useDomainApiReads: true, + agents: [createAgent({ historyFetchLimit: 2 })], + ingestDomainOutboxEntries, + focusedAgentId: null, + focusedAgentRunning: false, }); await act(async () => { - await Promise.resolve(); + await ctx.getValue().loadAgentHistory("agent-1", { limit: 2 }); + }); + expect(ingestDomainOutboxEntries).toHaveBeenCalledTimes(1); + expect(ingestDomainOutboxEntries).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([expect.objectContaining({ id: 5 }), expect.objectContaining({ id: 6 })]) + ); + expect(ctx.dispatch).toHaveBeenCalledWith({ + type: "updateAgent", + agentId: "agent-1", + patch: expect.objectContaining({ + historyFetchLimit: 2, + historyFetchedCount: 2, + historyMaybeTruncated: true, + }), }); - expect(fetchMock).toHaveBeenCalledWith("/api/runtime/summary", expect.anything()); + await act(async () => { + await ctx.getValue().loadAgentHistory("agent-1", { limit: 2 }); + }); + expect(ingestDomainOutboxEntries).toHaveBeenCalledTimes(1); + + await act(async () => { + ctx.getValue().loadMoreAgentHistory("agent-1"); + await Promise.resolve(); + await Promise.resolve(); + }); expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining("/api/runtime/agents/agent-1/history"), + expect.stringContaining("/api/runtime/agents/agent-1/history?limit=2&beforeOutboxId=5"), expect.anything() ); + expect(ingestDomainOutboxEntries).toHaveBeenCalledTimes(2); + expect(ingestDomainOutboxEntries).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([expect.objectContaining({ id: 3 }), expect.objectContaining({ id: 4 })]) + ); + expect(ctx.call).not.toHaveBeenCalledWith("status", {}); vi.unstubAllGlobals(); ctx.unmount(); }); + + it("does not drop valid history when outbox ids repeat with new createdAt values", async () => { + let historyCallCount = 0; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/runtime/agents/agent-1/history")) { + historyCallCount += 1; + if (historyCallCount === 1) { + return new Response( + JSON.stringify({ + enabled: true, + entries: [ + { + id: 5, + event: { + type: "gateway.event", + event: "runtime.delta", + seq: 5, + payload: { sessionKey: "agent:agent-1:main", delta: "old" }, + asOf: "2026-03-01T00:00:05.000Z", + }, + createdAt: "2026-03-01T00:00:05.000Z", + }, + ], + hasMore: false, + nextBeforeOutboxId: null, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response( + JSON.stringify({ + enabled: true, + entries: [ + { + id: 5, + event: { + type: "gateway.event", + event: "runtime.delta", + seq: 5, + payload: { sessionKey: "agent:agent-1:main", delta: "new" }, + asOf: "2026-03-02T00:00:05.000Z", + }, + createdAt: "2026-03-02T00:00:05.000Z", + }, + ], + hasMore: false, + nextBeforeOutboxId: null, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response(JSON.stringify({ enabled: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const ingestDomainOutboxEntries = vi.fn(); + const ctx = renderController({ + status: "disconnected", + useDomainApiReads: true, + agents: [createAgent({ historyFetchLimit: 2 })], + ingestDomainOutboxEntries, + focusedAgentId: null, + focusedAgentRunning: false, + }); + + await act(async () => { + await ctx.getValue().loadAgentHistory("agent-1", { limit: 2 }); + }); + await act(async () => { + await ctx.getValue().loadAgentHistory("agent-1", { limit: 2 }); + }); + + expect(ingestDomainOutboxEntries).toHaveBeenCalledTimes(2); + expect(ingestDomainOutboxEntries).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([expect.objectContaining({ id: 5, createdAt: "2026-03-01T00:00:05.000Z" })]) + ); + expect(ingestDomainOutboxEntries).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([expect.objectContaining({ id: 5, createdAt: "2026-03-02T00:00:05.000Z" })]) + ); + + vi.unstubAllGlobals(); + ctx.unmount(); + }); });