mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
Harden control-plane startup, domain WS behavior, and runtime route reliability
This commit is contained in:
@@ -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,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:
|
||||
- Studio’s server-owned control plane stores durable runtime projection + replay outbox in `runtime.db`.
|
||||
- This keeps runtime history and SSE replay deterministic across page refreshes and process restarts.
|
||||
|
||||
## UI guide
|
||||
|
||||
See `docs/ui-guide.md` for UI workflows (agent creation, cron jobs, exec approvals).
|
||||
@@ -127,6 +139,12 @@ If the UI loads but “Connect” fails, it’s usually Studio->Gateway:
|
||||
- 401 “Studio access token required”: `STUDIO_ACCESS_TOKEN` is enabled; open `/?access_token=...` once to set the cookie.
|
||||
- Helpful error codes: `studio.gateway_url_missing`, `studio.gateway_token_missing`, `studio.upstream_error`, `studio.upstream_closed`.
|
||||
|
||||
If startup fails with `better_sqlite3.node` / `NODE_MODULE_VERSION` mismatch:
|
||||
- Run `npm run verify:native-runtime:repair`
|
||||
- If it still fails, run:
|
||||
- `npm rebuild better-sqlite3`
|
||||
- `npm install`
|
||||
|
||||
## Architecture
|
||||
|
||||
See `ARCHITECTURE.md` for details on modules and data flow.
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["ws", "better-sqlite3"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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" } }
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -108,7 +108,7 @@ export const buildNewSessionAgentPatch = (agent: AgentState): Partial<AgentState
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentStoreState = {
|
||||
type AgentStoreState = {
|
||||
agents: AgentState[];
|
||||
selectedAgentId: string | null;
|
||||
loading: boolean;
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
export type GatewayErrorPayload = {
|
||||
type GatewayErrorPayload = {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user