Harden control-plane startup, domain WS behavior, and runtime route reliability

This commit is contained in:
George Pickett
2026-03-02 12:52:39 -08:00
parent adb41eabdb
commit aab2579675
105 changed files with 3501 additions and 714 deletions
+1
View File
@@ -0,0 +1 @@
20.9.0
+2
View File
@@ -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 `<state dir>/openclaw-studio/settings.json` and the WS proxy loads them via `server/studio-settings.js`. State path resolution lives in `lib/clawdbot/paths.ts`, honoring `OPENCLAW_STATE_DIR`. When Studio token is missing, settings loaders can fall back to token/port from `<state dir>/openclaw.json`. Loopback-IP gateway URLs are normalized to `localhost` in Studio settings, and the WS proxy rewrites loopback upstream origins to `localhost` for control-UI secure-context compatibility. The optional Studio access gate is enabled by `STUDIO_ACCESS_TOKEN` (`server/access-gate.js`).
- **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**:
+22 -4
View File
@@ -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)://<studio-host>: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:
- Studios 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, its 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.
+3 -1
View File
@@ -1,5 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
const nextConfig: NextConfig = {
serverExternalPackages: ["ws", "better-sqlite3"],
};
export default nextConfig;
+1
View File
@@ -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"
+10 -1
View File
@@ -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"
+110
View File
@@ -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)");
+21 -1
View File
@@ -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",
+3
View File
@@ -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");
+79
View File
@@ -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<GatewayConfigSnapshot>("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 });
}
}
@@ -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<string, unknown> & { id: string };
type GatewayAgentToolsOverrides = {
allow?: string[];
alsoAllow?: string[];
deny?: string[];
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
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<string, unknown> | 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<string, unknown>,
list: ConfigAgentEntry[]
): Record<string, unknown> => {
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<string, unknown>;
hash?: string;
exists?: boolean;
}): Record<string, unknown> => {
const payload: Record<string, unknown> = {
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<string, unknown>;
snapshotHash?: string;
snapshotExists?: boolean;
overrides: GatewayAgentToolsOverrides;
attempt?: number;
}): Promise<void> => {
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<GatewayConfigSnapshot>("config.get", {});
const retryConfig = isRecord(retrySnapshot.config)
? (retrySnapshot.config as Record<string, unknown>)
: {};
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<GatewayConfigSnapshot>("config.get", {});
const baseConfig = isRecord(snapshot.config) ? (snapshot.config as Record<string, unknown>) : {};
const list = readConfigAgentList(baseConfig);
const configEntry = list.find((entry) => entry.id === agentId) ?? null;
const sandboxRaw =
configEntry && isRecord(configEntry.sandbox) ? (configEntry.sandbox as Record<string, unknown>) : 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 });
}
}
@@ -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<ExecutionRoleId>(["conservative", "collaborative", "autonomous"]);
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const hasFilePayload = "file" in bodyOrError;
if (hasFilePayload) {
const baseHash = typeof bodyOrError.baseHash === "string" ? bodyOrError.baseHash.trim() : "";
return await executeGatewayIntent("exec.approvals.set", {
file: bodyOrError.file,
...(baseHash ? { baseHash } : {}),
});
}
const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : "";
const role = typeof bodyOrError.role === "string" ? bodyOrError.role.trim() : "";
if (!agentId || !VALID_ROLES.has(role as ExecutionRoleId)) {
return NextResponse.json({ error: "agentId and valid role are required." }, { status: 400 });
}
const runtimeOrError = await ensureDomainIntentRuntime();
if (runtimeOrError instanceof Response) {
return runtimeOrError as NextResponse;
}
try {
await upsertAgentExecApprovalsPolicyViaRuntime({
runtime: runtimeOrError,
agentId,
role: role as ExecutionRoleId,
});
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "exec_approvals_set_failed";
return NextResponse.json({ error: message }, { status: 400 });
}
}
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
const hasOwn = (value: Record<string, unknown>, 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 } : {}),
});
}
@@ -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 }
);
+73 -15
View File
@@ -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 });
}
+2 -3
View File
@@ -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" } }
);
+14 -9
View File
@@ -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();
@@ -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 };
@@ -15,7 +15,7 @@ export type ExecApprovalEventEffects = {
markActivityAgentIds: string[];
};
export type ExecApprovalFollowUpIntent = {
type ExecApprovalFollowUpIntent = {
shouldSend: boolean;
agentId: string | null;
sessionKey: string | null;
@@ -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<unknown>;
};
import type { RuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport";
type SetState<T> = (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<void> => {
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)))(
@@ -11,12 +11,12 @@ import type { AgentState } from "@/features/agents/state/store";
type GatewayEventFrame = Parameters<typeof planIngressCommands>[0]["event"];
export type PauseRunControlPlan = {
type PauseRunControlPlan = {
stalePausedAgentIds: string[];
pauseIntent: ReturnType<typeof planPauseRunIntent>;
};
export type AutoResumeRunControlPlan = {
type AutoResumeRunControlPlan = {
preWaitIntent: ReturnType<typeof planAutoResumeIntent>;
postWaitIntent: ReturnType<typeof planAutoResumeIntent>;
};
@@ -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 };
+2 -2
View File
@@ -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;
@@ -53,7 +53,7 @@ type ExecApprovalsSnapshot = {
};
};
export type HydrateAgentFleetResult = {
type HydrateAgentFleetResult = {
seeds: AgentStoreSeed[];
sessionCreatedAgentIds: string[];
sessionSettingsSyncedAgentIds: string[];
@@ -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[];
@@ -9,7 +9,7 @@ type GatewayClientLike = {
call: (method: string, params: unknown) => Promise<unknown>;
};
export type ReconcileCommand =
type ReconcileCommand =
| { kind: "clearRunTracking"; runId: string }
| { kind: "dispatchUpdateAgent"; agentId: string; patch: Partial<AgentState> }
| { kind: "requestHistoryRefresh"; agentId: string }
@@ -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;
@@ -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 };
@@ -1,6 +1,6 @@
import type { GatewayStatus } from "./gatewayRestartPolicy";
export type ConfigMutationGateInput = {
type ConfigMutationGateInput = {
status: GatewayStatus;
hasRunningAgents: boolean;
nextMutationRequiresIdleAgents: boolean;
@@ -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<void> {
await updateAgentPermissionsViaStudio({
client: params.client,
runtimeWriteTransport: params.runtimeWriteTransport,
agentId: params.agentId,
sessionKey: params.sessionKey,
draft: params.draft,
@@ -1,4 +1,4 @@
export type CreateBootstrapFacts = {
type CreateBootstrapFacts = {
completion: { agentId: string; agentName: string };
createdAgent: { agentId: string; sessionKey: string } | null;
bootstrapErrorMessage: string | null;
@@ -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;
@@ -1,8 +1,8 @@
import type { AgentState } from "@/features/agents/state/store";
export type SummarySnapshotSeed = Pick<AgentState, "sessionCreated" | "sessionKey">;
type SummarySnapshotSeed = Pick<AgentState, "sessionCreated" | "sessionKey">;
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";
};
@@ -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;
@@ -1,6 +1,6 @@
export type GatewayStatus = "disconnected" | "connecting" | "connected";
export type RestartObservation = {
type RestartObservation = {
sawDisconnect: boolean;
};
@@ -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:
@@ -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";
@@ -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<void>;
shouldAwaitRemoteRestart: () => Promise<boolean>;
};
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;
};
@@ -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<AgentState, "agentId" | "status">;
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";
@@ -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<unknown>;
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<unknown>;
agentCreate: (params: { name: string }) => Promise<{ id: string; name: string }>;
chatAbort: (params: { sessionKey: string }) => Promise<void>;
sessionsReset: (params: { key: string }) => Promise<void>;
agentRename: (params: { agentId: string; name: string }) => Promise<void>;
agentDelete: (params: { agentId: string }) => Promise<void>;
execApprovalResolve: (params: { id: string; decision: string }) => Promise<void>;
execApprovalsSet: (params: { agentId: string; role: RuntimeWriteExecutionRole }) => Promise<void>;
agentPermissionsUpdate: (params: {
agentId: string;
sessionKey: string;
commandMode: "off" | "ask" | "auto";
webAccess: boolean;
fileTools: boolean;
}) => Promise<void>;
agentWait: (params: { runId: string; timeoutMs?: number }) => Promise<void>;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
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 = <T>(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<string, unknown>) => Promise<unknown>;
}): 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<unknown>(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<unknown>(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 } : {}),
});
},
};
}
@@ -42,7 +42,7 @@ const findLatestHeartbeatResponse = (messages: ChatHistoryMessage[]) => {
return latestResponse;
};
export type SpecialLatestUpdateDeps = {
type SpecialLatestUpdateDeps = {
callGateway: (method: string, params: unknown) => Promise<unknown>;
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<void>;
refreshHeartbeat: (agents: AgentState[]) => void;
clearInFlight: (agentId: string) => void;
@@ -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<StudioBootstrapLoadCommand[]> {
try {
const result = isStudioDomainIntentModeEnabled()
const result = params.useDomainApiMode
? (
await fetchJson<{ result: Awaited<ReturnType<typeof hydrateAgentFleetFromGateway>> }>(
"/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;
@@ -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;
};
@@ -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<string, SkillSetupMessage>;
type RestartingMutationBlockState = MutationBlockState & { kind: MutationWorkflowKind };
type SkillSetupMessage = { kind: "success" | "error"; message: string };
type SkillSetupMessageMap = Record<string, SkillSetupMessage>;
type AgentForSettingsMutation = Pick<AgentState, "agentId" | "name" | "sessionKey">;
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<AgentState>) => 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<SkillStatusReport | null>(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();
@@ -24,7 +24,7 @@ type QueuedConfigMutation = {
reject: (error: unknown) => void;
};
export type ActiveConfigMutation = {
type ActiveConfigMutation = {
kind: ConfigMutationKind;
label: string;
};
@@ -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<GatewayModelPolicySnapshot | null>;
};
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<GatewayModelPolicySnapshot>("config.get", {});
params.setGatewayConfigSnapshot(snapshot);
const snapshot = await client.call<GatewayModelPolicySnapshot>("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<GatewayModelPolicySnapshot>("config.get", {});
configSnapshot = await client.call<GatewayModelPolicySnapshot>("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 {
@@ -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<void>;
loadAgentHistory: (agentId: string, options?: { limit?: number }) => Promise<void>;
loadAgentHistory: (
agentId: string,
options?: { limit?: number; beforeOutboxId?: number }
) => Promise<void>;
loadMoreAgentHistory: (agentId: string) => void;
reconcileRunningAgents: () => Promise<void>;
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<Set<string>>(new Set());
const reconcileRunInFlightRef = useRef<Set<string>>(new Set());
const domainHistoryCursorByAgentRef = useRef<Map<string, number | null>>(new Map());
const seenDomainOutboxKeysRef = useRef<Set<string>>(new Set());
const seenDomainOutboxKeyOrderRef = useRef<string[]>([]);
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<SummaryStatusSnapshot>("status", {}),
params.client.call<SummaryPreviewSnapshot>("sessions.preview", {
client.call<SummaryStatusSnapshot>("status", {}),
client.call<SummaryPreviewSnapshot>("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<DomainAgentHistoryResponse>(
`/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,
@@ -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 {
@@ -11,7 +11,7 @@ export type CronTranscriptIntent = {
activityAtMs: number | null;
};
export type GatewayEventIngressDecision = {
type GatewayEventIngressDecision = {
approvalEffects: ExecApprovalEventEffects | null;
cronDedupeKeyToRecord: string | null;
cronTranscriptIntent: CronTranscriptIntent | null;
@@ -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;
@@ -65,7 +65,7 @@ export type RuntimeAgentWorkflowInput = {
lifecycleFallbackDelayMs: number;
};
export type RuntimeAgentWorkflowResult = {
type RuntimeAgentWorkflowResult = {
commands: RuntimeAgentWorkflowCommand[];
};
@@ -45,7 +45,7 @@ export type RuntimeChatWorkflowInput = {
thinkingStartedAtMs: number | null;
};
export type RuntimeChatWorkflowResult = {
type RuntimeChatWorkflowResult = {
commands: RuntimeChatWorkflowCommand[];
};
@@ -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<string, unknown>;
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"
@@ -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<string>;
assistantStreamByRun: Map<string, string>;
@@ -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;
};
@@ -45,7 +45,7 @@ type LifecycleTerminalFallbackFireDecisionInput = {
runId?: string | null;
};
export type LifecycleTerminalDecisionInput =
type LifecycleTerminalDecisionInput =
| LifecycleTerminalEventDecisionInput
| LifecycleTerminalFallbackFireDecisionInput;
@@ -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;
+1 -1
View File
@@ -108,7 +108,7 @@ export const buildNewSessionAgentPatch = (agent: AgentState): Partial<AgentState
};
};
export type AgentStoreState = {
type AgentStoreState = {
agents: AgentState[];
selectedAgentId: string | null;
loading: boolean;
+3 -3
View File
@@ -4,14 +4,14 @@ import { randomUUID } from "node:crypto";
import { resolveStateDir } from "@/lib/clawdbot/paths";
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[];
};
+2 -2
View File
@@ -10,13 +10,13 @@ type RuntimeProbeCommandResult =
| { ok: true; value: unknown }
| { ok: false; error: string };
export type RuntimeProbeSnapshot = {
type RuntimeProbeSnapshot = {
at: string;
status: RuntimeProbeCommandResult;
sessions: RuntimeProbeCommandResult;
};
export type RuntimeFreshness = {
type RuntimeFreshness = {
source: "gateway" | "projection" | "probe";
stale: boolean;
asOf: string | null;
-7
View File
@@ -1,7 +0,0 @@
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
export const isStudioDomainIntentModeEnabled = (): boolean => {
const raw = process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE?.trim().toLowerCase() ?? "";
if (!raw) return true;
return !FALSE_VALUES.has(raw);
};
+37 -22
View File
@@ -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<void> => {
const agentId = params.agentId.trim();
if (!agentId) {
throw new Error("Agent id is required.");
}
const snapshot = await params.runtime.callGateway<ExecApprovalsSnapshot>("exec.approvals.get", {});
const 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<void> => {
const agentId = params.agentId.trim();
if (!agentId) {
throw new Error("Agent id is required.");
}
const snapshot = await params.runtime.callGateway<ExecApprovalsSnapshot>("exec.approvals.get", {});
const 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<ExecApprovalsSnapshot>("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 } : {}),
});
}
+15 -10
View File
@@ -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<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -19,22 +21,25 @@ const isConfigConflict = (error: ControlPlaneGatewayError): boolean => {
};
export const ensureDomainIntentRuntime = async (): Promise<
ReturnType<typeof getControlPlaneRuntime> | 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<Record<string, unknown> | Response> => {
+10 -4
View File
@@ -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<string>([
"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 },
},
+158 -7
View File
@@ -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<string, unknown> =>
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");
}
}
}
-38
View File
@@ -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<string, unknown> =>
Boolean(value && typeof value === "object");
const parseAgentIdFromSessionKey = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const match = value.match(AGENT_SESSION_KEY_RE);
return match ? match[1] : null;
};
const resolveAgentIdForDomainEvent = (event: ControlPlaneDomainEvent): string | null => {
if (event.type !== "gateway.event") return null;
const payload = event.payload;
if (!isObject(payload)) return null;
const directAgentId = typeof payload.agentId === "string" ? payload.agentId.trim() : "";
if (directAgentId) return directAgentId;
const fromSession =
parseAgentIdFromSessionKey(payload.sessionKey) ??
parseAgentIdFromSessionKey(payload.key) ??
parseAgentIdFromSessionKey(payload.runSessionKey);
return fromSession;
};
export const selectAgentHistoryEntries = (
entries: ControlPlaneOutboxEntry[],
agentId: string,
limit: number
): ControlPlaneOutboxEntry[] => {
const normalizedAgent = agentId.trim();
if (!normalizedAgent) return [];
const filtered = entries.filter((entry) => resolveAgentIdForDomainEvent(entry.event) === normalizedAgent);
if (limit <= 0) return [];
if (filtered.length <= limit) return filtered;
return filtered.slice(filtered.length - limit);
};
+107
View File
@@ -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<string, unknown> =>
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 } : {}),
});
@@ -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<DomainRuntimeBootstrapResult> {
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 };
}
}
+22 -7
View File
@@ -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<void> {
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);
}
}
}
}
+4 -4
View File
@@ -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;
+2 -2
View File
@@ -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;
+32 -9
View File
@@ -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(() => {
+12 -12
View File
@@ -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<string, unknown>;
hash?: string;
exists?: boolean;
@@ -55,12 +55,12 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
export type ConfigAgentEntry = Record<string, unknown> & { 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();
+1 -1
View File
@@ -1,4 +1,4 @@
export type GatewayErrorPayload = {
type GatewayErrorPayload = {
code: string;
message: string;
details?: unknown;
+2 -2
View File
@@ -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;
@@ -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;
+3 -3
View File
@@ -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<SkillSourceGroupId, "other">; label: string }> = [
{ id: "workspace", label: "Workspace Skills" },
+4 -4
View File
@@ -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<string, unknown>;
+3 -3
View File
@@ -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[];
};
+1 -1
View File
@@ -15,7 +15,7 @@ export type StudioSettingsResponse = {
type FocusedPatch = Record<string, Partial<StudioFocusedPreference> | null>;
type AvatarsPatch = Record<string, Record<string, string | null> | null>;
export type StudioSettingsCoordinatorTransport = {
type StudioSettingsCoordinatorTransport = {
fetchSettings: () => Promise<StudioSettingsResponse>;
updateSettings: (patch: StudioSettingsPatch) => Promise<StudioSettingsResponse>;
};
+1 -1
View File
@@ -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;
};
+7 -24
View File
@@ -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();
});
+4 -23
View File
@@ -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 }) => {
+3 -1
View File
@@ -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();
+4 -2
View File
@@ -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) => {
+9 -3
View File
@@ -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) => {
+6 -24
View File
@@ -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();
});
+163
View File
@@ -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<string, unknown> }>;
suggestedSelectedAgentId: string | null;
configSnapshot: Record<string, unknown> | 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",
},
});
});
};
+20 -6
View File
@@ -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<string, { mode: "focused"; filter: string; selectedAgentId: string | null }>;
avatars: Record<string, Record<string, string>>;
};
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));
};
+2
View File
@@ -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 }) => {
@@ -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();
});
+21 -48
View File
@@ -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");
@@ -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<string, unknown> };
};
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);
});
});
+1
View File
@@ -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";
@@ -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<PendingExecApproval[]>([]);
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(
+160
View File
@@ -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<void>((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<void>((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<void>((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)]);
}
});
});
+40
View File
@@ -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,
+2 -2
View File
@@ -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();
});
+106
View File
@@ -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);
});
});
+114
View File
@@ -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<typeof import("@/lib/controlplane/degraded-read")>(
"@/lib/controlplane/degraded-read"
);
return {
...actual,
probeOpenClawLocalState: vi.fn(async () => ({
at: "2026-02-28T02:41:00.000Z",
status: { ok: false, error: "openclaw_cli_not_found" },
sessions: { ok: false, error: "openclaw_cli_not_found" },
})),
};
});
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", () => ({

Some files were not shown because too many files have changed in this diff Show More