From e1b78696da7785057ecf5aa9323812037e819bd8 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Mon, 2 Mar 2026 17:18:53 -0800 Subject: [PATCH] big refactor in progress --- ARCHITECTURE.md | 265 ++----- README.md | 12 +- docs/control-plane-cutover-audit.md | 203 +++++ docs/pi-chat-streaming.md | 404 +--------- package-lock.json | 14 +- package.json | 8 +- scripts/sync-openclaw-gateway-client.ts | 34 - scripts/verify-native-runtime.mjs | 70 +- server/gateway-proxy.js | 295 ------- server/index.js | 57 +- src/app/api/intents/agent-file-set/route.ts | 25 + .../intents/agent-skills-allowlist/route.ts | 260 +++++++ src/app/api/intents/chat-abort/route.ts | 3 +- src/app/api/intents/cron-add/route.ts | 20 + src/app/api/intents/cron-remove/route.ts | 19 + src/app/api/intents/cron-run/route.ts | 22 + src/app/api/intents/skills-install/route.ts | 30 + .../remove => intents/skills-remove}/route.ts | 0 src/app/api/intents/skills-update/route.ts | 32 + src/app/api/runtime/agent-file/route.ts | 19 + .../{gateway => runtime}/agent-state/route.ts | 0 src/app/api/runtime/chat-history/route.ts | 20 + src/app/api/runtime/config/route.ts | 7 + src/app/api/runtime/cron/route.ts | 11 + .../api/{gateway => runtime}/media/route.ts | 0 src/app/api/runtime/models/route.ts | 7 + src/app/api/runtime/sessions/route.ts | 21 + src/app/api/runtime/skills/status/route.ts | 14 + src/app/api/studio/route.ts | 91 ++- src/app/page.tsx | 173 +++-- .../execApprovalControlLoopWorkflow.ts | 2 +- .../agents/approvals/execApprovalEvents.ts | 2 +- .../execApprovalLifecycleWorkflow.ts | 2 +- .../execApprovalRunControlOperation.ts | 2 +- .../agents/components/AgentInspectPanels.tsx | 38 +- .../agents/components/ConnectionPanel.tsx | 2 +- .../components/GatewayConnectScreen.tsx | 2 +- src/features/agents/components/HeaderBar.tsx | 2 +- .../agents/components/colorSemantics.ts | 2 +- .../agents/operations/agentFleetHydration.ts | 36 +- .../operations/agentPermissionsOperation.ts | 4 +- .../operations/agentReconcileOperation.ts | 18 +- .../operations/chatInteractionWorkflow.ts | 2 +- .../agents/operations/cronCreateOperation.ts | 16 +- .../agents/operations/deleteAgentOperation.ts | 4 +- .../operations/mutationLifecycleWorkflow.ts | 6 +- .../operations/runtimeWriteTransport.ts | 39 +- .../useAgentSettingsMutationController.ts | 247 ++++-- .../useChatInteractionController.ts | 8 +- .../useGatewayConfigSyncController.ts | 29 +- .../state/gatewayEventIngressWorkflow.ts | 2 +- .../state/gatewayRuntimeEventHandler.ts | 11 +- .../agents/state/runtimeChatEventWorkflow.ts | 5 +- .../agents/state/runtimeEventBridge.ts | 4 +- .../agents/state/runtimeEventPolicy.ts | 4 +- .../agents/state/runtimeTerminalWorkflow.ts | 4 +- .../agents/state/sessionSettingsMutations.ts | 8 +- src/lib/controlplane/domain-runtime-client.ts | 219 ++++++ src/lib/controlplane/openclaw-adapter.ts | 11 +- src/lib/controlplane/projection-store.ts | 49 +- src/lib/controlplane/runtime-read-route.ts | 65 ++ src/lib/controlplane/runtime.ts | 34 +- src/lib/cron/types.ts | 2 +- src/lib/gateway/GatewayClient.ts | 724 +----------------- src/lib/gateway/agentConfig.ts | 57 +- src/lib/gateway/agentFiles.ts | 32 +- src/lib/gateway/execApprovals.ts | 34 +- src/lib/gateway/gateway-disconnect.ts | 33 + src/lib/gateway/gateway-frames.ts | 43 ++ src/lib/gateway/gateway-status.ts | 6 + src/lib/gateway/gatewayReloadMode.ts | 20 +- .../gateway/openclaw/GatewayBrowserClient.ts | 643 ---------------- src/lib/gateway/proxy-url.ts | 6 - src/lib/gateway/session-settings-sync.ts | 80 ++ src/lib/skills/remove.ts | 2 +- src/lib/skills/types.ts | 4 +- src/lib/studio/settings.ts | 6 +- src/lib/studio/useStudioGatewaySettings.ts | 243 ++++++ src/lib/text/media-markdown.ts | 2 +- tests/setup.ts | 7 - tests/unit/agentBrainPanel.test.ts | 148 ++-- tests/unit/agentStateRoute.test.ts | 12 +- tests/unit/controlPlaneRuntime.test.ts | 17 +- tests/unit/deleteAgentOperation.test.ts | 6 +- tests/unit/gatewayBrowserClient.test.ts | 136 ---- .../unit/gatewayClient.connectErrors.test.ts | 52 -- tests/unit/gatewayClient.gap.test.ts | 45 -- .../gatewayClient.reconnect-recovery.test.ts | 134 ---- tests/unit/gatewayConnectRetryPolicy.test.ts | 36 - tests/unit/gatewayFrames.test.ts | 2 +- tests/unit/gatewayMediaRoute.test.ts | 9 +- tests/unit/gatewayProxy.test.ts | 712 ----------------- .../gatewayRuntimeEventHandler.chat.test.ts | 52 ++ tests/unit/mediaMarkdown.test.ts | 4 +- tests/unit/openclawAdapter.test.ts | 4 + tests/unit/runtimeEventPolicy.test.ts | 50 ++ tests/unit/runtimeWriteTransport.test.ts | 31 + tests/unit/sessionKey.test.ts | 2 +- tests/unit/sessionSettings.test.ts | 6 +- tests/unit/skillsRemoveClient.test.ts | 2 +- tests/unit/skillsRemoveRoute.test.ts | 8 +- tests/unit/studioSettingsRoute.test.ts | 4 +- ...useAgentSettingsMutationController.test.ts | 12 +- .../useGatewayConfigSyncController.test.ts | 4 + tests/unit/useGatewayConnection.test.ts | 440 ----------- 105 files changed, 2589 insertions(+), 4298 deletions(-) create mode 100644 docs/control-plane-cutover-audit.md delete mode 100644 scripts/sync-openclaw-gateway-client.ts delete mode 100644 server/gateway-proxy.js create mode 100644 src/app/api/intents/agent-file-set/route.ts create mode 100644 src/app/api/intents/agent-skills-allowlist/route.ts create mode 100644 src/app/api/intents/cron-add/route.ts create mode 100644 src/app/api/intents/cron-remove/route.ts create mode 100644 src/app/api/intents/cron-run/route.ts create mode 100644 src/app/api/intents/skills-install/route.ts rename src/app/api/{gateway/skills/remove => intents/skills-remove}/route.ts (100%) create mode 100644 src/app/api/intents/skills-update/route.ts create mode 100644 src/app/api/runtime/agent-file/route.ts rename src/app/api/{gateway => runtime}/agent-state/route.ts (100%) create mode 100644 src/app/api/runtime/chat-history/route.ts create mode 100644 src/app/api/runtime/config/route.ts create mode 100644 src/app/api/runtime/cron/route.ts rename src/app/api/{gateway => runtime}/media/route.ts (100%) create mode 100644 src/app/api/runtime/models/route.ts create mode 100644 src/app/api/runtime/sessions/route.ts create mode 100644 src/app/api/runtime/skills/status/route.ts create mode 100644 src/lib/controlplane/domain-runtime-client.ts create mode 100644 src/lib/controlplane/runtime-read-route.ts create mode 100644 src/lib/gateway/gateway-disconnect.ts create mode 100644 src/lib/gateway/gateway-frames.ts create mode 100644 src/lib/gateway/gateway-status.ts delete mode 100644 src/lib/gateway/openclaw/GatewayBrowserClient.ts delete mode 100644 src/lib/gateway/proxy-url.ts create mode 100644 src/lib/gateway/session-settings-sync.ts create mode 100644 src/lib/studio/useStudioGatewaySettings.ts delete mode 100644 tests/unit/gatewayBrowserClient.test.ts delete mode 100644 tests/unit/gatewayClient.connectErrors.test.ts delete mode 100644 tests/unit/gatewayClient.gap.test.ts delete mode 100644 tests/unit/gatewayClient.reconnect-recovery.test.ts delete mode 100644 tests/unit/gatewayConnectRetryPolicy.test.ts delete mode 100644 tests/unit/gatewayProxy.test.ts delete mode 100644 tests/unit/useGatewayConnection.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f6643fc..94c7b22 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,203 +1,90 @@ # Architecture -## High-level overview & goals -OpenClaw Studio is a gateway-first, single-user Next.js App Router UI for managing OpenClaw agents. It provides: -- A focused UI with fleet list, primary agent panel, and inspect sidebar. -- Local persistence for gateway connection + focused-view preferences via a JSON settings file. -- A same-origin WebSocket bridge (`/api/gateway/ws`) from browser to the upstream OpenClaw gateway. -- Gateway-backed edits for agent config and agent files. +## Overview +OpenClaw Studio now uses a single runtime architecture: -Primary goals: -- **Gateway-first**: agents, sessions, and config live in the gateway; Studio stores only UI settings. -- **Remote-friendly**: tailnet/remote gateways are first-class. -- **Clear boundaries**: client UI vs server routes vs external gateway/config. -- **Predictable state**: gateway is source of truth; local settings only for focused preferences + connection. -- **Maintainability**: feature-focused modules, minimal abstractions. +1. Browser -> Studio domain APIs (`/api/runtime/*`, `/api/intents/*`) +2. Browser -> Studio SSE stream (`/api/runtime/stream`) +3. Studio server -> OpenClaw Gateway (server-owned WebSocket adapter) -Non-goals: -- Multi-tenant or multi-user concurrency. -- Server-side rendering of data from external services. +The browser no longer opens a direct gateway transport and no `/api/gateway/ws` bridge exists in production runtime. -## Architectural style -**Layered + vertical slice (feature-first)** within Next.js App Router: -- UI components + feature state in `src/features`. -- Shared utilities and adapters in `src/lib`. -- Server-side route handlers under `src/app/api`. +## Core boundaries -This keeps feature cohesion high while preserving a clear client/server boundary. +### Browser boundary +- UI state and orchestration live under `src/features/agents` and `src/app/page.tsx`. +- Browser reads and writes only through Studio HTTP routes and SSE. +- Runtime events are consumed from `/api/runtime/stream` and funneled through `gatewayRuntimeEventHandler` and approval ingress workflows. -## Main modules / bounded contexts -- **Focused agent UI** (`src/features/agents`): focused agent panel, fleet sidebar, inspect panel, and local in-memory state + actions. The fleet sidebar keeps the same **New Agent** entry point, now implemented as a one-step `AgentCreateModal` flow (name + avatar + launch). Creation is create-only: `src/app/page.tsx` calls `runCreateAgentMutationLifecycle` and `createGatewayAgent`, then reloads fleet state and focuses chat for the new agent; no guided setup compilation, deferred setup persistence, or pending retry UI remains in runtime flows. Agents render a status-first summary and latest-update preview driven by gateway events. Per-agent runtime controls (`model`, `thinking`) live in the chat header (`AgentChatPanel`), active runs can be stopped from the chat composer via `chat.abort`, and pending exec approvals render in-chat action cards (`Allow once`, `Always allow`, `Deny`) while fleet rows show `Needs approval`. Settings sidebar actions remain focused on rename, display toggles, permission updates (`updateAgentPermissionsViaStudio`), new session, cron list/run/delete/create, and delete (`AgentSettingsPanel`). The Skills section in `AgentSettingsPanel` is split into `Access` (per-agent allowlist mode + toggles) and `Library` (gateway-wide setup actions in modal flow). Cron creation continues to use a guided modal scoped to the selected settings agent. Gateway event classification (`presence`/`heartbeat` summary refresh and `chat`/`agent` runtime streams) is centralized in bridge helpers (`src/features/agents/state/runtimeEventBridge.ts`), while runtime flow decisions are emitted from pure policy helpers (`src/features/agents/state/runtimeEventPolicy.ts`) and executed by `src/features/agents/state/gatewayRuntimeEventHandler.ts`; both are consumed from one gateway subscription path in `src/app/page.tsx`, where exec approval events are handled in parallel. Higher-level orchestration is factored into operations under `src/features/agents/operations/` (fleet hydration snapshots in `agentFleetHydration.ts`, pure fleet hydration derivation in `agentFleetHydrationDerivation.ts`, chat send in `chatSendOperation.ts`, cron create in `cronCreateOperation.ts`, mutation lifecycle policy (create/rename/delete) in `mutationLifecycleWorkflow.ts`, latest-update policy in `latestUpdateWorkflow.ts`, fleet summary/reconcile policy in `fleetLifecycleWorkflow.ts`, reconcile operation adapter in `agentReconcileOperation.ts`, history request/disposition policy in `historyLifecycleWorkflow.ts`, history sync operation adapter in `historySyncOperation.ts`, approval lifecycle policy in `src/features/agents/approvals/execApprovalLifecycleWorkflow.ts`, manual exec approval resolve operation in `src/features/agents/approvals/execApprovalResolveOperation.ts`, and execution primitives in `useConfigMutationQueue.ts` and `useGatewayRestartBlock.ts`). Rename/delete post-run UI side effects are emitted as typed mutation commands from `mutationLifecycleWorkflow.ts` and executed in `src/app/page.tsx`. Session setting mutations (model/thinking) are centralized in `src/features/agents/state/sessionSettingsMutations.ts` so optimistic state updates and sync/error behavior stay aligned. Transcript ownership is split intentionally: optimistic send appends local user transcript entries while canonical timestamps and final ordering come from `chat.history` sync via the history workflow boundary (`historyLifecycleWorkflow.ts`) and operation adapter (`historySyncOperation.ts`); replayed terminal chat events and late deltas from recently closed runs are ignored in `gatewayRuntimeEventHandler`, which requests recovery history only through the `requestHistoryRefresh` boundary command. Studio fetches a capped amount of chat history by default (currently 200 messages) and exposes a “Load more” affordance when the transcript may be truncated. Disconnected startup now uses a status-first `GatewayConnectScreen` with a local command copy affordance and a collapsible remote form. -- **Studio settings** (`src/lib/studio`, `src/app/api/studio`): local settings store for gateway URL/token and focused preferences (`src/lib/studio/settings.ts`, `src/lib/studio/settings-store.ts`, `src/app/api/studio/route.ts`). `src/lib/studio/coordinator.ts` now owns both the `/api/studio` transport helpers and shared client-side load/patch scheduling for gateway and focused settings. The `/api/studio` envelope also carries `domainApiModeEnabled`, and the UI treats that server-reported flag as the authoritative domain-mode source. -- **Gateway** (`src/lib/gateway`): WebSocket client for agent runtime (frames, connect, request/response). Session settings sync transport (`sessions.patch`) is centralized in `src/lib/gateway/GatewayClient.ts`. Connect failures surfaced through the Studio WS proxy are preserved as `GatewayResponseError` codes (parsed from `connect failed: ...`) so `useGatewayConnection` can gate auto-retry via `resolveGatewayAutoRetryDelayMs`. The OpenClaw control UI client is vendored in `src/lib/gateway/openclaw/GatewayBrowserClient.ts` with a sync script at `scripts/sync-openclaw-gateway-client.ts`. -- **Studio gateway proxy server** (`server/index.js`, `server/gateway-proxy.js`, `server/studio-settings.js`): custom Next server that terminates browser WS at `/api/gateway/ws`, loads upstream gateway URL/token server-side, injects auth token when needed, and forwards frames to the upstream gateway. -- **Gateway SSH helpers** (`src/lib/ssh/gateway-host.ts`): shared SSH target resolution and JSON-over-SSH execution for server routes. `runSshJson` centralizes `ssh -o BatchMode=yes` invocation, JSON parsing, and actionable error extraction; callers with large payloads (for example base64 media reads) can opt into a higher `maxBuffer` rather than duplicating `spawnSync` calls. -- **Gateway-backed config + agent-file edits** (`src/lib/gateway/agentConfig.ts`, `src/lib/gateway/agentFiles.ts`, `src/lib/gateway/execApprovals.ts`, `src/features/agents/components/AgentInspectPanels.tsx`): agent create/rename/heartbeat/delete and per-agent overrides via `config.get` + `config.patch`, agent file read/write via `agents.files.get` and `agents.files.set`, and per-agent exec approvals via `exec.approvals.get` + `exec.approvals.set`. -- **Heartbeat helpers** (`src/lib/gateway/agentConfig.ts`): resolves per-agent heartbeat state (enabled + schedule) by combining gateway config (`config.get`) and status (`status`) for the settings panel, triggers `wake` for “run now”, and owns the heartbeat type shapes and gateway config mutation helpers. -- **Session lifecycle actions** (`src/features/agents/state/store.tsx`, `src/app/page.tsx`): per-agent “New session” calls gateway `sessions.reset` on the current session key and resets local runtime transcript state. -- **Local OpenClaw config + paths** (`src/lib/clawdbot`): state/config path resolution with `OPENCLAW_*` env overrides (`src/lib/clawdbot/paths.ts`). Gateway URL/token in Studio are sourced from studio settings. -- **Shared agent config-list helpers** (`src/lib/gateway/agentConfig.ts`): pure `agents.list` read/write/upsert helpers used by gateway config patching to keep list-shape semantics aligned. -- **Shared utilities** (`src/lib/*`): env, ids, names, avatars, message parsing/normalization (including tool-line formatting) in `src/lib/text/message-extract.ts`, cron types + selector helpers + gateway call helpers in `src/lib/cron/types.ts`, logging, filesystem helpers. +### Server-owned control plane +- Control plane runtime modules: `src/lib/controlplane/*` + - `openclaw-adapter.ts`: upstream websocket lifecycle, handshake, request allowlist, reconnect policy. + - `runtime.ts`: process-local singleton runtime, subscription fanout, gateway call boundary. + - `projection-store.ts`: SQLite projection + outbox (`runtime.db`). +- Runtime read routes: + - `/api/runtime/summary` + - `/api/runtime/fleet` + - `/api/runtime/agents/[agentId]/history` + - `/api/runtime/stream` + - `/api/runtime/config`, `/api/runtime/models`, `/api/runtime/sessions`, `/api/runtime/chat-history`, `/api/runtime/cron`, `/api/runtime/skills/status`, `/api/runtime/agent-file`, `/api/runtime/agent-state`, `/api/runtime/media` +- Intent routes: + - `/api/intents/chat-send`, `/api/intents/chat-abort`, `/api/intents/sessions-reset` + - `/api/intents/agent-create`, `/api/intents/agent-rename`, `/api/intents/agent-delete`, `/api/intents/agent-wait` + - `/api/intents/agent-permissions-update`, `/api/intents/exec-approval-resolve`, `/api/intents/exec-approvals-set` + - `/api/intents/session-settings-sync` + - `/api/intents/cron-add`, `/api/intents/cron-run`, `/api/intents/cron-remove`, `/api/intents/cron-remove-agent`, `/api/intents/cron-restore` + - `/api/intents/skills-install`, `/api/intents/skills-update`, `/api/intents/skills-remove`, `/api/intents/agent-skills-allowlist`, `/api/intents/agent-file-set` -## Directory layout (top-level) -- `src/app`: Next.js App Router pages, layouts, global styles, and API routes. -- `src/features`: feature-first UI modules (currently focused agent-management components under `features/agents`). -- `src/lib`: domain utilities, adapters, API clients, and shared logic. -- `src/components`: shared UI components (minimal use today). -- `src/styles`: shared styling assets. -- `server`: custom Node server and WS proxy for gateway bridging + access gate. -- `public`: static assets. -- `tests`, `playwright.config.ts`, `vitest.config.ts`: automated testing. +### Settings boundary +- Studio settings route: `src/app/api/studio/route.ts` +- Persisted file: `~/.openclaw/openclaw-studio/settings.json` +- Gateway token is server-custodied and redacted from API responses. +- Gateway URL/token changes trigger deterministic control-plane reconnect via `runtime.reconnectForGatewaySettingsChange()`. -## Data flow & key boundaries -### 1) Studio settings + focused preferences -- **Source of truth**: JSON settings file at `~/.openclaw/openclaw-studio/settings.json` (resolved via `resolveStateDir` in `src/lib/clawdbot/paths.ts`). Settings store the gateway URL/token plus per-gateway focused preferences. -- **Server boundary**: `src/app/api/studio/route.ts` loads/saves settings by reading and writing `openclaw-studio/settings.json` under the resolved state dir. -- **Client boundary**: `useGatewayConnection` and focused/session flows in `src/app/page.tsx` use a shared `StudioSettingsCoordinator` to load settings and coalesce debounced `/api/studio` patch writes. +## Runtime durability model +- SQLite DB path: `${resolveStateDir()}/openclaw-studio/runtime.db` +- Projection store responsibilities: + - apply domain events idempotently + - persist ordered outbox rows + - serve replay/history windows +- SSE replay behavior: + - With `Last-Event-ID`: replay forward from that id. + - Without `Last-Event-ID`: replay recent tail window from outbox head. -Flow: -1. UI loads settings from `/api/studio`. -2. Gateway URL/token seed the connection panel and auto-connect. -3. Focused filter + selected agent are loaded for the current gateway. -4. UI schedules focused and gateway patches through the coordinator; both paths converge on `/api/studio`. +## History model +- Route: `/api/runtime/agents/[agentId]/history` +- Query: + - `limit` + - `beforeOutboxId` (exclusive cursor) +- Response: + - `entries` + - `hasMore` + - `nextBeforeOutboxId` +- Client side (`useRuntimeSyncController`) ingests fetched outbox rows into the same event pipeline as live SSE and dedupes by outbox id/time key. -### 2) Agent runtime (gateway) -- **Client-side boundary**: `GatewayClient` connects to Studio-origin `/api/gateway/ws` via `resolveStudioProxyGatewayUrl()` and wraps the vendored `GatewayBrowserClient`. -- **Server-side boundary**: custom server proxy (`server/gateway-proxy.js`) is in the middle for upstream URL/token resolution and connect-frame token injection. +## UI orchestration notes +- `src/app/page.tsx` remains top-level wiring: + - settings load + - fleet bootstrap + - stream subscription + - runtime sync polling/history load-more + - mutation controller wiring +- `runtimeWriteTransport` is intent-route based for runtime mutations. +- Settings/skills/cron/personality flows use domain clients in `src/lib/controlplane/domain-runtime-client.ts`. -Flow: -1. UI loads gateway URL/token from `/api/studio` (defaulting to `NEXT_PUBLIC_GATEWAY_URL`, or `ws://localhost:18789` when that env var is unset). -2. Browser opens WS to Studio `/api/gateway/ws` (`ws://` on `http`, `wss://` on `https`). -3. Proxy loads upstream URL/token from Studio settings on the server and opens upstream WS. -4. Proxy forwards `connect` and subsequent frames; it injects auth token server-side if the connect frame has none. -5. If upstream connect fails, the proxy sends an error response for the `connect` request (with a `studio.*` error code when possible) and closes the Studio-origin WS. The browser-side gateway client converts a failed `connect` response into a WS close with code `4008` and a reason like `connect failed: ...`; `GatewayClient.connect()` parses this into `GatewayResponseError`, and `useGatewayConnection` decides whether/when to auto-retry based on `connectErrorCode` (through `resolveGatewayAutoRetryDelayMs`). -6. UI requests `agents.list` and builds session keys via `buildAgentMainSessionKey(agentId, mainKey)`. -7. A single gateway listener in `src/app/page.tsx` classifies `presence`/`heartbeat`/`chat`/`agent` events through `classifyGatewayEventKind` in `src/features/agents/state/runtimeEventBridge.ts`, then routes runtime payloads through `src/features/agents/state/runtimeEventPolicy.ts` and executes intents via `src/features/agents/state/gatewayRuntimeEventHandler.ts`; it also independently tracks `exec.approval.requested` / `exec.approval.resolved` for in-chat approval cards. -8. Agent store updates agent output/state. -9. Pending approval queues are pruned by expiry timestamp (with a short grace window), so stale cards and stale `awaitingUserInput` badges self-clear even when no resolved event arrives. +## Removed legacy surfaces +- Browser gateway WS transport and vendored browser gateway client are removed from production runtime. +- Server gateway WS proxy bridge (`server/gateway-proxy.js`) is removed. +- Legacy `/api/gateway/*` route namespace was re-homed to `/api/runtime/*` and `/api/intents/*`. -### 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. -- **Domain history ingestion**: domain history responses are ingested into the same event pipelines used by live stream handling (`gatewayRuntimeEventHandler` and exec-approval ingress) with outbox-id dedupe in `useRuntimeSyncController`. +## Error semantics +- Gateway unavailable: deterministic `GATEWAY_UNAVAILABLE` shape from intent/runtime bootstrap helpers. +- Startup/read degradation: runtime read routes can return projection/probe-backed degraded responses with freshness metadata. +- Config/approvals conflict paths keep explicit conflict handling (base-hash retry where supported). -### 3) Agent create + per-agent setup -- **Agent files**: `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, `TOOLS.md`, `HEARTBEAT.md`, `MEMORY.md`. -- **Create + heartbeat + rename + per-agent overrides**: stored in gateway config and updated via `config.get` + `config.patch`. -- **Exec approvals policy**: managed after creation through settings/runtime approval flows. - -Flow: -1. `AgentCreateModal` captures only create payload (`name`, optional `avatarSeed`) in a one-step launch flow (`src/features/agents/components/AgentCreateModal.tsx`). -2. `runCreateAgentMutationLifecycle` enqueues one create mutation, applies queue guardrails, and drives create block status (`src/features/agents/operations/mutationLifecycleWorkflow.ts`). -3. `createGatewayAgent` calls `config.get` to derive workspace, then calls `agents.create` with `{ name, workspace }` (`src/lib/gateway/agentConfig.ts`). -4. Studio reloads fleet state and focuses the created agent chat in `src/app/page.tsx`. -5. Any authority/runtime adjustments happen after creation through settings operations such as `updateAgentPermissionsViaStudio` (`src/features/agents/operations/agentPermissionsOperation.ts`). - -### 4) Cron summaries + settings controls -- **Cron**: the UI calls gateway cron methods directly (`cron.list`, `cron.add`, `cron.run`, `cron.remove`) for latest-update previews and agent settings controls. -- **Create flow**: `AgentSettingsPanel` collects a `CronCreateDraft` in a modal wizard, `buildCronJobCreateInput` maps it to a gateway-safe payload (`src/lib/cron/createPayloadBuilder.ts`), and `performCronCreateFlow` executes create + scoped refresh (`src/features/agents/operations/cronCreateOperation.ts`). - -### 5) Session settings synchronization -- **UI boundary**: `AgentChatPanel` emits model/thinking callbacks from the agent header; `src/app/page.tsx` delegates both through one mutation helper. -- **Mutation boundary**: `applySessionSettingMutation` in `src/features/agents/state/sessionSettingsMutations.ts` owns optimistic store updates, `sessionCreated` guard logic, sync success updates, and user-facing failure lines. -- **Transport boundary**: `syncGatewaySessionSettings` in `src/lib/gateway/GatewayClient.ts` is the only client-side builder/invoker for `sessions.patch` payloads. - -## Cross-cutting concerns -- **Configuration**: environment variables are read directly from `process.env`. The browser uses `NEXT_PUBLIC_GATEWAY_URL` only as a default upstream URL when Studio settings are missing; the Studio server persists upstream URL/token in `/openclaw-studio/settings.json` and the WS proxy loads them via `server/studio-settings.js`. State path resolution lives in `lib/clawdbot/paths.ts`, honoring `OPENCLAW_STATE_DIR`. When Studio token is missing, settings loaders can fall back to token/port from `/openclaw.json`. Loopback-IP gateway URLs are normalized to `localhost` in Studio settings, and the WS proxy rewrites loopback upstream origins to `localhost` for control-UI secure-context compatibility. The optional Studio access gate is enabled by `STUDIO_ACCESS_TOKEN` (`server/access-gate.js`). -- **Runtime durability + startup guard**: domain-mode runtime projection/outbox persistence is stored in `${resolveStateDir()}/openclaw-studio/runtime.db` via `better-sqlite3` (`src/lib/controlplane/projection-store.ts`). Startup scripts (`verify:native-runtime:repair` for `dev` and `verify:native-runtime:check` for `start`) verify native addon compatibility before server boot. -- **Testing**: Playwright e2e runs Studio with an isolated `OPENCLAW_STATE_DIR` so the Studio WS proxy does not read real upstream gateway settings from the developer machine. -- **Logging**: API routes and the gateway client use built-in `console.*` logging. -- **Error handling**: - - API routes return JSON `{ error }` with appropriate status. - - `fetchJson` throws when `!res.ok`, surfaces errors to UI state. - - `StudioSettingsCoordinator` logs failed async persistence writes (debounced flush or queued patch failures) so settings-save errors are observable. - - Gateway connect failures with `INVALID_REQUEST: invalid config` surface a doctor hint in Studio (`npx openclaw doctor --fix` / `pnpm openclaw doctor --fix`). - - Gateway connect failures that close with `connect failed: ...` are preserved as `GatewayResponseError` codes so auto-retry gating can be code-driven (instead of message-driven). - - Gateway browser client truncates close reasons to WebSocket protocol limits (123 UTF-8 bytes) to avoid client-side close exceptions on long error messages. -- **Filesystem helpers**: server-only filesystem operations live at the API route boundaries. These helpers are used for local settings and gateway-adjacent file operations, not for agent file edits. -- **Remote gateway tools over SSH**: some server routes execute small scripts on the gateway host (for example agent-state operations and remote media reads). Shared helpers in `src/lib/ssh/gateway-host.ts` own SSH invocation and JSON parsing so routes do not hand-roll `spawnSync` error handling. -- **Tracing**: `src/instrumentation.ts` registers `@vercel/otel` for telemetry. -- **Validation**: request payload validation in API routes and typed client/server helpers in `src/lib/*`. - -## Major design decisions & trade-offs -- **Local settings file over DB**: fast, local-first persistence for gateway connection + focused preferences; trade-off is no concurrency or multi-user support. -- **Same-origin WS proxy instead of direct browser->gateway WS**: allows server-side token custody/injection and easier local/remote switching; trade-off is one extra hop and custom-server ownership. -- **Gateway-first agent records**: records map 1:1 to `agents.list` entries with main sessions; trade-off is no local-only agent concept. -- **Gateway-backed config + agent-file edits**: create/rename/heartbeat/delete and per-agent overrides via `config.patch`, agent files via `agents.files.get`/`agents.files.set`, and per-agent exec approvals via `exec.approvals.set`; trade-off is reliance on gateway availability. -- **Fleet hydration snapshot/derive split**: `hydrateAgentFleetFromGateway` loads gateway/settings snapshots (I/O) and delegates all derived decisions (seeds, exec policy resolution, summary selection) to a pure derivation helper; trade-off is one extra module and a more explicit snapshot input, but the derivation becomes independently testable. -- **Extract page-level workflows into operations**: keep `src/app/page.tsx` as wiring by moving workflow policy into operation modules (for example reconcile in `src/features/agents/operations/agentReconcileOperation.ts` and manual exec approval resolve in `src/features/agents/approvals/execApprovalResolveOperation.ts`); trade-off is more modules, but the workflows become independently unit testable without React rendering. -- **Structured connect errors + retry policy helper**: `GatewayClient.connect()` preserves gateway connect-failure codes and `useGatewayConnection` gates auto-retry via `resolveGatewayAutoRetryDelayMs`; trade-off is one more helper plus extra state (`connectErrorCode`), but the behavior is less brittle than string matching. -- **Narrow local config mutation boundary**: Studio does not write `openclaw.json` directly today; if a local-only integration is introduced, keep any local writes narrowly scoped to that integration and reuse shared list helpers instead of ad-hoc mutation paths; trade-off is less flexibility for local-only experimentation, but clearer ownership and lower drift risk. -- **Shared `agents.list` helper layer**: gateway and local config paths now consume one pure helper module for list parsing/writing/upsert behavior; trade-off is one more shared dependency, but it reduces semantic drift and duplicate bug surface. -- **Single gateway settings endpoint**: `/api/studio` is the sole Studio gateway URL/token source; trade-off is migration pressure on any older local-config-based callers, but it removes ambiguous ownership and dead paths. -- **Shared client settings coordinator module**: `src/lib/studio/coordinator.ts` now owns `/api/studio` transport plus load/schedule/flush behavior for gateway + focused state; trade-off is introducing a central client singleton, but it removes wrapper indirection and duplicate timers/fetch paths. -- **Single shared JSON-over-SSH helper**: server routes that need to run a gateway-side script over SSH should use `runSshJson` in `src/lib/ssh/gateway-host.ts` (and opt into a larger `maxBuffer` when expecting large payloads) rather than duplicating `spawnSync` + JSON parsing; trade-off is one shared dependency, but it reduces drift risk and keeps error surfacing consistent. -- **Vendored gateway client + sync script**: reduces drift from upstream OpenClaw UI; trade-off is maintaining a sync path and local copies of upstream helpers. -- **Feature-first organization**: increases cohesion in UI; trade-off is more discipline to keep shared logic in `lib`. -- **Node runtime for API routes**: required for filesystem access and tool proxying; trade-off is Node-only server runtime. -- **Event-driven summaries + on-demand history**: keeps the dashboard lightweight; trade-off is history not being available until requested. -- **Runtime policy/executor split for event handling**: one listener path in `src/app/page.tsx` classifies frames through `src/features/agents/state/runtimeEventBridge.ts`, derives side-effect-free decisions in `src/features/agents/state/runtimeEventPolicy.ts`, and executes those intents in `src/features/agents/state/gatewayRuntimeEventHandler.ts`; trade-off is additional intent-shape maintenance, but lower coupling between lifecycle policy and side effects. -- **Single gateway event intake subscription**: one `client.onEvent` path now handles both summary-refresh events (`presence`/`heartbeat`) and runtime stream events (`chat`/`agent`) using bridge classification helpers; trade-off is a larger callback surface, but fewer lifecycle and cleanup divergence points. -- **Shared session-setting mutation path**: model and thinking-level updates now pass through one UI mutation helper plus one gateway sync helper (`src/features/agents/state/sessionSettingsMutations.ts` + `src/lib/gateway/GatewayClient.ts`), reducing divergence between optimistic state and remote patch flows. -- **Server-reported domain mode authority**: app-level runtime routing now uses `/api/studio` `domainApiModeEnabled` as the only authoritative toggle; trade-off is explicit parameter threading through operations/hooks, but it removes module-level routing divergence caused by client env fallback checks. -- **Gap-free SSE startup over outbox replay**: `/api/runtime/stream` now uses early subscribe + startup buffering + a single monotonic outbox-id emission guard to preserve ordered delivery across replay and live handoff; trade-off is slightly more route complexity, but it removes boundary-loss and duplicate-delivery failure modes under reconnect/fresh-connect race conditions. - -## Mermaid diagrams -### C4 Level 1 (System Context) -```mermaid -C4Context - title OpenClaw Studio - System Context - Person(user, "User", "Operates agents locally") - System(ui, "OpenClaw Studio", "Next.js App Router UI") - System(proxy, "Studio WS Proxy", "Custom server /api/gateway/ws") - System_Ext(gateway, "OpenClaw Gateway", "WebSocket runtime") - System_Ext(fs, "Local Filesystem", "settings.json and other local reads (e.g. path suggestions)") - - Rel(user, ui, "Uses") - Rel(ui, proxy, "WebSocket frames") - Rel(proxy, gateway, "WebSocket frames") - Rel(ui, fs, "HTTP to API routes -> fs read/write") -``` - -### C4 Level 2 (Containers/Components) -```mermaid -C4Container - title OpenClaw Studio - Containers - Person(user, "User") - - Container_Boundary(app, "Next.js App") { - Container(client, "Client UI", "React", "Focused agent-management UI, state, gateway client") - Container(api, "API Routes", "Next.js route handlers", "Studio settings and gateway-host state tools") - Container(proxy, "WS Proxy", "Custom Node server", "Bridges /api/gateway/ws to upstream gateway with token injection") - } - - Container_Ext(gateway, "Gateway", "WebSocket", "Agent runtime") - Container_Ext(fs, "Filesystem", "Local", "settings.json and other local reads") - - Rel(user, client, "Uses") - Rel(client, api, "HTTP JSON") - Rel(client, proxy, "WebSocket /api/gateway/ws") - Rel(proxy, gateway, "WebSocket") - Rel(api, fs, "Read/Write") - Rel(proxy, fs, "Read settings/token") -``` - -## Explicit forbidden patterns -- Do not read/write local files directly from client components. -- Do not reintroduce local projects/workspaces as a source of truth for agent records. -- Do not write agent rename/heartbeat/override data directly to `openclaw.json`; use gateway `config.patch`. -- Do not read/write agent files on the local filesystem; use the gateway tools proxy. -- Do not add parallel gateway settings endpoints; `/api/studio` is the only supported Studio gateway URL/token path. -- Do not branch main app runtime request routing on client env helpers; use `/api/studio` `domainApiModeEnabled`. -- Do not add new generic local `openclaw.json` mutation wrappers for runtime agent-management flows; if a local-only integration is introduced, keep any local writes narrowly scoped and well tested. -- Do not store gateway tokens or secrets in client-side persistent storage. -- Do not add new global mutable state outside `AgentStoreProvider` for agent UI data. -- Do not silently swallow errors in API routes; always return actionable errors. -- Do not add heavy abstractions or frameworks unless there is clear evidence of need. - -## Future-proofing notes -- If multi-user support becomes a goal, replace the settings file with a DB-backed service and introduce authentication at the API boundary. -- If gateway protocol evolves, isolate changes within `src/lib/gateway` and keep UI call sites stable. +## Guardrails +- Do not reintroduce browser direct gateway transport. +- Do not add new `/api/gateway/*` routes. +- Keep gateway method allowlist explicit in `openclaw-adapter.ts`. +- Keep settings token redaction server-side. +- Keep migrations additive for `runtime.db`. diff --git a/README.md b/README.md index 188efaf..9370cb4 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,11 @@ Notes: ## How It Connects (Mental Model) -In domain API mode (default), there are **two primary paths**: +OpenClaw Studio now runs one runtime architecture with **two primary paths**: 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”. ## Install from source (advanced) @@ -103,7 +101,7 @@ Paths and key settings: - 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. +- Domain API mode: always enabled. Studio runs on the server-owned control-plane architecture. - `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: @@ -120,7 +118,7 @@ See `docs/ui-guide.md` for UI workflows (agent creation, cron jobs, exec approva ## PI + chat streaming -See `docs/pi-chat-streaming.md` for how Studio bridges browser WebSocket traffic to the upstream Gateway, how runtime streaming arrives (`chat`/`agent` events), and how the chat UI renders tool calls, thinking traces, and final transcript lines. +See `docs/pi-chat-streaming.md` for how Studio streams runtime events over domain SSE (`/api/runtime/stream`), applies replay/history, and renders tool calls, thinking traces, and final transcript lines. ## Permissions + sandboxing @@ -141,6 +139,10 @@ If the UI loads but “Connect” fails, it’s usually Studio->Gateway: If startup fails with `better_sqlite3.node` / `NODE_MODULE_VERSION` mismatch: - Run `npm run verify:native-runtime:repair` +- Confirm `node` and `npm` point at the same runtime before launching Studio: + - `node -v && node -p "process.versions.modules"` + - `which node && which npm` + - If they differ (for example Homebrew `npm` + `nvm` `node`), run `nvm use` in that terminal first. - If it still fails, run: - `npm rebuild better-sqlite3` - `npm install` diff --git a/docs/control-plane-cutover-audit.md b/docs/control-plane-cutover-audit.md new file mode 100644 index 0000000..d8a554f --- /dev/null +++ b/docs/control-plane-cutover-audit.md @@ -0,0 +1,203 @@ +# Control Plane Cutover Audit + +Date: 2026-03-02 +Status: fresh-eyes first-principles inventory (pass 2) + +## Objective + +Identify every remaining legacy path where browser code is still coupled to gateway protocol or gateway transport semantics, so we can remove those connections in one final cutover. + +## First-Principles Boundary + +Target boundary: +- Browser: Studio domain APIs only (`/api/runtime/*`, `/api/intents/*`, SSE stream). +- Server: sole owner of upstream OpenClaw gateway WebSocket and method contracts. + +A path is legacy if any browser-executed module does one of: +1. opens (or auto-opens) gateway transport (`/api/gateway/ws`), or +2. issues gateway RPC semantics (`client.call("...")`), directly or through gateway helper wrappers. + +## Re-scan Method + +Commands used: + +```bash +rg -n "GatewayClient|GatewayBrowserClient|useGatewayConnection|resolveStudioProxyGatewayUrl|/api/gateway/ws" src server scripts docs README.md ARCHITECTURE.md tests +rg -n "client\.call\(" src --glob '!**/*.test.*' +rg -n "new WebSocket\(|WebSocket\(" src server --glob '!**/*.test.*' +rg -n "/api/gateway/" src --glob '!**/*.test.*' +``` + +Current measured footprint: +- `29` non-test `src` files import from `@/lib/gateway/GatewayClient`. +- `23` non-test `client.call(...)` call-sites remain in `src`. +- `8` non-test files currently contain direct `client.call(...)` executions. +- `41` test files still reference legacy gateway client/proxy surfaces. + +## A) Hard Blockers: Browser Transport Still Exists + +These files keep the browser->gateway WS architecture alive: + +- `src/app/page.tsx:230` + - App root still calls `useGatewayConnection(...)`. +- `src/lib/gateway/GatewayClient.ts:573` + - `connect(...)` uses `resolveStudioProxyGatewayUrl()`. +- `src/lib/gateway/GatewayClient.ts:590-596` + - auto-connect effect still runs after settings load. +- `src/lib/gateway/proxy-url.ts:1-5` + - browser WS URL builder for `/api/gateway/ws`. +- `src/lib/gateway/openclaw/GatewayBrowserClient.ts:421` + - browser `new WebSocket(...)`. +- `server/index.js:55-66` + - `/api/gateway/ws` upgrade wiring. +- `server/gateway-proxy.js:88-293` + - bridge from browser WS to upstream gateway WS. + +Important drift from architecture intent: +- `useGatewayConnection` currently has no domain-mode suppression guard around auto-connect/retry paths; `domainApiModeEnabled` is loaded and returned, but not used to prevent transport startup in this file. + +## B) Active Browser Gateway RPC Paths (Not Just Types) + +### 1) Settings: config/models via browser RPC + +- `src/features/agents/operations/useGatewayConfigSyncController.ts:68` `config.get` +- `src/features/agents/operations/useGatewayConfigSyncController.ts:138` `config.get` +- `src/features/agents/operations/useGatewayConfigSyncController.ts:149-151` `models.list` + +### 2) Settings: skills + cron via browser RPC helpers + +- `src/features/agents/operations/useAgentSettingsMutationController.ts:163` `skills.status` +- `src/features/agents/operations/useAgentSettingsMutationController.ts:232` `cron.list` +- `src/features/agents/operations/useAgentSettingsMutationController.ts:531` `cron.run` +- `src/features/agents/operations/useAgentSettingsMutationController.ts:562` `cron.remove` +- `src/features/agents/operations/useAgentSettingsMutationController.ts:730-734` skills allowlist write +- `src/features/agents/operations/useAgentSettingsMutationController.ts:776-779` skills allowlist read +- `src/features/agents/operations/useAgentSettingsMutationController.ts:817-822` skills allowlist write +- `src/features/agents/operations/useAgentSettingsMutationController.ts:913-917` `skills.install` +- `src/features/agents/operations/useAgentSettingsMutationController.ts:1003-1006` `skills.update` +- `src/features/agents/operations/useAgentSettingsMutationController.ts:1026-1029` `skills.update` + +### 3) Personality files via browser RPC + +- `src/features/agents/components/AgentInspectPanels.tsx:1251` `readGatewayAgentFile(...)` +- `src/features/agents/components/AgentInspectPanels.tsx:1293` `writeGatewayAgentFile(...)` +- `src/lib/gateway/agentFiles.ts:22` `agents.files.get` +- `src/lib/gateway/agentFiles.ts:41` `agents.files.set` + +### 4) Latest-update enrichment via browser RPC + +- `src/app/page.tsx:456-457` wires `client.call(...)` and cron list helper into latest-update op. +- `src/features/agents/operations/specialLatestUpdateOperation.ts:88-93` `sessions.list` +- `src/features/agents/operations/specialLatestUpdateOperation.ts:108-111` `chat.history` + +## C) Legacy Fallback Branches Still Present in Runtime Paths + +These are mode-gated but keep legacy behavior implemented and reachable: + +- `src/features/agents/operations/runtimeWriteTransport.ts:97-271` + - fallback `client.call(...)` branches for `chat.send`, `chat.abort`, `sessions.reset`, `exec.approval.resolve`, `agent.wait`, plus legacy create/rename/delete helpers. +- `src/features/agents/operations/studioBootstrapOperation.ts:48-55` + - non-domain fleet hydration via gateway. +- `src/features/agents/operations/agentFleetHydration.ts:78-153` + - gateway reads: `config.get`, `exec.approvals.get`, `agents.list`, `sessions.list`, `status`, `sessions.preview`. +- `src/features/agents/operations/useRuntimeSyncController.ts:133-343` + - non-domain summary/history/reconcile/gap handling via gateway RPC. +- `src/features/agents/operations/agentPermissionsOperation.ts:207-323` + - legacy config/session/approvals mutation path. + +## D) Gateway-Coupled Shared Modules That Must Go in Final Cutover + +These wrappers encode gateway methods and keep browser feature code coupled: + +- `src/lib/gateway/agentConfig.ts` + - `config.get`, `config.patch`, `config.set`, `agents.create`, `agents.update`, `agents.delete`, `status`. +- `src/lib/gateway/execApprovals.ts` + - `exec.approvals.get`, `exec.approvals.set`. +- `src/lib/gateway/agentFiles.ts` + - `agents.files.get`, `agents.files.set`. +- `src/lib/gateway/gatewayReloadMode.ts` + - legacy config writes. +- `src/lib/cron/types.ts` + - `cron.list`, `cron.add`, `cron.run`, `cron.remove`. +- `src/lib/skills/types.ts` + - `skills.status`, `skills.install`, `skills.update`. + +## E) Legacy Namespace Routes (Not WS, but Cleanup Targets) + +These are server routes but still under legacy `/api/gateway/*` naming and browser callers: + +- Callers: + - `src/lib/text/media-markdown.ts:17` -> `/api/gateway/media` + - `src/features/agents/operations/deleteAgentOperation.ts:140,151` -> `/api/gateway/agent-state` + - `src/lib/skills/remove.ts:23` -> `/api/gateway/skills/remove` +- Routes: + - `src/app/api/gateway/media/route.ts` + - `src/app/api/gateway/agent-state/route.ts` + - `src/app/api/gateway/skills/remove/route.ts` + +Note: +- These are not direct browser WS transport, but they should be renamed/re-homed during final legacy cleanup. + +## F) Documentation + Test Drag + +### Docs still describing legacy browser gateway mode + +- `README.md:86` +- `ARCHITECTURE.md` (multiple sections describing `/api/gateway/ws` and `GatewayClient` as active architecture) +- `docs/pi-chat-streaming.md` (legacy browser gateway transport narrative) +- `docs/permissions-sandboxing.md` (GatewayClient references) + +### Tests tied to legacy stack + +`41` test files currently reference `GatewayClient`, `GatewayBrowserClient`, `/api/gateway/ws`, or `/api/gateway/*` routes. + +Representative sets: +- transport/proxy: `tests/unit/gatewayProxy.test.ts`, `tests/unit/gatewayBrowserClient.test.ts`, `tests/unit/useGatewayConnection.test.ts` +- gateway wrappers: `tests/unit/gatewayAgentOverrides.test.ts`, `tests/unit/gatewayExecApprovals.test.ts`, `tests/unit/cronGatewayClient.test.ts`, `tests/unit/skillsGatewayClient.test.ts` +- legacy routes: `tests/unit/gatewayMediaRoute.test.ts`, `tests/unit/agentStateRoute.test.ts`, `tests/unit/skillsRemoveRoute.test.ts` + +## Complete Removal Checklist (File Groups) + +### 1) Remove browser transport stack +- `src/lib/gateway/GatewayClient.ts` +- `src/lib/gateway/openclaw/GatewayBrowserClient.ts` +- `src/lib/gateway/proxy-url.ts` +- `server/gateway-proxy.js` +- `/api/gateway/ws` wiring in `server/index.js` + +### 2) Replace active browser RPC UI surfaces +- `src/features/agents/operations/useGatewayConfigSyncController.ts` +- `src/features/agents/operations/useAgentSettingsMutationController.ts` +- `src/features/agents/components/AgentInspectPanels.tsx` +- `src/features/agents/operations/specialLatestUpdateOperation.ts` +- `src/app/page.tsx` (latest-update gateway wiring + connection hook dependency) + +### 3) Remove fallback legacy branches +- `src/features/agents/operations/runtimeWriteTransport.ts` +- `src/features/agents/operations/studioBootstrapOperation.ts` +- `src/features/agents/operations/agentFleetHydration.ts` +- `src/features/agents/operations/useRuntimeSyncController.ts` +- `src/features/agents/operations/agentPermissionsOperation.ts` + +### 4) Remove gateway method wrapper modules +- `src/lib/gateway/agentConfig.ts` +- `src/lib/gateway/agentFiles.ts` +- `src/lib/gateway/execApprovals.ts` +- `src/lib/gateway/gatewayReloadMode.ts` +- `src/lib/cron/types.ts` (legacy RPC portions) +- `src/lib/skills/types.ts` (legacy RPC portions) + +### 5) Re-home `/api/gateway/*` route namespace +- `src/app/api/gateway/media/route.ts` +- `src/app/api/gateway/agent-state/route.ts` +- `src/app/api/gateway/skills/remove/route.ts` +- update callers in `src/lib/text/media-markdown.ts`, `src/features/agents/operations/deleteAgentOperation.ts`, `src/lib/skills/remove.ts` + +## Exit Criteria (Migration Actually Complete) + +1. No browser module imports or uses `useGatewayConnection` / `GatewayClient` / `GatewayBrowserClient`. +2. No browser runtime path executes `client.call(...)` for gateway methods. +3. `server/index.js` no longer handles `/api/gateway/ws` upgrades. +4. `server/gateway-proxy.js` is deleted. +5. `/api/gateway/*` routes are removed or fully re-homed. +6. Docs and tests no longer describe legacy browser-gateway mode as a supported operational path. diff --git a/docs/pi-chat-streaming.md b/docs/pi-chat-streaming.md index 7c4eccc..6137183 100644 --- a/docs/pi-chat-streaming.md +++ b/docs/pi-chat-streaming.md @@ -1,373 +1,31 @@ -# PI + Chat Streaming (Studio Side) - -This document exists to onboard coding agents quickly when debugging chat issues in OpenClaw Studio. - -Scope: -- Describes how Studio connects to the OpenClaw Gateway, how runtime streaming arrives over WebSockets, and how the UI renders it. -- Treats **PI** as “the coding agent running behind the Gateway” (an OpenClaw agent). Studio does not implement PI logic; it displays and controls the Gateway session. - -Non-scope: -- PI internals and model/tool execution details. Those live in the OpenClaw repository and the Gateway implementation. - -## Key Files (Start Here) - -- Studio server entry + upgrade wiring: `server/index.js` -- Browser WS bridge to upstream gateway: `server/gateway-proxy.js` -- Browser WS URL (always same-origin `/api/gateway/ws`): `src/lib/gateway/proxy-url.ts` -- Browser gateway protocol client (vendored): `src/lib/gateway/openclaw/GatewayBrowserClient.ts` -- Studio gateway wrapper + connect policy: `src/lib/gateway/GatewayClient.ts` -- Runtime stream classification and merge helpers: `src/features/agents/state/runtimeEventBridge.ts` -- Runtime event executor (streaming -> state -> transcript lines): `src/features/agents/state/gatewayRuntimeEventHandler.ts` -- Chat rendering: `src/features/agents/components/AgentChatPanel.tsx`, `src/features/agents/components/chatItems.ts` -- Message parsing (text/thinking/tool markers): `src/lib/text/message-extract.ts` -- History sync + transcript merge: `src/features/agents/operations/historySyncOperation.ts`, `src/features/agents/state/transcript.ts` - -## Relationship To OpenClaw (What’s Vendored Here) - -Studio vendors the browser Gateway client used to speak the Gateway protocol: -- Vendored client: `src/lib/gateway/openclaw/GatewayBrowserClient.ts` -- Sync script: `scripts/sync-openclaw-gateway-client.ts` -- Current sync source path used by that script: `~/openclaw/ui/src/ui/gateway.ts` - -Important: -- Studio syncs `GatewayBrowserClient.ts` from `~/openclaw` via the sync script above. -- If protocol mismatch is suspected, first verify the sync source file and the upstream Gateway runtime/protocol files are aligned. - -If a protocol mismatch is suspected (missing event fields, renamed streams, different error codes), start by checking whether Studio’s vendored client is in sync with the Gateway version you’re running. - -## Upstream Source Of Truth (OpenClaw) - -For chat streaming behavior, these upstream files are authoritative: -- `~/openclaw/src/gateway/protocol/schema/logs-chat.ts` (`chat.send`, `chat.history`, and chat event schema) -- `~/openclaw/src/gateway/server-methods/chat.ts` (`chat.send` ack + idempotency, `chat.history` payload shaping/sanitization) -- `~/openclaw/src/gateway/server-chat.ts` (`agent` event fanout and synthetic `chat` delta/final bridging) -- `~/openclaw/src/agents/pi-embedded-subscribe.ts` and handlers (`assistant`/`tool`/`lifecycle` stream emission) - -When updating this doc, verify behavior against those files, not assumptions. - -## Terminology - -- Studio: this repo, a Next.js UI with a custom Node server. -- Gateway (upstream): the OpenClaw Gateway WebSocket server (default `ws://localhost:18789`). -- WS bridge / proxy: Studio’s server-side WebSocket that bridges the browser to the upstream Gateway. -- Frame: JSON message over WebSocket (request/response/event). -- Run: a single streamed execution identified by `runId`. -- Session: identified by `sessionKey` (Studio uses `agent::` for main sessions). - -## High-Level Network Path - -There are two separate WebSocket hops, plus a protocol-level `connect` request: - -```mermaid -sequenceDiagram - participant B as Browser (Studio UI) - participant S as Studio server (WS proxy) - participant G as OpenClaw Gateway (upstream) - - B->>S: WS connect /api/gateway/ws - B->>S: req(connect) (Gateway protocol frame) - S->>G: WS connect upstream (url from settings.json) - S->>G: req(connect) (injects token if missing) - G-->>S: res(connect) - S-->>B: res(connect) - G-->>S: event(chat/agent/presence/heartbeat) - S-->>B: event(...) -``` - -Files: -- WS proxy entrypoint: `server/index.js` -- WS proxy implementation: `server/gateway-proxy.js` - -Notes: -- The browser never opens a WebSocket directly to the upstream Gateway URL. The browser always speaks to the Studio same-origin bridge at `/api/gateway/ws` (computed by `src/lib/gateway/proxy-url.ts`). -- The “upstream gateway URL” shown in Studio settings is used by the Studio server (the proxy) to open the upstream connection. - -## End-To-End Flow (PI Run -> UI) - -This is the “happy path” you want in your head when debugging: - -1. User types in the chat composer and hits Send (`src/features/agents/components/AgentChatPanel.tsx`). -2. Studio calls `chat.send` with `sessionKey` and `idempotencyKey = runId` (`src/features/agents/operations/chatSendOperation.ts`). -3. Gateway runs the agent (PI) for that session. -4. While the run is executing, the Gateway may stream: - - `event: "agent"` frames for live partial output (`stream: "assistant"`), live thinking (`reason*`/`think*` streams), tool calls/results (`stream: "tool"`), and lifecycle (`stream: "lifecycle"`). - - `event: "chat"` frames for the chat message stream (`state: "delta" | "final" | ...`). - - Both streams can describe the same run progression from different layers (`agent` stream events and `chat` message events), so Studio must merge idempotently. -5. Studio merges those events into: - - live fields (`streamText`, `thinkingTrace`) via batched `queueLivePatch` (fast UI updates without committing to the transcript yet) - - committed transcript lines (`outputLines`) via `appendOutput` (final messages, tool lines, meta/timestamp, thinking trace) -6. The chat panel renders: - - historical transcript from `outputLines` - - an extra “live assistant” card at the bottom built from `streamText` + `thinkingTrace` while `status === "running"`. - -The key wiring is in: -- Event subscription + dispatch: `src/app/page.tsx` -- Runtime event handler: `src/features/agents/state/gatewayRuntimeEventHandler.ts` -- Store reducer: `src/features/agents/state/store.tsx` - -## Studio Settings (Where Gateway URL/Token Come From) - -Studio persists Gateway connection settings on the Studio host (not in browser storage). The UI loads them into browser memory at runtime: -- `~/.openclaw/openclaw-studio/settings.json` (see `README.md` for the canonical location) - -The WS proxy loads these settings server-side and opens the upstream connection. - -Files: -- Settings file access (WS proxy): `server/studio-settings.js` -- Settings API route (browser -> server): `src/app/api/studio/route.ts` -- Client-side load/patch coordinator: `src/lib/studio/coordinator.ts` -- Settings storage + fallback behavior used by `/api/studio`: `src/lib/studio/settings-store.ts` - -Connection note: -- In the browser, `useGatewayConnection()` stores the upstream URL/token in memory (loaded from `/api/studio`) but connects the WebSocket to Studio via `resolveStudioProxyGatewayUrl()`; the upstream URL is passed as `authScopeKey` (not as the WebSocket URL). See `src/lib/gateway/GatewayClient.ts`. - -Token resolution note: -- The Studio server resolves an upstream token from `openclaw-studio/settings.json`, and if it is missing it may fall back to the local OpenClaw config in `openclaw.json` (token + port). This behavior exists in both the WS proxy path (`server/studio-settings.js`) and the `/api/studio` storage layer (`src/lib/studio/settings-store.ts`) and they should remain consistent. -- During `connect`, the WS proxy forwards browser-provided auth (`params.auth.token` or `params.device.signature`) as-is. It injects the host-resolved token only when browser auth is absent. `studio.gateway_token_missing` is returned only when neither browser auth nor host token is available. - -## WebSocket Frame Shapes - -Studio expects Gateway frames shaped like: - -```json -{ "type": "req", "id": "uuid", "method": "connect", "params": { } } -{ "type": "res", "id": "uuid", "ok": true, "payload": { } } -{ "type": "res", "id": "uuid", "ok": false, "error": { "code": "…", "message": "…" } } -{ "type": "event", "event": "chat", "payload": { } } -``` - -Types live in: -- `src/lib/gateway/GatewayClient.ts` - -### Connect handshake - -The first *protocol frame* from the browser must be `req(connect)`. The WS proxy: -- Rejects non-`connect` frames until connected. -- Opens an upstream WS to the configured Gateway URL. -- Injects `auth.token` into the connect params if the connect frame does not already contain a token, and if it does not include a device signature. -- Returns `studio.gateway_token_missing` only when no browser auth is present and no host token can be resolved. -- Sets an `Origin` header for the upstream WebSocket derived from the upstream URL (and normalizes loopback hostnames to `localhost`). - -Code: -- Connect enforcement + token injection: `server/gateway-proxy.js` - -### Connect failures - -On failure to load settings or open upstream, the proxy sends an error `res` for the connect request (when possible) and then closes the WS. - -Important detail (how errors become actionable in the UI): -- The browser-side Gateway client (`src/lib/gateway/openclaw/GatewayBrowserClient.ts`) closes the WebSocket with close code `4008` and a reason like `connect failed: ` after it receives a failed `res(connect)`. `GatewayClient.connect()` parses that close into `GatewayResponseError(code, message)` for UI retry policy and user-facing errors. -- Separately, the proxy may also close with `1011` / `connect failed`; the “connect failed: …” close reason that the UI parses is produced by the browser client, not the proxy. -- WebSocket close reasons are truncated to 123 UTF-8 bytes in the browser client to avoid protocol errors on long messages. - -Error codes used by the proxy include: -- `studio.gateway_url_missing` -- `studio.gateway_token_missing` -- `studio.gateway_url_invalid` -- `studio.settings_load_failed` -- `studio.upstream_error` -- `studio.upstream_closed` - -## Reconnects And Retries - -There are two layers of retry behavior: - -- Transport reconnect (after a successful hello): the vendored browser client reconnects the browser->Studio WebSocket with backoff when it closes, and continues emitting events after reconnect. See `src/lib/gateway/openclaw/GatewayBrowserClient.ts`. -- Initial connect failure retry: when the initial `connect` handshake fails (for example bad token), `GatewayClient.connect()` tears down the vendored client and returns a rejected promise; `useGatewayConnection()` may schedule a limited re-attempt unless the error code is known non-retryable. See `resolveGatewayAutoRetryDelayMs` in `src/lib/gateway/GatewayClient.ts`. - -## Studio Access Gate - -When Studio is bound to a public host, `STUDIO_ACCESS_TOKEN` is required. For loopback-only binds, it remains optional. When enabled, Studio enforces a simple access gate: -- HTTP: blocks `/api/*` routes unless the correct cookie is present; you can set it once via `/?access_token=...`. -- WebSocket: blocks `/api/gateway/ws` upgrades unless the cookie is present. - -Files: -- Gate implementation: `server/access-gate.js` -- Gate integration for WS upgrades: `server/index.js` - -## Streaming: What the Gateway Sends and How Studio Uses It - -Studio classifies gateway events by `event` name: -- `presence`, `heartbeat`: summary refresh triggers -- `chat`: runtime chat messages (delta/final) -- `agent`: runtime per-stream deltas (assistant/thinking/tool/lifecycle) - -Code: -- Classification: `src/features/agents/state/runtimeEventBridge.ts` -- Execution: `src/features/agents/state/gatewayRuntimeEventHandler.ts` - -## Live Fields vs Committed Transcript (Why Streaming Can “Look Weird”) - -Studio intentionally separates: -- Live streaming UI: `AgentState.streamText` and `AgentState.thinkingTrace` are updated via `queueLivePatch`, which batches patches and coalesces multiple deltas before they hit React state (`src/app/page.tsx`). -- Committed transcript: `AgentState.outputLines` is appended via `appendOutput`. These are the lines that become the durable on-screen transcript and are later merged with `chat.history` results (`src/features/agents/state/store.tsx`). - -This split is why you can see: -- “live” assistant output update rapidly at the bottom card during a run -- then a finalized assistant message (plus tool lines / thinking trace / meta timestamp) appear in the transcript on `final` - -### `event: "chat"` payload - -Studio treats `chat` events as the canonical “message” stream for transcript completion. Expected fields: -- `runId` -- `sessionKey` -- `state`: `delta | final | aborted | error` -- `message` (shape varies; Studio extracts text/thinking/tool metadata defensively) - -Key behaviors (Studio-side): -- Ignores user/system roles for transcript append (but uses them for status/summary). -- User messages shown in the transcript are primarily from local optimistic send and from `chat.history` sync (not from runtime `chat` user-role events). -- On `final`, appends: - - a `[[meta]]{...}` line (timestamp and thinking duration when available) - - a `[[trace]]` thinking block when extracted - - tool call/result markdown lines when present - - the assistant text (if any) -- If a `final` assistant message arrives without an extractable thinking trace, Studio may request `chat.history` as recovery. -- `chat.send` is idempotency-keyed upstream and returns a started ack before async completion; this is why history reconciliation can race with runtime events and must be idempotent. - -### `event: "agent"` payload - -Studio uses `agent` events for live streaming and richer tool/lifecycle updates. Expected fields: -- `runId` -- `stream`: `assistant | tool | lifecycle | ` -- `data`: record with `text`/`delta` and stream-specific keys - -Stream handling (high-level): -- `assistant`: merges `data.delta` into a live `streamText` for the UI. -- reasoning stream (anything that is not `assistant`, `tool`, `lifecycle` and matches hints like `reason`/`think`/`analysis`/`trace`): merged into `thinkingTrace`. -- `tool`: formats tool call and tool result lines using `[[tool]]` and `[[tool-result]]`. -- `lifecycle`: start/end/error transitions; if a run reaches `end` without chat final events, Studio may flush the last streamed assistant text as a fallback final transcript entry. - -Code: -- Runtime agent stream merge + append: `src/features/agents/state/gatewayRuntimeEventHandler.ts` - -## How Chat UI Renders Streaming - -Studio keeps an `outputLines: string[]` transcript per agent, plus live fields like `streamText` and `thinkingTrace`. - -Rendering pipeline: -- `outputLines` contains: - - user messages as `> ...` - - assistant messages as raw markdown text - - tool call/results with prefixes `[[tool]]` and `[[tool-result]]` - - optional meta lines `[[meta]]{...}` for timestamps and thinking durations - - optional thinking trace lines `[[trace]] ...` -- The panel derives structured chat items from `outputLines` and (optionally) live streaming state. -- UI toggles that change rendering: - - `showThinkingTraces`: hides/shows `[[trace]]` thinking entries. - - `toolCallingEnabled`: when off, tool lines are hidden and some exec tool results may be shown as assistant text. - -### Rendering contract - -- Assistant markdown renders as assistant markdown. Studio does not wrap normal assistant markdown in a synthetic `Output` container. -- Tool cards render only from explicit marker lines: `[[tool]]` and `[[tool-result]]`. -- List-marker visibility comes from chat markdown styles in `src/app/styles/markdown.css`; stream parsing does not invent list bullets. - -Files: -- Chat panel UI: `src/features/agents/components/AgentChatPanel.tsx` -- Transcript parsing into items: `src/features/agents/components/chatItems.ts` -- Message extraction helpers (text/thinking/tool parsing): `src/lib/text/message-extract.ts` -- Media line rewrite (images/audio/video rendered in markdown): `src/lib/text/media-markdown.ts` - -## Sending Messages (Browser -> PI via Gateway) - -Send path (high level): -- UI submits a message through `sendChatMessageViaStudio()` which: - - Sets agent state to running and clears live streams. - - Optionally resets local transcript state for `/new` or `/reset` (local UI behavior). - - Optimistically appends the user line (`> ...`) to the transcript. - - Ensures session settings are synced once via `sessions.patch` (model/thinking/exec settings) before first send. - - Calls `chat.send` with `idempotencyKey = runId` and `deliver: false`. - -Stop path: -- UI calls `chat.abort` to stop an active run. - -Files: -- Send operation: `src/features/agents/operations/chatSendOperation.ts` -- Session settings sync transport: `src/lib/gateway/GatewayClient.ts` -- Stop call site: `src/app/page.tsx` - -## Post-Connect Side Effects (Local Gateway Only) - -After a successful connection, Studio may mutate gateway config when the upstream gateway URL is local: -- It reads `config.get` and may write `config.set` to ensure `gateway.reload.mode` is `"hot"` for local Studio usage. - -File: -- Reload mode enforcement: `src/lib/gateway/gatewayReloadMode.ts` - -## Sequence Gaps (Dropped Events) - -Gateway event frames may include `seq`. The vendored browser client tracks `seq` and reports gaps (`expected`, `received`) via `onGap`. - -Studio behavior on gap: -- Logs a warning. -- Forces a summary snapshot refresh and reconciles running agents. - -Files: -- Gap detection: `src/lib/gateway/openclaw/GatewayBrowserClient.ts` -- Gap handling: `src/app/page.tsx` - -## History Sync (Recovery, Load More) - -Studio can fetch history via `chat.history` and merge it into the transcript. - -Key points: -- Studio intentionally treats gateway history as canonical for timestamps/final ordering. -- History merge is designed to avoid duplicates and reconcile local optimistic sends. -- History parsing intentionally skips some system-ish content (heartbeat prompts, restart sentinel messages, and UI metadata prefixes). See `buildHistoryLines()` in `src/features/agents/state/runtimeEventBridge.ts`. -- Transcript v2 can be toggled with `NEXT_PUBLIC_STUDIO_TRANSCRIPT_V2`. -- Transcript debug logs can be enabled with `NEXT_PUBLIC_STUDIO_TRANSCRIPT_DEBUG`. - -Files: -- History operation: `src/features/agents/operations/historySyncOperation.ts` -- Transcript merge/sort primitives: `src/features/agents/state/transcript.ts` - -## Exec Approvals In Chat (Related To “PI Runs”) - -Some runs require exec approval. These are surfaced as in-chat cards and are handled separately from the `chat`/`agent` runtime stream. - -Files: -- Event to pending-card state: `src/features/agents/approvals/execApprovalEvents.ts` -- Resolve operation: `src/features/agents/approvals/execApprovalResolveOperation.ts` -- Wiring (subscribe + render): `src/app/page.tsx`, `src/features/agents/components/AgentChatPanel.tsx` - -## Media Rendering (Images From Agent Output) - -If an agent outputs lines like: -- `MEDIA: /home/ubuntu/.openclaw/.../image.png` - -Studio may render them inline: -1. UI rewrites eligible `MEDIA:` lines into markdown images (`![](/api/gateway/media?path=...)`) but avoids rewriting inside fenced code blocks. -2. The browser requests `/api/gateway/media`. -3. The API route reads the image either locally (only under `~/.openclaw`) or over SSH for remote gateways, and returns the bytes with the correct `Content-Type`. - -Files: -- Rewrite helper: `src/lib/text/media-markdown.ts` -- Media API route: `src/app/api/gateway/media/route.ts` -- SSH helper + env vars (`OPENCLAW_GATEWAY_SSH_TARGET`, `OPENCLAW_GATEWAY_SSH_USER`): `src/lib/ssh/gateway-host.ts` - -## Debugging Checklist (When Chat “Feels Buggy”) - -Start with the hop where symptoms appear. - -WS bridge / connectivity: -- Studio server logs (proxy): `server/gateway-proxy.js` -- Common failures: wrong `ws://` vs `wss://`, missing token, gateway closed, upstream TLS mismatch - -Streaming correctness (missing/duplicated output): -- Event classification + runtime stream merge: `src/features/agents/state/gatewayRuntimeEventHandler.ts` -- Text/thinking/tool extraction quirks: `src/lib/text/message-extract.ts` -- UI item derivation and collapsing rules: `src/features/agents/components/chatItems.ts` -- Dedupe of tool lines per run + closed-run ignore window: `src/features/agents/state/gatewayRuntimeEventHandler.ts` - -History and ordering issues: -- `chat.history` merge logic and dedupe: `src/features/agents/operations/historySyncOperation.ts` -- Transcript entry ordering/fingerprints: `src/features/agents/state/transcript.ts` - -Media not rendering: -- `MEDIA:` rewrite behavior and code-fence skipping: `src/lib/text/media-markdown.ts` -- Image fetch route behavior (local vs SSH, allowlisted extensions, size limits): `src/app/api/gateway/media/route.ts` - -If you need Gateway-side observability: -- Capture the exact `connect` settings used by Studio (URL + token are stored server-side in the Studio settings file). -- Inspect Gateway logs on the Gateway host using your environment’s service/log tooling. +# PI Chat Streaming + +## Current transport model +PI/chat runtime streaming is server-owned control plane only. + +- Browser subscribes to `GET /api/runtime/stream` (SSE). +- Studio server maintains the upstream gateway websocket. +- Browser never opens a direct gateway websocket. + +## Event flow +1. User sends message via `POST /api/intents/chat-send`. +2. Server forwards intent through control-plane adapter (`chat.send`). +3. Gateway emits runtime events; adapter projects them to outbox. +4. `/api/runtime/stream` emits ordered `gateway.event` frames with monotonic outbox ids. +5. Browser ingests events through existing runtime/approval handlers. + +## Replay and resume +- If client reconnects with `Last-Event-ID`, stream replays forward from that id. +- If client connects fresh (no `Last-Event-ID`), stream replays a recent tail window from outbox head. +- Live subscription and replay are sequenced to avoid replay/live gaps and duplicate terminal effects. + +## History backfill +- `GET /api/runtime/agents/[agentId]/history?limit=&beforeOutboxId=` +- Returns newest window first and cursor metadata: + - `hasMore` + - `nextBeforeOutboxId` +- Browser applies history entries through the same event pipeline as live stream and dedupes outbox ids. + +## Freshness/degraded behavior +- Runtime reads expose freshness metadata when gateway is unavailable. +- Projection-backed data can still render while writes fail fast with deterministic gateway-unavailable errors. diff --git a/package-lock.json b/package-lock.json index 39ec1ab..ed1b1cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.1.0", "dependencies": { "@multiavatar/multiavatar": "github:multiavatar/Multiavatar", - "@noble/ed25519": "^3.0.0", "@vercel/otel": "^2.1.0", "better-sqlite3": "^12.6.2", "clsx": "^2.1.1", @@ -40,6 +39,9 @@ "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.0.18" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@acemir/cssom": { @@ -1873,15 +1875,6 @@ "node": ">= 10" } }, - "node_modules/@noble/ed25519": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz", - "integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5633,6 +5626,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index b748b51..ad19c46 100644 --- a/package.json +++ b/package.json @@ -8,16 +8,15 @@ "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", + "predev": "node scripts/verify-native-runtime.mjs --repair", + "predev:turbo": "node scripts/verify-native-runtime.mjs --repair", + "prestart": "node scripts/verify-native-runtime.mjs --check", "dev": "node server/index.js --dev", "dev:turbo": "node server/index.js --dev", "build": "next build", "start": "node server/index.js", "lint": "eslint .", "cleanup:ux-artifacts": "node scripts/cleanup-ux-artifacts.mjs", - "sync:gateway-client": "node scripts/sync-openclaw-gateway-client.ts", "studio:setup": "node scripts/studio-setup.js", "smoke:dev-server": "node scripts/smoke-dev-server.mjs", "typecheck": "tsc --noEmit", @@ -27,7 +26,6 @@ }, "dependencies": { "@multiavatar/multiavatar": "github:multiavatar/Multiavatar", - "@noble/ed25519": "^3.0.0", "@vercel/otel": "^2.1.0", "better-sqlite3": "^12.6.2", "clsx": "^2.1.1", diff --git a/scripts/sync-openclaw-gateway-client.ts b/scripts/sync-openclaw-gateway-client.ts deleted file mode 100644 index fa9ca21..0000000 --- a/scripts/sync-openclaw-gateway-client.ts +++ /dev/null @@ -1,34 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -const repoRoot = process.cwd(); -const sourcePath = path.join(os.homedir(), "openclaw", "ui", "src", "ui", "gateway.ts"); -const destPath = path.join( - repoRoot, - "src", - "lib", - "gateway", - "openclaw", - "GatewayBrowserClient.ts" -); - -if (!fs.existsSync(sourcePath)) { - console.error(`Missing upstream gateway client at ${sourcePath}.`); - process.exit(1); -} - -let contents = fs.readFileSync(sourcePath, "utf8"); -contents = contents - .replace( - /from "\.\.\/\.\.\/\.\.\/src\/gateway\/protocol\/client-info\.js";/g, - 'from "./client-info";' - ) - .replace( - /from "\.\.\/\.\.\/\.\.\/src\/gateway\/device-auth\.js";/g, - 'from "./device-auth-payload";' - ); - -fs.mkdirSync(path.dirname(destPath), { recursive: true }); -fs.writeFileSync(destPath, contents, "utf8"); -console.log(`Synced gateway client to ${destPath}.`); diff --git a/scripts/verify-native-runtime.mjs b/scripts/verify-native-runtime.mjs index 5ce4a45..7bb54a6 100644 --- a/scripts/verify-native-runtime.mjs +++ b/scripts/verify-native-runtime.mjs @@ -1,15 +1,49 @@ import { spawnSync } from "node:child_process"; +import fs from "node:fs"; import { createRequire } from "node:module"; +import path from "node:path"; const require = createRequire(import.meta.url); const mode = process.argv.includes("--repair") ? "repair" : "check"; const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const npmExecPath = process.env.npm_execpath || ""; +const bundledNpmCliPath = path.resolve( + path.dirname(process.execPath), + "..", + "lib", + "node_modules", + "npm", + "bin", + "npm-cli.js" +); const log = (message) => { console.info(`[native-runtime] ${message}`); }; +const resolvePathEnvKey = () => { + for (const key of Object.keys(process.env)) { + if (key.toLowerCase() === "path") return key; + } + return "PATH"; +}; + +const resolveSpawnEnv = () => { + const pathKey = resolvePathEnvKey(); + const pathDelimiter = process.platform === "win32" ? ";" : ":"; + const nodeBinDir = path.dirname(process.execPath); + const existingPath = process.env[pathKey] || ""; + const prefixedPath = existingPath + ? `${nodeBinDir}${pathDelimiter}${existingPath}` + : nodeBinDir; + return { + ...process.env, + [pathKey]: prefixedPath, + npm_config_scripts_prepend_node_path: "true", + }; +}; + const getErrorCode = (error) => { if (!error || typeof error !== "object" || Array.isArray(error)) return ""; const code = error.code; @@ -47,7 +81,10 @@ const printRemediation = () => { const verifyLoad = () => { try { - require("better-sqlite3"); + const BetterSqlite3 = require("better-sqlite3"); + const db = new BetterSqlite3(":memory:"); + db.prepare("SELECT 1").get(); + db.close(); return { ok: true }; } catch (error) { return { @@ -59,15 +96,40 @@ const verifyLoad = () => { }; const rebuildBetterSqlite = () => { - const result = spawnSync(npmCommand, ["rebuild", "better-sqlite3"], { + const spawnEnv = resolveSpawnEnv(); + if (fs.existsSync(bundledNpmCliPath)) { + const viaBundledNpm = spawnSync( + process.execPath, + [bundledNpmCliPath, "rebuild", "better-sqlite3"], + { + stdio: "inherit", + env: spawnEnv, + } + ); + if (viaBundledNpm.status === 0) return true; + } + + if (npmExecPath.trim()) { + const viaExecPath = spawnSync( + process.execPath, + [npmExecPath, "rebuild", "better-sqlite3"], + { + stdio: "inherit", + env: spawnEnv, + } + ); + if (viaExecPath.status === 0) return true; + } + const viaPath = spawnSync(npmCommand, ["rebuild", "better-sqlite3"], { stdio: "inherit", - env: process.env, + env: spawnEnv, }); - return result.status === 0; + return viaPath.status === 0; }; log(`mode=${mode}`); log(`node=${process.version} abi=${process.versions.modules}`); +log(`node_exec=${process.execPath}`); const firstPass = verifyLoad(); if (firstPass.ok) { diff --git a/server/gateway-proxy.js b/server/gateway-proxy.js deleted file mode 100644 index 17f8497..0000000 --- a/server/gateway-proxy.js +++ /dev/null @@ -1,295 +0,0 @@ -const { WebSocket, WebSocketServer } = require("ws"); - -const buildErrorResponse = (id, code, message) => { - return { - type: "res", - id, - ok: false, - error: { code, message }, - }; -}; - -const isObject = (value) => Boolean(value && typeof value === "object"); - -const safeJsonParse = (raw) => { - try { - return JSON.parse(raw); - } catch { - return null; - } -}; - -const resolvePathname = (url) => { - const raw = typeof url === "string" ? url : ""; - const idx = raw.indexOf("?"); - return (idx === -1 ? raw : raw.slice(0, idx)) || "/"; -}; - -const injectAuthToken = (params, token) => { - const next = isObject(params) ? { ...params } : {}; - const auth = isObject(next.auth) ? { ...next.auth } : {}; - auth.token = token; - next.auth = auth; - return next; -}; - -const resolveOriginForUpstream = (upstreamUrl) => { - const url = new URL(upstreamUrl); - const proto = url.protocol === "wss:" ? "https:" : "http:"; - const hostname = - url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "0.0.0.0" - ? "localhost" - : url.hostname; - const host = url.port ? `${hostname}:${url.port}` : hostname; - return `${proto}//${host}`; -}; - -const hasNonEmptyToken = (params) => { - const raw = params && isObject(params) && isObject(params.auth) ? params.auth.token : ""; - return typeof raw === "string" && raw.trim().length > 0; -}; - -const hasNonEmptyPassword = (params) => { - const raw = params && isObject(params) && isObject(params.auth) ? params.auth.password : ""; - return typeof raw === "string" && raw.trim().length > 0; -}; - -const hasNonEmptyDeviceToken = (params) => { - const raw = params && isObject(params) && isObject(params.auth) ? params.auth.deviceToken : ""; - return typeof raw === "string" && raw.trim().length > 0; -}; - -const hasCompleteDeviceAuth = (params) => { - const device = params && isObject(params) && isObject(params.device) ? params.device : null; - if (!device) { - return false; - } - const id = typeof device.id === "string" ? device.id.trim() : ""; - const publicKey = typeof device.publicKey === "string" ? device.publicKey.trim() : ""; - const signature = typeof device.signature === "string" ? device.signature.trim() : ""; - const nonce = typeof device.nonce === "string" ? device.nonce.trim() : ""; - const signedAt = device.signedAt; - return ( - id.length > 0 && - publicKey.length > 0 && - signature.length > 0 && - nonce.length > 0 && - Number.isFinite(signedAt) && - signedAt >= 0 - ); -}; - -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") { - throw new Error("createGatewayProxy requires loadUpstreamSettings()."); - } - - const wss = new WebSocketServer({ noServer: true }); - - wss.on("connection", (browserWs) => { - let upstreamWs = null; - let upstreamReady = false; - let connectRequestId = null; - let connectResponseSent = false; - let closed = false; - - const closeBoth = (code, reason) => { - if (closed) return; - closed = true; - try { - browserWs.close(code, reason); - } catch {} - try { - upstreamWs?.close(code, reason); - } catch {} - }; - - const sendToBrowser = (frame) => { - if (browserWs.readyState !== WebSocket.OPEN) return; - browserWs.send(JSON.stringify(frame)); - }; - - const sendConnectError = (code, message) => { - if (connectRequestId && !connectResponseSent) { - connectResponseSent = true; - sendToBrowser(buildErrorResponse(connectRequestId, code, message)); - } - closeBoth(1011, "connect failed"); - }; - - browserWs.on("message", async (raw) => { - const parsed = safeJsonParse(String(raw ?? "")); - if (!parsed || !isObject(parsed)) { - closeBoth(1003, "invalid json"); - return; - } - - if (!upstreamWs) { - if (parsed.type !== "req" || parsed.method !== "connect") { - closeBoth(1008, "connect required"); - return; - } - const id = typeof parsed.id === "string" ? parsed.id : ""; - if (!id) { - closeBoth(1008, "connect id required"); - return; - } - connectRequestId = id; - const browserHasAuth = - hasNonEmptyToken(parsed.params) || - hasNonEmptyPassword(parsed.params) || - hasNonEmptyDeviceToken(parsed.params) || - hasCompleteDeviceAuth(parsed.params); - - let upstreamUrl = ""; - let upstreamToken = ""; - try { - const settings = await loadUpstreamSettings(); - upstreamUrl = typeof settings?.url === "string" ? settings.url.trim() : ""; - upstreamToken = typeof settings?.token === "string" ? settings.token.trim() : ""; - } catch (err) { - logError("Failed to load upstream gateway settings.", err); - sendConnectError("studio.settings_load_failed", "Failed to load Studio gateway settings."); - return; - } - - if (!upstreamUrl) { - sendConnectError( - "studio.gateway_url_missing", - "Upstream gateway URL is not configured on the Studio host." - ); - return; - } - if (!upstreamToken && !browserHasAuth) { - sendConnectError( - "studio.gateway_token_missing", - "Upstream gateway token is not configured on the Studio host." - ); - return; - } - - let upstreamOrigin = ""; - try { - upstreamOrigin = resolveOriginForUpstream(upstreamUrl); - } catch { - sendConnectError( - "studio.gateway_url_invalid", - "Upstream gateway URL is invalid on the Studio host." - ); - return; - } - - 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; - if (browserHasAuth) { - upstreamWs.send(JSON.stringify(parsed)); - return; - } - - const connectFrame = { - ...parsed, - params: injectAuthToken(parsed.params, upstreamToken), - }; - upstreamWs.send(JSON.stringify(connectFrame)); - }); - - upstreamWs.on("message", (upRaw) => { - const upParsed = safeJsonParse(String(upRaw ?? "")); - if (upParsed && isObject(upParsed) && upParsed.type === "res") { - const resId = typeof upParsed.id === "string" ? upParsed.id : ""; - if (resId && connectRequestId && resId === connectRequestId) { - connectResponseSent = true; - } - } - if (browserWs.readyState === WebSocket.OPEN) { - browserWs.send(String(upRaw ?? "")); - } - }); - - upstreamWs.on("close", (ev) => { - const reason = typeof ev?.reason === "string" ? ev.reason : ""; - if (!connectResponseSent) { - sendToBrowser( - buildErrorResponse( - connectRequestId, - "studio.upstream_closed", - `Upstream gateway closed (${ev.code}): ${reason}` - ) - ); - } - closeBoth(1012, "upstream closed"); - }); - - 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", - "Failed to connect to upstream gateway WebSocket." - ); - }); - - log("proxy connected"); - return; - } - - if (!upstreamReady || upstreamWs.readyState !== WebSocket.OPEN) { - closeBoth(1013, "upstream not ready"); - return; - } - - upstreamWs.send(JSON.stringify(parsed)); - }); - - browserWs.on("close", () => { - closeBoth(1000, "client closed"); - }); - - browserWs.on("error", (err) => { - logError("Browser WebSocket error.", err); - closeBoth(1011, "client error"); - }); - }); - - const handleUpgrade = (req, socket, head) => { - if (!allowWs(req)) { - socket.destroy(); - return; - } - wss.handleUpgrade(req, socket, head, (ws) => { - wss.emit("connection", ws, req); - }); - }; - - return { wss, handleUpgrade }; -} - -module.exports = { createGatewayProxy }; diff --git a/server/index.js b/server/index.js index 8d2c466..e9cc285 100644 --- a/server/index.js +++ b/server/index.js @@ -2,12 +2,12 @@ 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 path = require("node:path"); +const { spawnSync } = require("node:child_process"); const next = require("next"); const { createAccessGate } = require("./access-gate"); -const { createGatewayProxy } = require("./gateway-proxy"); const { assertPublicHostAllowed, resolveHosts } = require("./network-policy"); -const { loadUpstreamGatewaySettings } = require("./studio-settings"); const resolvePort = () => { const raw = process.env.PORT?.trim() || "3000"; @@ -16,14 +16,24 @@ const resolvePort = () => { return port; }; -const resolvePathname = (url) => { - const raw = typeof url === "string" ? url : ""; - const idx = raw.indexOf("?"); - return (idx === -1 ? raw : raw.slice(0, idx)) || "/"; +const verifyNativeRuntime = (dev) => { + if (process.env.OPENCLAW_SKIP_NATIVE_RUNTIME_VERIFY === "1") return; + const scriptPath = path.resolve(__dirname, "..", "scripts", "verify-native-runtime.mjs"); + const modeArg = dev ? "--repair" : "--check"; + const result = spawnSync(process.execPath, [scriptPath, modeArg], { + stdio: "inherit", + env: process.env, + }); + if (result.status === 0) return; + if (typeof result.status === "number" && result.status !== 0) { + process.exit(result.status); + } + throw result.error ?? new Error("Failed to verify native runtime dependencies."); }; async function main() { const dev = process.argv.includes("--dev"); + verifyNativeRuntime(dev); const hostnames = Array.from(new Set(resolveHosts(process.env))); const hostname = hostnames[0] ?? "127.0.0.1"; const port = resolvePort(); @@ -46,27 +56,7 @@ async function main() { token: process.env.STUDIO_ACCESS_TOKEN, }); - const proxy = createGatewayProxy({ - loadUpstreamSettings: async () => { - const settings = loadUpstreamGatewaySettings(process.env); - return { url: settings.url, token: settings.token }; - }, - allowWs: (req) => { - if (resolvePathname(req.url) !== "/api/gateway/ws") return false; - if (!accessGate.allowUpgrade(req)) return false; - return true; - }, - }); - await app.prepare(); - const handleUpgrade = app.getUpgradeHandler(); - const handleServerUpgrade = (req, socket, head) => { - if (resolvePathname(req.url) === "/api/gateway/ws") { - proxy.handleUpgrade(req, socket, head); - return; - } - handleUpgrade(req, socket, head); - }; const createServer = () => http.createServer((req, res) => { @@ -76,21 +66,6 @@ async function main() { const servers = hostnames.map(() => createServer()); - const attachUpgradeHandlers = (server) => { - server.on("upgrade", handleServerUpgrade); - server.on("newListener", (eventName, listener) => { - if (eventName !== "upgrade") return; - if (listener === handleServerUpgrade) return; - process.nextTick(() => { - server.removeListener("upgrade", listener); - }); - }); - }; - - for (const server of servers) { - attachUpgradeHandlers(server); - } - const listenOnHost = (server, host) => new Promise((resolve, reject) => { const onError = (err) => { diff --git a/src/app/api/intents/agent-file-set/route.ts b/src/app/api/intents/agent-file-set/route.ts new file mode 100644 index 0000000..8e1226f --- /dev/null +++ b/src/app/api/intents/agent-file-set/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; + +import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + const bodyOrError = await parseIntentBody(request); + if (bodyOrError instanceof Response) { + return bodyOrError as NextResponse; + } + + const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : ""; + const name = typeof bodyOrError.name === "string" ? bodyOrError.name.trim() : ""; + const content = typeof bodyOrError.content === "string" ? bodyOrError.content : null; + if (!agentId || !name || content === null) { + return NextResponse.json({ error: "agentId, name, and content are required." }, { status: 400 }); + } + + return await executeGatewayIntent("agents.files.set", { + agentId, + name, + content, + }); +} diff --git a/src/app/api/intents/agent-skills-allowlist/route.ts b/src/app/api/intents/agent-skills-allowlist/route.ts new file mode 100644 index 0000000..76b2e42 --- /dev/null +++ b/src/app/api/intents/agent-skills-allowlist/route.ts @@ -0,0 +1,260 @@ +import { NextResponse } from "next/server"; + +import { + ensureDomainIntentRuntime, + parseIntentBody, +} from "@/lib/controlplane/intent-route"; +import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter"; +import type { ControlPlaneRuntime } from "@/lib/controlplane/runtime"; + +type AgentSkillsAccessMode = "all" | "none" | "allowlist"; + +type ConfigAgentEntry = Record & { id: string }; +type GatewayConfigSnapshot = { + config?: unknown; + hash?: string; + exists?: boolean; +}; + +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +const readConfigAgentList = ( + config: Record | undefined +): ConfigAgentEntry[] => { + if (!config) return []; + const agents = isRecord(config.agents) ? config.agents : null; + const list = Array.isArray(agents?.list) ? agents.list : []; + return list.filter((entry): entry is ConfigAgentEntry => { + if (!isRecord(entry)) return false; + if (typeof entry.id !== "string") return false; + return entry.id.trim().length > 0; + }); +}; + +const writeConfigAgentList = ( + config: Record, + list: ConfigAgentEntry[] +): Record => { + const agents = isRecord(config.agents) ? { ...config.agents } : {}; + return { ...config, agents: { ...agents, list } }; +}; + +const upsertConfigAgentEntry = ( + list: ConfigAgentEntry[], + agentId: string, + updater: (entry: ConfigAgentEntry) => ConfigAgentEntry +): { list: ConfigAgentEntry[]; entry: ConfigAgentEntry } => { + let updatedEntry: ConfigAgentEntry | null = null; + const nextList = list.map((entry) => { + if (entry.id !== agentId) return entry; + const next = updater({ ...entry, id: agentId }); + updatedEntry = next; + return next; + }); + if (!updatedEntry) { + updatedEntry = updater({ id: agentId }); + nextList.push(updatedEntry); + } + return { list: nextList, entry: updatedEntry }; +}; + +const normalizeSkillAllowlistInput = (values: unknown): string[] => { + if (!Array.isArray(values)) return []; + const next = values + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + return Array.from(new Set(next)).sort((a, b) => a.localeCompare(b)); +}; + +const areStringArraysEqual = (a: readonly string[], b: readonly string[]): boolean => { + if (a.length !== b.length) return false; + for (let index = 0; index < a.length; index += 1) { + if (a[index] !== b[index]) return false; + } + return true; +}; + +const buildAgentSkillsConfig = (params: { + baseConfig: Record; + agentId: string; + mode: AgentSkillsAccessMode; + skillNames?: string[]; +}): Record => { + const list = readConfigAgentList(params.baseConfig); + const currentEntry = list.find((entry) => entry.id === params.agentId); + const hasEntry = Boolean(currentEntry); + const currentRawSkills = currentEntry?.skills; + + if (params.mode === "all") { + if (!hasEntry) { + return params.baseConfig; + } + if (!Object.prototype.hasOwnProperty.call(currentEntry, "skills")) { + return params.baseConfig; + } + } + + if (params.mode === "none" && Array.isArray(currentRawSkills) && currentRawSkills.length === 0) { + return params.baseConfig; + } + + if (params.mode === "allowlist") { + const rawSkills = params.skillNames; + if (!rawSkills) { + throw new Error("Skills allowlist is required when mode is allowlist."); + } + const normalizedNext = normalizeSkillAllowlistInput(rawSkills); + if (Array.isArray(currentRawSkills)) { + const normalizedCurrent = normalizeSkillAllowlistInput(currentRawSkills); + if (areStringArraysEqual(normalizedCurrent, normalizedNext)) { + return params.baseConfig; + } + } + } + + const { list: nextList } = upsertConfigAgentEntry(list, params.agentId, (entry) => { + const next: ConfigAgentEntry = { ...entry, id: params.agentId }; + if (params.mode === "all") { + if ("skills" in next) { + delete next.skills; + } + return next; + } + if (params.mode === "none") { + next.skills = []; + return next; + } + const rawSkills = params.skillNames; + if (!rawSkills) { + throw new Error("Skills allowlist is required when mode is allowlist."); + } + next.skills = normalizeSkillAllowlistInput(rawSkills); + return next; + }); + return writeConfigAgentList(params.baseConfig, nextList); +}; + +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 mapIntentError = (error: unknown): NextResponse => { + if (error instanceof ControlPlaneGatewayError) { + if (error.code.trim().toUpperCase() === "GATEWAY_UNAVAILABLE") { + return NextResponse.json( + { + error: error.message, + code: "GATEWAY_UNAVAILABLE", + reason: "gateway_unavailable", + }, + { status: 503 } + ); + } + return NextResponse.json( + { + error: error.message, + code: error.code, + details: error.details, + }, + { status: 400 } + ); + } + const message = error instanceof Error ? error.message : "intent_failed"; + return NextResponse.json({ error: message }, { status: 500 }); +}; + +const applySkillsMode = async (params: { + runtime: ControlPlaneRuntime; + agentId: string; + mode: AgentSkillsAccessMode; + skillNames?: string[]; + attempt?: number; +}): Promise => { + const attempt = params.attempt ?? 0; + const snapshot = await params.runtime.callGateway("config.get", {}); + const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; + const nextConfig = buildAgentSkillsConfig({ + baseConfig, + agentId: params.agentId, + mode: params.mode, + skillNames: params.skillNames, + }); + if (nextConfig === baseConfig) { + return; + } + + const payload: Record = { + raw: JSON.stringify(nextConfig, null, 2), + }; + const requiresBaseHash = snapshot.exists !== false; + const baseHash = requiresBaseHash ? snapshot.hash?.trim() : undefined; + if (requiresBaseHash && !baseHash) { + throw new Error("Gateway config hash unavailable; re-run config.get."); + } + if (baseHash) { + payload.baseHash = baseHash; + } + + try { + await params.runtime.callGateway("config.set", payload); + } catch (error) { + if (attempt >= 1 || !isConfigConflict(error)) { + throw error; + } + await applySkillsMode({ ...params, attempt: attempt + 1 }); + } +}; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + const bodyOrError = await parseIntentBody(request); + if (bodyOrError instanceof Response) { + return bodyOrError as NextResponse; + } + + const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : ""; + const modeRaw = typeof bodyOrError.mode === "string" ? bodyOrError.mode.trim() : ""; + if (!agentId) { + return NextResponse.json({ error: "agentId is required." }, { status: 400 }); + } + if (modeRaw !== "all" && modeRaw !== "none" && modeRaw !== "allowlist") { + return NextResponse.json({ error: "mode must be one of: all, none, allowlist." }, { status: 400 }); + } + + const mode = modeRaw as AgentSkillsAccessMode; + const skillNames = normalizeSkillAllowlistInput(bodyOrError.skillNames); + if (mode === "allowlist" && skillNames.length === 0) { + return NextResponse.json( + { error: "skillNames must contain at least one value when mode is allowlist." }, + { status: 400 } + ); + } + + const runtimeOrError = await ensureDomainIntentRuntime(); + if (runtimeOrError instanceof Response) { + return runtimeOrError as NextResponse; + } + + try { + await applySkillsMode({ + runtime: runtimeOrError, + agentId, + mode, + ...(mode === "allowlist" ? { skillNames } : {}), + }); + return NextResponse.json({ ok: true, payload: { updated: true } }); + } catch (error) { + return mapIntentError(error); + } +} diff --git a/src/app/api/intents/chat-abort/route.ts b/src/app/api/intents/chat-abort/route.ts index b55fe7f..c73a3ae 100644 --- a/src/app/api/intents/chat-abort/route.ts +++ b/src/app/api/intents/chat-abort/route.ts @@ -13,5 +13,6 @@ export async function POST(request: Request) { if (!sessionKey) { return NextResponse.json({ error: "sessionKey is required." }, { status: 400 }); } - return await executeGatewayIntent("chat.abort", { sessionKey }); + const runId = typeof bodyOrError.runId === "string" ? bodyOrError.runId.trim() : ""; + return await executeGatewayIntent("chat.abort", runId ? { sessionKey, runId } : { sessionKey }); } diff --git a/src/app/api/intents/cron-add/route.ts b/src/app/api/intents/cron-add/route.ts new file mode 100644 index 0000000..dd37a26 --- /dev/null +++ b/src/app/api/intents/cron-add/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; + +import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + const bodyOrError = await parseIntentBody(request); + if (bodyOrError instanceof Response) { + return bodyOrError as NextResponse; + } + + const name = typeof bodyOrError.name === "string" ? bodyOrError.name.trim() : ""; + const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : ""; + if (!name || !agentId) { + return NextResponse.json({ error: "name and agentId are required." }, { status: 400 }); + } + + return await executeGatewayIntent("cron.add", bodyOrError); +} diff --git a/src/app/api/intents/cron-remove/route.ts b/src/app/api/intents/cron-remove/route.ts new file mode 100644 index 0000000..52aaa51 --- /dev/null +++ b/src/app/api/intents/cron-remove/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; + +import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + const bodyOrError = await parseIntentBody(request); + if (bodyOrError instanceof Response) { + return bodyOrError as NextResponse; + } + + const id = typeof bodyOrError.id === "string" ? bodyOrError.id.trim() : ""; + if (!id) { + return NextResponse.json({ error: "id is required." }, { status: 400 }); + } + + return await executeGatewayIntent("cron.remove", { id }); +} diff --git a/src/app/api/intents/cron-run/route.ts b/src/app/api/intents/cron-run/route.ts new file mode 100644 index 0000000..fbfa8f3 --- /dev/null +++ b/src/app/api/intents/cron-run/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; + +import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + const bodyOrError = await parseIntentBody(request); + if (bodyOrError instanceof Response) { + return bodyOrError as NextResponse; + } + + const id = typeof bodyOrError.id === "string" ? bodyOrError.id.trim() : ""; + if (!id) { + return NextResponse.json({ error: "id is required." }, { status: 400 }); + } + + return await executeGatewayIntent("cron.run", { + id, + mode: "force", + }); +} diff --git a/src/app/api/intents/skills-install/route.ts b/src/app/api/intents/skills-install/route.ts new file mode 100644 index 0000000..4ac7fec --- /dev/null +++ b/src/app/api/intents/skills-install/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; + +import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + const bodyOrError = await parseIntentBody(request); + if (bodyOrError instanceof Response) { + return bodyOrError as NextResponse; + } + + const name = typeof bodyOrError.name === "string" ? bodyOrError.name.trim() : ""; + const installId = + typeof bodyOrError.installId === "string" ? bodyOrError.installId.trim() : ""; + if (!name || !installId) { + return NextResponse.json({ error: "name and installId are required." }, { status: 400 }); + } + + const timeoutMs = + typeof bodyOrError.timeoutMs === "number" && Number.isFinite(bodyOrError.timeoutMs) + ? Math.max(1, Math.floor(bodyOrError.timeoutMs)) + : undefined; + + return await executeGatewayIntent("skills.install", { + name, + installId, + ...(typeof timeoutMs === "number" ? { timeoutMs } : {}), + }); +} diff --git a/src/app/api/gateway/skills/remove/route.ts b/src/app/api/intents/skills-remove/route.ts similarity index 100% rename from src/app/api/gateway/skills/remove/route.ts rename to src/app/api/intents/skills-remove/route.ts diff --git a/src/app/api/intents/skills-update/route.ts b/src/app/api/intents/skills-update/route.ts new file mode 100644 index 0000000..a88de09 --- /dev/null +++ b/src/app/api/intents/skills-update/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; + +import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; + +export const runtime = "nodejs"; + +const hasOwn = (value: Record, key: string) => + Object.prototype.hasOwnProperty.call(value, key); + +export async function POST(request: Request) { + const bodyOrError = await parseIntentBody(request); + if (bodyOrError instanceof Response) { + return bodyOrError as NextResponse; + } + + const skillKey = typeof bodyOrError.skillKey === "string" ? bodyOrError.skillKey.trim() : ""; + if (!skillKey) { + return NextResponse.json({ error: "skillKey is required." }, { status: 400 }); + } + + const includeEnabled = hasOwn(bodyOrError, "enabled"); + const includeApiKey = hasOwn(bodyOrError, "apiKey"); + if (!includeEnabled && !includeApiKey) { + return NextResponse.json({ error: "enabled or apiKey is required." }, { status: 400 }); + } + + return await executeGatewayIntent("skills.update", { + skillKey, + ...(includeEnabled ? { enabled: bodyOrError.enabled } : {}), + ...(includeApiKey ? { apiKey: bodyOrError.apiKey } : {}), + }); +} diff --git a/src/app/api/runtime/agent-file/route.ts b/src/app/api/runtime/agent-file/route.ts new file mode 100644 index 0000000..e1577ae --- /dev/null +++ b/src/app/api/runtime/agent-file/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; + +import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const agentId = (url.searchParams.get("agentId") ?? "").trim(); + const name = (url.searchParams.get("name") ?? "").trim(); + if (!agentId || !name) { + return NextResponse.json({ error: "agentId and name are required." }, { status: 400 }); + } + + return await executeRuntimeGatewayRead("agents.files.get", { + agentId, + name, + }); +} diff --git a/src/app/api/gateway/agent-state/route.ts b/src/app/api/runtime/agent-state/route.ts similarity index 100% rename from src/app/api/gateway/agent-state/route.ts rename to src/app/api/runtime/agent-state/route.ts diff --git a/src/app/api/runtime/chat-history/route.ts b/src/app/api/runtime/chat-history/route.ts new file mode 100644 index 0000000..aaff421 --- /dev/null +++ b/src/app/api/runtime/chat-history/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; + +import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const sessionKey = (url.searchParams.get("sessionKey") ?? "").trim(); + if (!sessionKey) { + return NextResponse.json({ error: "sessionKey is required." }, { status: 400 }); + } + const limitRaw = (url.searchParams.get("limit") ?? "0").trim(); + const limit = Number(limitRaw); + + return await executeRuntimeGatewayRead("chat.history", { + sessionKey, + ...(Number.isFinite(limit) && limit > 0 ? { limit: Math.floor(limit) } : {}), + }); +} diff --git a/src/app/api/runtime/config/route.ts b/src/app/api/runtime/config/route.ts new file mode 100644 index 0000000..91c234c --- /dev/null +++ b/src/app/api/runtime/config/route.ts @@ -0,0 +1,7 @@ +import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route"; + +export const runtime = "nodejs"; + +export async function GET() { + return await executeRuntimeGatewayRead("config.get", {}); +} diff --git a/src/app/api/runtime/cron/route.ts b/src/app/api/runtime/cron/route.ts new file mode 100644 index 0000000..c81f702 --- /dev/null +++ b/src/app/api/runtime/cron/route.ts @@ -0,0 +1,11 @@ +import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const includeDisabled = (url.searchParams.get("includeDisabled") ?? "true").trim(); + return await executeRuntimeGatewayRead("cron.list", { + includeDisabled: includeDisabled !== "false", + }); +} diff --git a/src/app/api/gateway/media/route.ts b/src/app/api/runtime/media/route.ts similarity index 100% rename from src/app/api/gateway/media/route.ts rename to src/app/api/runtime/media/route.ts diff --git a/src/app/api/runtime/models/route.ts b/src/app/api/runtime/models/route.ts new file mode 100644 index 0000000..3ea8276 --- /dev/null +++ b/src/app/api/runtime/models/route.ts @@ -0,0 +1,7 @@ +import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route"; + +export const runtime = "nodejs"; + +export async function GET() { + return await executeRuntimeGatewayRead("models.list", {}); +} diff --git a/src/app/api/runtime/sessions/route.ts b/src/app/api/runtime/sessions/route.ts new file mode 100644 index 0000000..4dc1197 --- /dev/null +++ b/src/app/api/runtime/sessions/route.ts @@ -0,0 +1,21 @@ +import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const agentId = (url.searchParams.get("agentId") ?? "").trim(); + const includeGlobal = (url.searchParams.get("includeGlobal") ?? "false").trim() !== "false"; + const includeUnknown = (url.searchParams.get("includeUnknown") ?? "false").trim() !== "false"; + const search = (url.searchParams.get("search") ?? "").trim(); + const limitRaw = (url.searchParams.get("limit") ?? "0").trim(); + const limit = Number(limitRaw); + + return await executeRuntimeGatewayRead("sessions.list", { + ...(agentId ? { agentId } : {}), + includeGlobal, + includeUnknown, + ...(search ? { search } : {}), + ...(Number.isFinite(limit) && limit > 0 ? { limit: Math.floor(limit) } : {}), + }); +} diff --git a/src/app/api/runtime/skills/status/route.ts b/src/app/api/runtime/skills/status/route.ts new file mode 100644 index 0000000..7045932 --- /dev/null +++ b/src/app/api/runtime/skills/status/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; + +import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const agentId = (url.searchParams.get("agentId") ?? "").trim(); + if (!agentId) { + return NextResponse.json({ error: "agentId is required." }, { status: 400 }); + } + return await executeRuntimeGatewayRead("skills.status", { agentId }); +} diff --git a/src/app/api/studio/route.ts b/src/app/api/studio/route.ts index be5bd9d..7609f7a 100644 --- a/src/app/api/studio/route.ts +++ b/src/app/api/studio/route.ts @@ -1,7 +1,10 @@ import { NextResponse } from "next/server"; import { type StudioSettingsPatch } from "@/lib/studio/settings"; -import { isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime"; +import { + isStudioDomainApiModeEnabled, + peekControlPlaneRuntime, +} from "@/lib/controlplane/runtime"; import { applyStudioSettingsPatch, loadLocalGatewayDefaults, @@ -15,13 +18,88 @@ export const runtime = "nodejs"; const isPatch = (value: unknown): value is StudioSettingsPatch => Boolean(value && typeof value === "object"); -const buildSettingsResponseBody = () => { +type RuntimeReconnectMetadata = { + attempted: boolean; + restarted: boolean; + reason?: string; + previousStatus?: string; + error?: string; +}; + +const normalizeGatewaySettings = (settings: ReturnType) => { + const gateway = settings.gateway ?? null; + return { + url: typeof gateway?.url === "string" ? gateway.url.trim() : "", + token: typeof gateway?.token === "string" ? gateway.token.trim() : "", + }; +}; + +const gatewaySettingsChanged = ( + previous: ReturnType, + next: ReturnType +) => { + const left = normalizeGatewaySettings(previous); + const right = normalizeGatewaySettings(next); + return left.url !== right.url || left.token !== right.token; +}; + +const reconnectRuntimeForGatewaySettingsChange = async ( + previous: ReturnType, + next: ReturnType +): Promise => { + if (!gatewaySettingsChanged(previous, next)) return null; + if (!isStudioDomainApiModeEnabled()) { + return { + attempted: false, + restarted: false, + reason: "domain_api_mode_disabled", + }; + } + const runtime = peekControlPlaneRuntime(); + if (!runtime) { + return { + attempted: false, + restarted: false, + reason: "runtime_not_initialized", + }; + } + const previousStatus = runtime.connectionStatus(); + if (previousStatus === "stopped") { + return { + attempted: false, + restarted: false, + reason: "runtime_stopped", + previousStatus, + }; + } + try { + await runtime.reconnectForGatewaySettingsChange(); + return { + attempted: true, + restarted: true, + previousStatus, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : "controlplane_reconnect_failed"; + console.error("Failed to reconnect control-plane runtime after gateway settings update.", error); + return { + attempted: true, + restarted: false, + previousStatus, + error: message, + }; + } +}; + +const buildSettingsResponseBody = (metadata?: RuntimeReconnectMetadata | null) => { const settings = loadStudioSettings(); const localGatewayDefaults = loadLocalGatewayDefaults(); return { settings: redactStudioSettingsSecrets(settings), localGatewayDefaults: redactLocalGatewayDefaultsSecrets(localGatewayDefaults), domainApiModeEnabled: isStudioDomainApiModeEnabled(), + ...(metadata ? { runtimeReconnect: metadata } : {}), }; }; @@ -41,8 +119,13 @@ export async function PUT(request: Request) { if (!isPatch(body)) { return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 }); } - applyStudioSettingsPatch(body); - return NextResponse.json(buildSettingsResponseBody()); + const previousSettings = loadStudioSettings(); + const nextSettings = applyStudioSettingsPatch(body); + const runtimeReconnect = await reconnectRuntimeForGatewaySettingsChange( + previousSettings, + nextSettings + ); + return NextResponse.json(buildSettingsResponseBody(runtimeReconnect)); } catch (err) { const message = err instanceof Error ? err.message : "Failed to save studio settings."; console.error(message); diff --git a/src/app/page.tsx b/src/app/page.tsx index d41c467..e37795c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -16,10 +16,8 @@ import { EmptyStatePanel } from "@/features/agents/components/EmptyStatePanel"; import { isHeartbeatPrompt, } from "@/lib/text/message-extract"; -import { - useGatewayConnection, - type GatewayStatus, -} from "@/lib/gateway/GatewayClient"; +import { useStudioGatewaySettings } from "@/lib/studio/useStudioGatewaySettings"; +import type { GatewayStatus } from "@/lib/gateway/gateway-status"; import type { ControlPlaneOutboxEntry } from "@/lib/controlplane/contracts"; import { type GatewayModelChoice, @@ -37,7 +35,6 @@ import { createGatewayRuntimeEventHandler } from "@/features/agents/state/gatewa import { type CronJobSummary, formatCronJobDisplay, - listCronJobs, resolveLatestCronJobForAgent, } from "@/lib/cron/types"; import { @@ -51,8 +48,8 @@ import { applySessionSettingMutation } from "@/features/agents/state/sessionSett import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types"; import { isGatewayDisconnectLikeError, - type EventFrame, -} from "@/lib/gateway/GatewayClient"; +} from "@/lib/gateway/gateway-disconnect"; +import type { EventFrame } from "@/lib/gateway/gateway-frames"; import { useConfigMutationQueue, type ConfigMutationKind, @@ -115,6 +112,11 @@ import { type SettingsRouteTab, } from "@/features/agents/operations/settingsRouteWorkflow"; import { useSettingsRouteController } from "@/features/agents/operations/useSettingsRouteController"; +import { + loadDomainChatHistory, + listDomainCronJobs, + listDomainSessions, +} from "@/lib/controlplane/domain-runtime-client"; const PENDING_EXEC_APPROVAL_PRUNE_GRACE_MS = 500; type MobilePane = "fleet" | "chat"; @@ -227,10 +229,12 @@ const AgentStudioPage = () => { useLocalGatewayDefaults, setGatewayUrl, setToken, - } = useGatewayConnection(settingsCoordinator); - const useDomainApiMode = domainApiModeEnabled === true; - const coreConnected = useDomainApiMode ? true : status === "connected"; - const coreStatus: GatewayStatus = coreConnected ? "connected" : status; + } = useStudioGatewaySettings(settingsCoordinator); + const useDomainApiMode = domainApiModeEnabled; + const gatewayStatus: GatewayStatus = status; + const gatewayConnected = gatewayStatus === "connected"; + const coreConnected = useDomainApiMode ? true : gatewayConnected; + const coreStatus: GatewayStatus = useDomainApiMode ? "connected" : gatewayStatus; const runtimeWriteTransport = useMemo( () => createRuntimeWriteTransport({ @@ -452,9 +456,36 @@ const AgentStudioPage = () => { }, []); const specialLatestUpdate = useMemo(() => { + const callGateway = async (method: string, params: unknown): Promise => { + if (method === "sessions.list") { + const body = (params ?? {}) as { + agentId?: string; + includeGlobal?: boolean; + includeUnknown?: boolean; + search?: string; + limit?: number; + }; + return await listDomainSessions({ + agentId: body.agentId ?? "", + includeGlobal: body.includeGlobal, + includeUnknown: body.includeUnknown, + search: body.search, + limit: body.limit, + }); + } + if (method === "chat.history") { + const body = (params ?? {}) as { sessionKey?: string; limit?: number }; + return await loadDomainChatHistory({ + sessionKey: body.sessionKey ?? "", + limit: body.limit, + }); + } + throw new Error(`Unsupported special latest-update method in domain mode: ${method}`); + }; + return createSpecialLatestUpdateOperation({ - callGateway: (method, params) => client.call(method, params), - listCronJobs: () => listCronJobs(client, { includeDisabled: true }), + callGateway, + listCronJobs: () => listDomainCronJobs({ includeDisabled: true }), resolveCronJobForAgent, formatCronJobDisplay, dispatchUpdateAgent: (agentId, patch) => { @@ -463,7 +494,7 @@ const AgentStudioPage = () => { isDisconnectLikeError: isGatewayDisconnectLikeError, logError: (message) => console.error(message), }); - }, [client, dispatch, resolveCronJobForAgent]); + }, [dispatch, resolveCronJobForAgent]); const refreshHeartbeatLatestUpdate = useCallback(() => { const agents = stateRef.current.agents; @@ -520,7 +551,8 @@ const AgentStudioPage = () => { const { refreshGatewayConfigSnapshot } = useGatewayConfigSyncController({ client, - status, + status: gatewayStatus, + useDomainApiReads: useDomainApiMode, settingsRouteActive, inspectSidebarAgentId, gatewayConfigSnapshot, @@ -535,7 +567,7 @@ const AgentStudioPage = () => { const settingsMutationController = useAgentSettingsMutationController({ client, runtimeWriteTransport, - status, + status: gatewayStatus, isLocalGateway, agents, hasCreateBlock: Boolean(createAgentBlock), @@ -583,7 +615,7 @@ const AgentStudioPage = () => { queuedBlockedByRunningAgents, activeConfigMutation, } = useConfigMutationQueue({ - status: coreStatus, + status: gatewayStatus, hasRunningAgents, hasRestartBlockInProgress, }); @@ -661,7 +693,7 @@ const AgentStudioPage = () => { useEffect(() => { const commands = runStudioFocusedSelectionPersistenceOperation({ gatewayUrl, - status, + status: coreStatus, focusedPreferencesLoaded, agentsLoadedOnce, selectedAgentId: state.selectedAgentId, @@ -675,7 +707,7 @@ const AgentStudioPage = () => { focusedPreferencesLoaded, gatewayUrl, settingsCoordinator, - status, + coreStatus, state.selectedAgentId, ]); @@ -814,7 +846,7 @@ const AgentStudioPage = () => { } = useChatInteractionController({ client, runtimeWriteTransport, - status: coreStatus, + status: gatewayStatus, agents, dispatch, setError, @@ -854,7 +886,7 @@ const AgentStudioPage = () => { } = useSettingsRouteController({ settingsRouteActive, settingsRouteAgentId, - status: coreStatus, + status: gatewayStatus, agentsLoadedOnce, selectedAgentId: state.selectedAgentId, focusedAgentId: focusedAgent?.agentId ?? null, @@ -918,7 +950,7 @@ const AgentStudioPage = () => { await runCreateAgentMutationLifecycle( { payload, - status: coreStatus, + status: gatewayStatus, hasCreateBlock: Boolean(createAgentBlock), hasRenameBlock: hasRenameMutationBlock, hasDeleteBlock: hasDeleteMutationBlock, @@ -1019,7 +1051,7 @@ const AgentStudioPage = () => { refreshGatewayConfigSnapshot, runtimeWriteTransport, setError, - coreStatus, + gatewayStatus, ] ); @@ -1158,7 +1190,7 @@ const AgentStudioPage = () => { const pauseRunForExecApproval = useCallback( async (approval: PendingExecApproval, preferredAgentId?: string | null) => { await runPauseRunForExecApprovalOperation({ - status: coreStatus, + status: gatewayStatus, runtimeWriteTransport, approval, preferredAgentId: preferredAgentId ?? null, @@ -1168,7 +1200,7 @@ const AgentStudioPage = () => { logWarn: (message, error) => console.warn(message, error), }); }, - [coreStatus, runtimeWriteTransport] + [gatewayStatus, runtimeWriteTransport] ); const handleGatewayEventIngress = useCallback( @@ -1243,49 +1275,39 @@ const AgentStudioPage = () => { pendingDomainOutboxEntriesRef.current = []; ingestDomainOutboxEntries(pendingEntries); } - let unsubscribeGatewayEvents: (() => void) | null = null; let stream: EventSource | null = null; - if (useDomainApiMode) { - stream = new EventSource("/api/runtime/stream"); - stream.addEventListener("gateway.event", (raw) => { - const message = raw as MessageEvent; - try { - const parsed = JSON.parse(message.data) as { - event?: string; - payload?: unknown; - seq?: number; - }; - if (typeof parsed.event !== "string") return; - const frame: EventFrame = { - type: "event", - event: parsed.event, - payload: parsed.payload, - ...(typeof parsed.seq === "number" ? { seq: parsed.seq } : {}), - }; - handler.handleEvent(frame); - handleGatewayEventIngress(frame); - } catch {} - }); - stream.addEventListener("runtime.status", () => { - void loadSummarySnapshot(); - }); - stream.onerror = () => { - // EventSource performs automatic reconnect; keep warning low-noise. - }; - } else { - unsubscribeGatewayEvents = client.onEvent((event: EventFrame) => { - handler.handleEvent(event); - handleGatewayEventIngress(event); - }); - } + stream = new EventSource("/api/runtime/stream"); + stream.addEventListener("gateway.event", (raw) => { + const message = raw as MessageEvent; + try { + const parsed = JSON.parse(message.data) as { + event?: string; + payload?: unknown; + seq?: number; + }; + if (typeof parsed.event !== "string") return; + const frame: EventFrame = { + type: "event", + event: parsed.event, + payload: parsed.payload, + ...(typeof parsed.seq === "number" ? { seq: parsed.seq } : {}), + }; + handler.handleEvent(frame); + handleGatewayEventIngress(frame); + } catch {} + }); + stream.addEventListener("runtime.status", () => { + void loadSummarySnapshot(); + }); + stream.onerror = () => { + // EventSource performs automatic reconnect; keep warning low-noise. + }; return () => { runtimeEventHandlerRef.current = null; handler.dispose(); - unsubscribeGatewayEvents?.(); stream?.close(); }; }, [ - client, dispatch, loadAgentHistory, loadSummarySnapshot, @@ -1295,7 +1317,6 @@ const AgentStudioPage = () => { specialLatestUpdate, handleGatewayEventIngress, ingestDomainOutboxEntries, - useDomainApiMode, coreStatus, ]); @@ -1319,7 +1340,7 @@ const AgentStudioPage = () => { : queuedConfigMutationCount > 0 ? queuedBlockedByRunningAgents ? `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}; waiting for ${runningAgentCount} running agent${runningAgentCount === 1 ? "" : "s"} to finish` - : !coreConnected + : !gatewayConnected ? `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}; waiting for gateway connection` : `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}` : null; @@ -1337,7 +1358,7 @@ const AgentStudioPage = () => { sawDisconnect: restartingMutationBlock.sawDisconnect, } : null, - status, + status: gatewayStatus, }); const restartingMutationModalTestId = restartingMutationBlock ? restartingMutationBlock.kind === "delete-agent" @@ -1389,7 +1410,7 @@ const AgentStudioPage = () => {
setShowConnectionPanel(true)} />
@@ -1408,7 +1429,7 @@ const AgentStudioPage = () => { gatewayUrl={gatewayUrl} token={token} localGatewayDefaults={localGatewayDefaults} - status={status} + status={gatewayStatus} error={gatewayError} onGatewayUrlChange={setGatewayUrl} onTokenChange={setToken} @@ -1447,7 +1468,7 @@ const AgentStudioPage = () => { ) : null}
setShowConnectionPanel(true)} />
@@ -1457,7 +1478,7 @@ const AgentStudioPage = () => { { {inspectSidebarAgent ? ( effectiveSettingsTab === "personality" ? ( { onCreateAgent={() => { handleOpenCreateAgentModal(); }} - createDisabled={!coreConnected || createAgentBusy || state.loading} + createDisabled={!gatewayConnected || createAgentBusy || state.loading} createBusy={createAgentBusy} onSelectAgent={handleFleetSelectAgent} /> @@ -1722,7 +1743,7 @@ const AgentStudioPage = () => { { onRemoveQueuedMessage={(index) => removeQueuedMessage(focusedAgent.agentId, index) } - onStopRun={() => handleStopRun(focusedAgent.agentId, focusedAgent.sessionKey)} + onStopRun={() => + handleStopRun( + focusedAgent.agentId, + focusedAgent.sessionKey, + focusedAgent.runId + ) + } onAvatarShuffle={() => handleAvatarShuffle(focusedAgent.agentId)} pendingExecApprovals={focusedPendingExecApprovals} onResolveExecApproval={(id, decision) => { @@ -1766,7 +1793,7 @@ const AgentStudioPage = () => { description={ hasAnyAgents ? undefined - : coreConnected + : gatewayConnected ? "Use New Agent in the sidebar to add your first agent." : "Connect to your gateway to load agents into the studio." } diff --git a/src/features/agents/approvals/execApprovalControlLoopWorkflow.ts b/src/features/agents/approvals/execApprovalControlLoopWorkflow.ts index 9c2c303..0f3ecca 100644 --- a/src/features/agents/approvals/execApprovalControlLoopWorkflow.ts +++ b/src/features/agents/approvals/execApprovalControlLoopWorkflow.ts @@ -15,7 +15,7 @@ import { type CronTranscriptIntent, } from "@/features/agents/state/gatewayEventIngressWorkflow"; import type { AgentState } from "@/features/agents/state/store"; -import type { EventFrame } from "@/lib/gateway/GatewayClient"; +import type { EventFrame } from "@/lib/gateway/gateway-frames"; export type ExecApprovalPendingSnapshot = ApprovalPendingState; diff --git a/src/features/agents/approvals/execApprovalEvents.ts b/src/features/agents/approvals/execApprovalEvents.ts index f9115ee..e130842 100644 --- a/src/features/agents/approvals/execApprovalEvents.ts +++ b/src/features/agents/approvals/execApprovalEvents.ts @@ -1,5 +1,5 @@ import type { AgentState } from "@/features/agents/state/store"; -import type { EventFrame } from "@/lib/gateway/GatewayClient"; +import type { EventFrame } from "@/lib/gateway/gateway-frames"; import type { ExecApprovalDecision } from "@/features/agents/approvals/types"; type RequestedPayload = { diff --git a/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts b/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts index 00c7e0c..82ffe94 100644 --- a/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts +++ b/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts @@ -5,7 +5,7 @@ import { resolveExecApprovalAgentId, } from "@/features/agents/approvals/execApprovalEvents"; import type { AgentState } from "@/features/agents/state/store"; -import type { EventFrame } from "@/lib/gateway/GatewayClient"; +import type { EventFrame } from "@/lib/gateway/gateway-frames"; import { GatewayResponseError } from "@/lib/gateway/errors"; export type ExecApprovalEventEffects = { diff --git a/src/features/agents/approvals/execApprovalRunControlOperation.ts b/src/features/agents/approvals/execApprovalRunControlOperation.ts index 3555078..77b148f 100644 --- a/src/features/agents/approvals/execApprovalRunControlOperation.ts +++ b/src/features/agents/approvals/execApprovalRunControlOperation.ts @@ -15,7 +15,7 @@ import { import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation"; import type { RuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport"; import type { AgentState } from "@/features/agents/state/store"; -import type { EventFrame } from "@/lib/gateway/GatewayClient"; +import type { EventFrame } from "@/lib/gateway/gateway-frames"; import { EXEC_APPROVAL_AUTO_RESUME_MARKER } from "@/lib/text/message-extract"; type GatewayClientLike = { diff --git a/src/features/agents/components/AgentInspectPanels.tsx b/src/features/agents/components/AgentInspectPanels.tsx index d18259a..3242364 100644 --- a/src/features/agents/components/AgentInspectPanels.tsx +++ b/src/features/agents/components/AgentInspectPanels.tsx @@ -17,9 +17,9 @@ import { import type { AgentState } from "@/features/agents/state/store"; import type { CronCreateDraft, CronCreateTemplateId } from "@/lib/cron/createPayloadBuilder"; import { formatCronPayload, formatCronSchedule, type CronJobSummary } from "@/lib/cron/types"; -import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import type { GatewayStatus } from "@/lib/gateway/gateway-status"; import type { SkillStatusReport } from "@/lib/skills/types"; -import { readGatewayAgentFile, writeGatewayAgentFile } from "@/lib/gateway/agentFiles"; +import { readDomainAgentFile, writeDomainAgentFile } from "@/lib/controlplane/domain-runtime-client"; import { resolveExecutionRoleFromAgent, resolvePresetDefaultsForRole, @@ -1201,10 +1201,10 @@ type UseAgentFilesEditorResult = { }; const useAgentFilesEditor = (params: { - client: GatewayClient | null | undefined; agentId: string | null | undefined; + gatewayStatus: GatewayStatus; }): UseAgentFilesEditorResult => { - const { client, agentId } = params; + const { agentId, gatewayStatus } = params; const [agentFiles, setAgentFiles] = useState(createAgentFilesState); const [agentFilesLoading, setAgentFilesLoading] = useState(false); const [agentFilesSaving, setAgentFilesSaving] = useState(false); @@ -1233,13 +1233,17 @@ const useAgentFilesEditor = (params: { setAgentFilesError("Agent ID is missing for this agent."); return; } - if (!client) { - setAgentFilesError("Gateway client is not available."); + if (gatewayStatus !== "connected") { + if (gatewayStatus === "connecting") { + setAgentFilesError(null); + } else { + setAgentFilesError("Gateway is not connected."); + } return; } const results = await Promise.all( AGENT_FILE_NAMES.map(async (name) => { - const file = await readGatewayAgentFile({ client, agentId: trimmedAgentId, name }); + const file = await readDomainAgentFile({ agentId: trimmedAgentId, name }); return { name, content: file.content, exists: file.exists }; }) ); @@ -1260,7 +1264,7 @@ const useAgentFilesEditor = (params: { } finally { setAgentFilesLoading(false); } - }, [agentId, client]); + }, [agentId, gatewayStatus]); const saveAgentFiles = useCallback(async () => { setAgentFilesSaving(true); @@ -1271,14 +1275,13 @@ const useAgentFilesEditor = (params: { setAgentFilesError("Agent ID is missing for this agent."); return false; } - if (!client) { - setAgentFilesError("Gateway client is not available."); + if (gatewayStatus !== "connected") { + setAgentFilesError("Gateway is not connected."); return false; } await Promise.all( AGENT_FILE_NAMES.map(async (name) => { - await writeGatewayAgentFile({ - client, + await writeDomainAgentFile({ agentId: trimmedAgentId, name, content: agentFiles[name].content, @@ -1303,7 +1306,7 @@ const useAgentFilesEditor = (params: { } finally { setAgentFilesSaving(false); } - }, [agentFiles, agentId, client]); + }, [agentFiles, agentId, gatewayStatus]); const setAgentFileContent = useCallback((name: AgentFileName, value: string) => { if (!isAgentFileName(name)) return; @@ -1337,7 +1340,7 @@ const useAgentFilesEditor = (params: { }; type AgentBrainPanelProps = { - client: GatewayClient; + gatewayStatus: GatewayStatus; agents: AgentState[]; selectedAgentId: string | null; onUnsavedChangesChange?: (dirty: boolean) => void; @@ -1357,7 +1360,7 @@ const AgentBrainPanelSection = ({ ); export const AgentBrainPanel = ({ - client, + gatewayStatus, agents, selectedAgentId, onUnsavedChangesChange, @@ -1379,7 +1382,10 @@ export const AgentBrainPanel = ({ setAgentFileContent, saveAgentFiles, discardAgentFileChanges, - } = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null }); + } = useAgentFilesEditor({ + agentId: selectedAgent?.agentId ?? null, + gatewayStatus, + }); const draft = useMemo(() => parsePersonalityFiles(agentFiles), [agentFiles]); const setIdentityField = useCallback( diff --git a/src/features/agents/components/ConnectionPanel.tsx b/src/features/agents/components/ConnectionPanel.tsx index 553098b..8f6af22 100644 --- a/src/features/agents/components/ConnectionPanel.tsx +++ b/src/features/agents/components/ConnectionPanel.tsx @@ -1,4 +1,4 @@ -import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; +import type { GatewayStatus } from "@/lib/gateway/gateway-status"; import { X } from "lucide-react"; import { resolveGatewayStatusBadgeClass, resolveGatewayStatusLabel } from "./colorSemantics"; diff --git a/src/features/agents/components/GatewayConnectScreen.tsx b/src/features/agents/components/GatewayConnectScreen.tsx index 42545e2..593c358 100644 --- a/src/features/agents/components/GatewayConnectScreen.tsx +++ b/src/features/agents/components/GatewayConnectScreen.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { Check, Copy, Eye, EyeOff, Loader2 } from "lucide-react"; -import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; +import type { GatewayStatus } from "@/lib/gateway/gateway-status"; import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; import type { StudioGatewaySettings } from "@/lib/studio/settings"; diff --git a/src/features/agents/components/HeaderBar.tsx b/src/features/agents/components/HeaderBar.tsx index c82b002..d49fb87 100644 --- a/src/features/agents/components/HeaderBar.tsx +++ b/src/features/agents/components/HeaderBar.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { ThemeToggle } from "@/components/theme-toggle"; -import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; +import type { GatewayStatus } from "@/lib/gateway/gateway-status"; import { Plug } from "lucide-react"; import { resolveGatewayStatusBadgeClass } from "./colorSemantics"; diff --git a/src/features/agents/components/colorSemantics.ts b/src/features/agents/components/colorSemantics.ts index c1b21c4..fbb61eb 100644 --- a/src/features/agents/components/colorSemantics.ts +++ b/src/features/agents/components/colorSemantics.ts @@ -1,5 +1,5 @@ import type { AgentStatus } from "@/features/agents/state/store"; -import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; +import type { GatewayStatus } from "@/lib/gateway/gateway-status"; export const AGENT_STATUS_LABEL: Record = { idle: "Idle", diff --git a/src/features/agents/operations/agentFleetHydration.ts b/src/features/agents/operations/agentFleetHydration.ts index 2d9272f..3248607 100644 --- a/src/features/agents/operations/agentFleetHydration.ts +++ b/src/features/agents/operations/agentFleetHydration.ts @@ -13,6 +13,20 @@ type GatewayClientLike = { call: (method: string, params: unknown) => Promise; }; +const callGateway = async ( + client: GatewayClientLike, + method: string, + params: unknown +): Promise => { + const invoke = ( + client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise } + ).call; + if (typeof invoke !== "function") { + throw new Error("Gateway call transport is unavailable."); + } + return (await invoke(method, params)) as T; +}; + type AgentsListResult = { defaultId: string; mainKey: string; @@ -75,10 +89,11 @@ export async function hydrateAgentFleetFromGateway(params: { let configSnapshot = params.cachedConfigSnapshot; if (!configSnapshot) { try { - configSnapshot = (await params.client.call( + configSnapshot = await callGateway( + params.client, "config.get", {} - )) as GatewayModelPolicySnapshot; + ); } catch (err) { if (!params.isDisconnectLikeError(err)) { logError("Failed to load gateway config while loading agents.", err); @@ -98,17 +113,18 @@ export async function hydrateAgentFleetFromGateway(params: { let execApprovalsSnapshot: ExecApprovalsSnapshot | null = null; try { - execApprovalsSnapshot = (await params.client.call( + execApprovalsSnapshot = await callGateway( + params.client, "exec.approvals.get", {} - )) as ExecApprovalsSnapshot; + ); } catch (err) { if (!params.isDisconnectLikeError(err)) { logError("Failed to load exec approvals while loading agents.", err); } } - const agentsResult = (await params.client.call("agents.list", {})) as AgentsListResult; + const agentsResult = await callGateway(params.client, "agents.list", {}); const mainKey = agentsResult.mainKey?.trim() || "main"; const mainSessionKeyByAgent = new Map(); @@ -116,13 +132,13 @@ export async function hydrateAgentFleetFromGateway(params: { agentsResult.agents.map(async (agent) => { try { const expectedMainKey = buildAgentMainSessionKey(agent.id, mainKey); - const sessions = (await params.client.call("sessions.list", { + const sessions = await callGateway(params.client, "sessions.list", { agentId: agent.id, includeGlobal: false, includeUnknown: false, search: expectedMainKey, limit: 4, - })) as SessionsListResult; + }); const entries = Array.isArray(sessions.sessions) ? sessions.sessions : []; const mainEntry = entries.find((entry) => isSameSessionKey(entry.key ?? "", expectedMainKey)) ?? null; @@ -149,12 +165,12 @@ export async function hydrateAgentFleetFromGateway(params: { ).slice(0, 64); if (sessionKeys.length > 0) { const snapshot = await Promise.all([ - params.client.call("status", {}) as Promise, - params.client.call("sessions.preview", { + callGateway(params.client, "status", {}), + callGateway(params.client, "sessions.preview", { keys: sessionKeys, limit: 8, maxChars: 240, - }) as Promise, + }), ]); statusSummary = snapshot[0] ?? null; previewResult = snapshot[1] ?? null; diff --git a/src/features/agents/operations/agentPermissionsOperation.ts b/src/features/agents/operations/agentPermissionsOperation.ts index 005a365..cd52679 100644 --- a/src/features/agents/operations/agentPermissionsOperation.ts +++ b/src/features/agents/operations/agentPermissionsOperation.ts @@ -1,5 +1,5 @@ import type { GatewayClient } from "@/lib/gateway/GatewayClient"; -import { syncGatewaySessionSettings } from "@/lib/gateway/GatewayClient"; +import { syncGatewaySessionSettings } from "@/lib/gateway/session-settings-sync"; import { readConfigAgentList, updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig"; import { createRuntimeWriteTransport, @@ -7,7 +7,7 @@ import { } from "@/features/agents/operations/runtimeWriteTransport"; type ExecutionRoleId = "conservative" | "collaborative" | "autonomous"; -export type CommandModeId = "off" | "ask" | "auto"; +type CommandModeId = "off" | "ask" | "auto"; export type AgentPermissionsDraft = { commandMode: CommandModeId; diff --git a/src/features/agents/operations/agentReconcileOperation.ts b/src/features/agents/operations/agentReconcileOperation.ts index 7376cc7..4f18801 100644 --- a/src/features/agents/operations/agentReconcileOperation.ts +++ b/src/features/agents/operations/agentReconcileOperation.ts @@ -9,6 +9,20 @@ type GatewayClientLike = { call: (method: string, params: unknown) => Promise; }; +const callGateway = async ( + client: GatewayClientLike, + method: string, + params: unknown +): Promise => { + const invoke = ( + client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise } + ).call; + if (typeof invoke !== "function") { + throw new Error("Gateway call transport is unavailable."); + } + return (await invoke(method, params)) as T; +}; + type ReconcileCommand = | { kind: "clearRunTracking"; runId: string } | { kind: "dispatchUpdateAgent"; agentId: string; patch: Partial } @@ -81,10 +95,10 @@ export const runAgentReconcileOperation = async (params: { if (!params.claimRunId(runId)) continue; try { - const result = (await params.client.call("agent.wait", { + const result = await callGateway<{ status?: unknown }>(params.client, "agent.wait", { runId, timeoutMs: 1, - })) as { status?: unknown }; + }); const outcome = resolveReconcileWaitOutcome(result?.status); if (!outcome) { continue; diff --git a/src/features/agents/operations/chatInteractionWorkflow.ts b/src/features/agents/operations/chatInteractionWorkflow.ts index 1a400fd..6384bf1 100644 --- a/src/features/agents/operations/chatInteractionWorkflow.ts +++ b/src/features/agents/operations/chatInteractionWorkflow.ts @@ -1,4 +1,4 @@ -import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; +import type { GatewayStatus } from "@/lib/gateway/gateway-status"; type StopRunIntent = | { kind: "deny"; reason: "not-connected" | "missing-session-key"; message: string } diff --git a/src/features/agents/operations/cronCreateOperation.ts b/src/features/agents/operations/cronCreateOperation.ts index fcf4659..ce35cbe 100644 --- a/src/features/agents/operations/cronCreateOperation.ts +++ b/src/features/agents/operations/cronCreateOperation.ts @@ -34,10 +34,14 @@ type CronBusyState = { type CronCreateDeps = { buildInput?: (agentId: string, draft: CronCreateDraft) => CronJobCreateInput; createCronJob?: (client: GatewayClient, input: CronJobCreateInput) => Promise; + createCronJobForInput?: (input: CronJobCreateInput) => Promise; listCronJobs?: ( client: GatewayClient, params: { includeDisabled?: boolean } ) => Promise<{ jobs: CronJobSummary[] }>; + listCronJobsWithoutClient?: (params: { + includeDisabled?: boolean; + }) => Promise<{ jobs: CronJobSummary[] }>; }; const isCronActionBusy = (busy: CronBusyState) => @@ -70,14 +74,22 @@ export const performCronCreateFlow = async (params: { const buildInput = params.deps?.buildInput ?? buildCronJobCreateInput; const createCronJob = params.deps?.createCronJob ?? createCronJobDefault; const listCronJobs = params.deps?.listCronJobs ?? listCronJobsDefault; + const createCronJobForInput = params.deps?.createCronJobForInput ?? null; + const listCronJobsWithoutClient = params.deps?.listCronJobsWithoutClient ?? null; params.onBusyChange(true); params.onError(null); try { const input = buildInput(resolvedAgentId, params.draft); - await createCronJob(params.client, input); - const listResult = await listCronJobs(params.client, { includeDisabled: true }); + if (createCronJobForInput) { + await createCronJobForInput(input); + } else { + await createCronJob(params.client, input); + } + const listResult = listCronJobsWithoutClient + ? await listCronJobsWithoutClient({ includeDisabled: true }) + : await listCronJobs(params.client, { includeDisabled: true }); const jobs = sortCronJobsByUpdatedAt(filterCronJobsForAgent(listResult.jobs, resolvedAgentId)); params.onJobs(jobs); return "created"; diff --git a/src/features/agents/operations/deleteAgentOperation.ts b/src/features/agents/operations/deleteAgentOperation.ts index 8eb6724..2c30d3f 100644 --- a/src/features/agents/operations/deleteAgentOperation.ts +++ b/src/features/agents/operations/deleteAgentOperation.ts @@ -137,7 +137,7 @@ export const deleteAgentViaStudio = async (params: { { trashAgentState: async (agentId) => { const { result } = await fetchJson<{ result: TrashAgentStateResult }>( - "/api/gateway/agent-state", + "/api/runtime/agent-state", { method: "POST", headers: { "content-type": "application/json" }, @@ -148,7 +148,7 @@ export const deleteAgentViaStudio = async (params: { }, restoreAgentState: async (agentId, trashDir) => { const { result } = await fetchJson<{ result: RestoreAgentStateResult }>( - "/api/gateway/agent-state", + "/api/runtime/agent-state", { method: "PUT", headers: { "content-type": "application/json" }, diff --git a/src/features/agents/operations/mutationLifecycleWorkflow.ts b/src/features/agents/operations/mutationLifecycleWorkflow.ts index dabe271..a68f1de 100644 --- a/src/features/agents/operations/mutationLifecycleWorkflow.ts +++ b/src/features/agents/operations/mutationLifecycleWorkflow.ts @@ -2,9 +2,9 @@ import type { GatewayStatus } from "@/features/agents/operations/gatewayRestartP import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types"; import type { ConfigMutationKind } from "@/features/agents/operations/useConfigMutationQueue"; -export type MutationKind = "create-agent" | "rename-agent" | "delete-agent"; +type MutationKind = "create-agent" | "rename-agent" | "delete-agent"; -export type MutationBlockPhase = "queued" | "mutating" | "awaiting-restart"; +type MutationBlockPhase = "queued" | "mutating" | "awaiting-restart"; export type MutationBlockState = { kind: MutationKind; @@ -189,7 +189,7 @@ export type CreateAgentBlockState = { startedAt: number; }; -export type CreateAgentLifecycleCompletion = { +type CreateAgentLifecycleCompletion = { agentId: string; agentName: string; }; diff --git a/src/features/agents/operations/runtimeWriteTransport.ts b/src/features/agents/operations/runtimeWriteTransport.ts index 80b8f7f..94591ed 100644 --- a/src/features/agents/operations/runtimeWriteTransport.ts +++ b/src/features/agents/operations/runtimeWriteTransport.ts @@ -1,6 +1,6 @@ import { postStudioIntent } from "@/lib/controlplane/intents-client"; import type { GatewayClient } from "@/lib/gateway/GatewayClient"; -import { syncGatewaySessionSettings } from "@/lib/gateway/GatewayClient"; +import { syncGatewaySessionSettings } from "@/lib/gateway/session-settings-sync"; import { createGatewayAgent, deleteGatewayAgent, renameGatewayAgent } from "@/lib/gateway/agentConfig"; import { readGatewayAgentExecApprovals, @@ -26,7 +26,7 @@ export type RuntimeWriteTransport = { execAsk?: "off" | "on-miss" | "always" | null; }) => Promise; agentCreate: (params: { name: string }) => Promise<{ id: string; name: string }>; - chatAbort: (params: { sessionKey: string }) => Promise; + chatAbort: (params: { sessionKey: string; runId?: string }) => Promise; sessionsReset: (params: { key: string }) => Promise; agentRename: (params: { agentId: string; name: string }) => Promise; agentDelete: (params: { agentId: string }) => Promise; @@ -53,6 +53,20 @@ const requireNonEmpty = (value: string, fieldLabel: string): string => { return trimmed; }; +const callLegacyGateway = async ( + client: GatewayClient, + method: string, + params: unknown +): Promise => { + const invoke = ( + client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise } + ).call; + if (typeof invoke !== "function") { + throw new Error("Legacy gateway client call transport is unavailable."); + } + return (await invoke(method, params)) as T; +}; + const unwrapIntentPayload = (result: unknown): T => { if (isRecord(result) && "payload" in result) { return result.payload as T; @@ -98,7 +112,7 @@ export function createRuntimeWriteTransport(params: { const result = await postIntent("/api/intents/chat-send", payload); return unwrapIntentPayload(result); } - return await params.client.call("chat.send", payload); + return await callLegacyGateway(params.client, "chat.send", payload); }, sessionSettingsSync: async ({ sessionKey, @@ -170,13 +184,17 @@ export function createRuntimeWriteTransport(params: { : normalizedName; return { id: created.id, name: createdName }; }, - chatAbort: async ({ sessionKey }) => { + chatAbort: async ({ sessionKey, runId }) => { const normalizedSessionKey = requireNonEmpty(sessionKey, "Session key"); + const normalizedRunId = typeof runId === "string" ? runId.trim() : ""; + const payload = normalizedRunId + ? { sessionKey: normalizedSessionKey, runId: normalizedRunId } + : { sessionKey: normalizedSessionKey }; if (params.useDomainIntents) { - await postIntent("/api/intents/chat-abort", { sessionKey: normalizedSessionKey }); + await postIntent("/api/intents/chat-abort", payload); return; } - await params.client.call("chat.abort", { sessionKey: normalizedSessionKey }); + await callLegacyGateway(params.client, "chat.abort", payload); }, sessionsReset: async ({ key }) => { const normalizedSessionKey = requireNonEmpty(key, "Session key"); @@ -184,7 +202,7 @@ export function createRuntimeWriteTransport(params: { await postIntent("/api/intents/sessions-reset", { key: normalizedSessionKey }); return; } - await params.client.call("sessions.reset", { key: normalizedSessionKey }); + await callLegacyGateway(params.client, "sessions.reset", { key: normalizedSessionKey }); }, agentRename: async ({ agentId, name }) => { const normalizedAgentId = requireNonEmpty(agentId, "Agent id"); @@ -216,7 +234,10 @@ export function createRuntimeWriteTransport(params: { await postIntent("/api/intents/exec-approval-resolve", { id: normalizedId, decision }); return; } - await params.client.call("exec.approval.resolve", { id: normalizedId, decision }); + await callLegacyGateway(params.client, "exec.approval.resolve", { + id: normalizedId, + decision, + }); }, execApprovalsSet: async ({ agentId, role }) => { const normalizedAgentId = requireNonEmpty(agentId, "Agent id"); @@ -264,7 +285,7 @@ export function createRuntimeWriteTransport(params: { }); return; } - await params.client.call("agent.wait", { + await callLegacyGateway(params.client, "agent.wait", { runId: normalizedRunId, ...(typeof timeoutMs === "number" ? { timeoutMs } : {}), }); diff --git a/src/features/agents/operations/useAgentSettingsMutationController.ts b/src/features/agents/operations/useAgentSettingsMutationController.ts index 79c5d0a..8c90f79 100644 --- a/src/features/agents/operations/useAgentSettingsMutationController.ts +++ b/src/features/agents/operations/useAgentSettingsMutationController.ts @@ -28,8 +28,9 @@ import { sortCronJobsByUpdatedAt, type CronJobSummary, } from "@/lib/cron/types"; -import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient"; -import { isGatewayDisconnectLikeError } from "@/lib/gateway/GatewayClient"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import type { GatewayStatus } from "@/lib/gateway/gateway-status"; +import { isGatewayDisconnectLikeError } from "@/lib/gateway/gateway-disconnect"; import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models"; import { readGatewayAgentSkillsAllowlist, @@ -45,6 +46,16 @@ import { type SkillStatusEntry, type SkillStatusReport, } from "@/lib/skills/types"; +import { + createDomainCronJob, + installDomainSkill, + listDomainCronJobs, + loadDomainSkillStatus, + removeDomainCronJob, + setDomainAgentSkillsAllowlist, + runDomainCronJobNow, + updateDomainSkill, +} from "@/lib/controlplane/domain-runtime-client"; type RestartingMutationBlockState = MutationBlockState & { kind: MutationWorkflowKind }; type SkillSetupMessage = { kind: "success" | "error"; message: string }; @@ -79,6 +90,37 @@ type UseAgentSettingsMutationControllerParams = { useDomainIntents: boolean; }; +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +const readAgentSkillsAllowlistFromSnapshot = ( + snapshot: GatewayModelPolicySnapshot | null, + agentId: string +): string[] | undefined => { + const normalizedAgentId = agentId.trim(); + if (!normalizedAgentId) return undefined; + const configRaw = snapshot?.config; + const config = isRecord(configRaw) ? configRaw : null; + const agentsRaw = config && isRecord(config.agents) ? config.agents : null; + const list = Array.isArray(agentsRaw?.list) ? agentsRaw.list : []; + const entry = list.find((candidate) => { + if (!isRecord(candidate)) return false; + return candidate.id === normalizedAgentId; + }); + if (!entry || !isRecord(entry)) { + return undefined; + } + const rawSkills = (entry as Record).skills; + if (!Array.isArray(rawSkills)) { + return undefined; + } + const values = rawSkills + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + return Array.from(new Set(values)); +}; + export function useAgentSettingsMutationController(params: UseAgentSettingsMutationControllerParams) { const skillsLoadRequestIdRef = useRef(0); const [settingsSkillsReport, setSettingsSkillsReport] = useState(null); @@ -109,7 +151,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat const mutationContext: AgentSettingsMutationContext = useMemo( () => ({ - status: params.useDomainIntents ? "connected" : params.status, + status: params.status, hasCreateBlock: params.hasCreateBlock, hasRenameBlock: hasRenameMutationBlock, hasDeleteBlock: hasDeleteMutationBlock, @@ -125,7 +167,6 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat hasRenameMutationBlock, params.hasCreateBlock, params.status, - params.useDomainIntents, ] ); @@ -160,7 +201,9 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat setSettingsSkillsLoading(true); setSettingsSkillsError(null); try { - const report = await loadAgentSkillStatus(params.client, resolvedAgentId); + const report = params.useDomainIntents + ? await loadDomainSkillStatus(resolvedAgentId) + : await loadAgentSkillStatus(params.client, resolvedAgentId); if (requestId !== skillsLoadRequestIdRef.current) { return; } @@ -181,7 +224,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat } } }, - [params.client] + [params.client, params.useDomainIntents] ); useEffect(() => { @@ -229,7 +272,9 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat setSettingsCronLoading(true); setSettingsCronError(null); try { - const result = await listCronJobs(params.client, { includeDisabled: true }); + const result = params.useDomainIntents + ? await listDomainCronJobs({ includeDisabled: true }) + : await listCronJobs(params.client, { includeDisabled: true }); const filtered = filterCronJobsForAgent(result.jobs, resolvedAgentId); setSettingsCronJobs(sortCronJobsByUpdatedAt(filtered)); } catch (err) { @@ -243,7 +288,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat setSettingsCronLoading(false); } }, - [params.client] + [params.client, params.useDomainIntents] ); useEffect(() => { @@ -498,6 +543,15 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat onBusyChange: setCronCreateBusy, onError: setSettingsCronError, onJobs: setSettingsCronJobs, + deps: params.useDomainIntents + ? { + createCronJobForInput: async (input) => { + await createDomainCronJob(input); + }, + listCronJobsWithoutClient: async ({ includeDisabled }) => + await listDomainCronJobs({ includeDisabled }), + } + : undefined, }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create automation."; @@ -507,7 +561,14 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat throw err; } }, - [cronCreateBusy, cronDeleteBusyJobId, cronRunBusyJobId, mutationContext, params.client] + [ + cronCreateBusy, + cronDeleteBusyJobId, + cronRunBusyJobId, + mutationContext, + params.client, + params.useDomainIntents, + ] ); const handleRunCronJob = useCallback( @@ -528,7 +589,11 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat setCronRunBusyJobId(resolvedJobId); setSettingsCronError(null); try { - await runCronJobNow(params.client, resolvedJobId); + if (params.useDomainIntents) { + await runDomainCronJobNow(resolvedJobId); + } else { + await runCronJobNow(params.client, resolvedJobId); + } await loadCronJobsForSettingsAgent(resolvedAgentId); } catch (err) { const message = err instanceof Error ? err.message : "Failed to run schedule."; @@ -538,7 +603,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat setCronRunBusyJobId((current) => (current === resolvedJobId ? null : current)); } }, - [loadCronJobsForSettingsAgent, mutationContext, params.client] + [loadCronJobsForSettingsAgent, mutationContext, params.client, params.useDomainIntents] ); const handleDeleteCronJob = useCallback( @@ -559,7 +624,9 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat setCronDeleteBusyJobId(resolvedJobId); setSettingsCronError(null); try { - const result = await removeCronJob(params.client, resolvedJobId); + const result = params.useDomainIntents + ? await removeDomainCronJob(resolvedJobId) + : await removeCronJob(params.client, resolvedJobId); if (result.ok && result.removed) { setSettingsCronJobs((jobs) => jobs.filter((job) => job.id !== resolvedJobId)); } @@ -572,7 +639,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat setCronDeleteBusyJobId((current) => (current === resolvedJobId ? null : current)); } }, - [loadCronJobsForSettingsAgent, mutationContext, params.client] + [loadCronJobsForSettingsAgent, mutationContext, params.client, params.useDomainIntents] ); const handleRenameAgent = useCallback( @@ -727,15 +794,22 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat agentId, decisionKind: "use-all-skills", run: async (normalizedAgentId) => { - await updateGatewayAgentSkillsAllowlist({ - client: params.client, - agentId: normalizedAgentId, - mode: "all", - }); + if (params.useDomainIntents) { + await setDomainAgentSkillsAllowlist({ + agentId: normalizedAgentId, + mode: "all", + }); + } else { + await updateGatewayAgentSkillsAllowlist({ + client: params.client, + agentId: normalizedAgentId, + mode: "all", + }); + } }, }); }, - [params.client, runSkillsMutation] + [params.client, params.useDomainIntents, runSkillsMutation] ); const handleDisableAllSkills = useCallback( @@ -744,15 +818,22 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat agentId, decisionKind: "disable-all-skills", run: async (normalizedAgentId) => { - await updateGatewayAgentSkillsAllowlist({ - client: params.client, - agentId: normalizedAgentId, - mode: "none", - }); + if (params.useDomainIntents) { + await setDomainAgentSkillsAllowlist({ + agentId: normalizedAgentId, + mode: "none", + }); + } else { + await updateGatewayAgentSkillsAllowlist({ + client: params.client, + agentId: normalizedAgentId, + mode: "none", + }); + } }, }); }, - [params.client, runSkillsMutation] + [params.client, params.useDomainIntents, runSkillsMutation] ); const handleSetSkillEnabled = useCallback( @@ -773,10 +854,15 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat if (visibleSkillNames.length === 0) { throw new Error("Cannot update skill access: no skills available for this agent."); } - const existingAllowlist = await readGatewayAgentSkillsAllowlist({ - client: params.client, - agentId: normalizedAgentId, - }); + const existingAllowlist = params.useDomainIntents + ? readAgentSkillsAllowlistFromSnapshot( + params.gatewayConfigSnapshot, + normalizedAgentId + ) + : await readGatewayAgentSkillsAllowlist({ + client: params.client, + agentId: normalizedAgentId, + }); const baseline = existingAllowlist ?? visibleSkillNames; const next = new Set( baseline.map((value) => value.trim()).filter((value) => value.length > 0) @@ -786,16 +872,25 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat } else { next.delete(resolvedSkillName); } - await updateGatewayAgentSkillsAllowlist({ - client: params.client, - agentId: normalizedAgentId, - mode: "allowlist", - skillNames: [...next], - }); + const nextAllowlist = [...next]; + if (params.useDomainIntents) { + await setDomainAgentSkillsAllowlist({ + agentId: normalizedAgentId, + mode: "allowlist", + skillNames: nextAllowlist, + }); + } else { + await updateGatewayAgentSkillsAllowlist({ + client: params.client, + agentId: normalizedAgentId, + mode: "allowlist", + skillNames: nextAllowlist, + }); + } }, }); }, - [params.client, runSkillsMutation, settingsSkillsReport] + [params.client, params.gatewayConfigSnapshot, params.useDomainIntents, runSkillsMutation, settingsSkillsReport] ); const handleSetSkillsAllowlist = useCallback( @@ -814,16 +909,24 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat if (normalizedSkillNames.length === 0) { throw new Error("Cannot set selected skills mode: choose at least one skill."); } - await updateGatewayAgentSkillsAllowlist({ - client: params.client, - agentId: normalizedAgentId, - mode: "allowlist", - skillNames: normalizedSkillNames, - }); + if (params.useDomainIntents) { + await setDomainAgentSkillsAllowlist({ + agentId: normalizedAgentId, + mode: "allowlist", + skillNames: normalizedSkillNames, + }); + } else { + await updateGatewayAgentSkillsAllowlist({ + client: params.client, + agentId: normalizedAgentId, + mode: "allowlist", + skillNames: normalizedSkillNames, + }); + } }, }); }, - [params.client, runSkillsMutation] + [params.client, params.useDomainIntents, runSkillsMutation] ); const handleSkillApiKeyDraftChange = useCallback((skillKey: string, value: string) => { @@ -910,18 +1013,24 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat skillKey, label: `Install dependencies for ${name.trim() || skillKey.trim()}`, run: async () => { - const result = await installSkill(params.client, { - name, - installId, - timeoutMs: SKILL_INSTALL_TIMEOUT_MS, - }); + const result = params.useDomainIntents + ? await installDomainSkill({ + name, + installId, + timeoutMs: SKILL_INSTALL_TIMEOUT_MS, + }) + : await installSkill(params.client, { + name, + installId, + timeoutMs: SKILL_INSTALL_TIMEOUT_MS, + }); return { successMessage: result.message || "Installed", }; }, }); }, - [SKILL_INSTALL_TIMEOUT_MS, params.client, runSkillSetupMutation] + [SKILL_INSTALL_TIMEOUT_MS, params.client, params.useDomainIntents, runSkillSetupMutation] ); const handleRemoveSkill = useCallback( @@ -1000,17 +1109,30 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat label: `Save API key for ${normalizedSkillKey}`, refreshConfigSnapshot: true, run: async () => { - await updateSkill(params.client, { - skillKey: normalizedSkillKey, - apiKey, - }); + if (params.useDomainIntents) { + await updateDomainSkill({ + skillKey: normalizedSkillKey, + apiKey, + }); + } else { + await updateSkill(params.client, { + skillKey: normalizedSkillKey, + apiKey, + }); + } return { successMessage: "API key saved", }; }, }); }, - [params.client, runSkillSetupMutation, setSkillMessage, settingsSkillApiKeyDrafts] + [ + params.client, + params.useDomainIntents, + runSkillSetupMutation, + setSkillMessage, + settingsSkillApiKeyDrafts, + ] ); const handleSetSkillGlobalEnabled = useCallback( @@ -1023,17 +1145,24 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat label: `${enabled ? "Enable" : "Disable"} ${normalizedSkillKey}`, refreshConfigSnapshot: true, run: async () => { - await updateSkill(params.client, { - skillKey: normalizedSkillKey, - enabled, - }); + if (params.useDomainIntents) { + await updateDomainSkill({ + skillKey: normalizedSkillKey, + enabled, + }); + } else { + await updateSkill(params.client, { + skillKey: normalizedSkillKey, + enabled, + }); + } return { successMessage: enabled ? "Skill enabled globally" : "Skill disabled globally", }; }, }); }, - [params.client, runSkillSetupMutation] + [params.client, params.useDomainIntents, runSkillSetupMutation] ); return { diff --git a/src/features/agents/operations/useChatInteractionController.ts b/src/features/agents/operations/useChatInteractionController.ts index ec28c45..83440be 100644 --- a/src/features/agents/operations/useChatInteractionController.ts +++ b/src/features/agents/operations/useChatInteractionController.ts @@ -10,7 +10,7 @@ import { import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation"; import { mergePendingLivePatch } from "@/features/agents/state/livePatchQueue"; import { buildNewSessionAgentPatch, type AgentState } from "@/features/agents/state/store"; -import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; +import type { GatewayStatus } from "@/lib/gateway/gateway-status"; import type { RuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport"; type ChatInteractionDispatchAction = @@ -48,7 +48,7 @@ type ChatInteractionController = { handleSend: (agentId: string, sessionKey: string, message: string) => Promise; removeQueuedMessage: (agentId: string, index: number) => void; handleNewSession: (agentId: string) => Promise; - handleStopRun: (agentId: string, sessionKey: string) => Promise; + handleStopRun: (agentId: string, sessionKey: string, runId?: string | null) => Promise; queueLivePatch: (agentId: string, patch: Partial) => void; clearPendingLivePatch: (agentId: string) => void; }; @@ -295,7 +295,7 @@ export function useChatInteractionController( }, [params.agents, params.status, sendNextQueuedMessage]); const handleStopRun = useCallback( - async (agentId: string, sessionKey: string) => { + async (agentId: string, sessionKey: string, runId?: string | null) => { const stopIntent = planStopRunIntent({ status: params.status, agentId, @@ -313,8 +313,10 @@ export function useChatInteractionController( setStopBusyAgentId(agentId); stopBusyAgentIdRef.current = agentId; try { + const normalizedRunId = typeof runId === "string" ? runId.trim() : ""; await params.runtimeWriteTransport.chatAbort({ sessionKey: stopIntent.sessionKey, + ...(normalizedRunId ? { runId: normalizedRunId } : {}), }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to stop run."; diff --git a/src/features/agents/operations/useGatewayConfigSyncController.ts b/src/features/agents/operations/useGatewayConfigSyncController.ts index e258d3b..3060b37 100644 --- a/src/features/agents/operations/useGatewayConfigSyncController.ts +++ b/src/features/agents/operations/useGatewayConfigSyncController.ts @@ -13,6 +13,7 @@ import { type GatewayModelPolicySnapshot, } from "@/lib/gateway/models"; import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import { loadDomainConfigSnapshot, loadDomainModels } from "@/lib/controlplane/domain-runtime-client"; const defaultLogError = (message: string, err: unknown) => { console.error(message, err); @@ -21,6 +22,7 @@ const defaultLogError = (message: string, err: unknown) => { type UseGatewayConfigSyncControllerParams = { client: GatewayClient; status: GatewayConnectionStatus; + useDomainApiReads: boolean; settingsRouteActive: boolean; inspectSidebarAgentId: string | null; gatewayConfigSnapshot: GatewayModelPolicySnapshot | null; @@ -48,6 +50,7 @@ export function useGatewayConfigSyncController( const { client, status, + useDomainApiReads, settingsRouteActive, inspectSidebarAgentId, gatewayConfigSnapshot, @@ -65,7 +68,9 @@ export function useGatewayConfigSyncController( const refreshGatewayConfigSnapshot = useCallback(async () => { if (status !== "connected") return null; try { - const snapshot = await client.call("config.get", {}); + const snapshot = useDomainApiReads + ? await loadDomainConfigSnapshot() + : await client.call("config.get", {}); setGatewayConfigSnapshot(snapshot); return snapshot; } catch (err) { @@ -74,9 +79,12 @@ export function useGatewayConfigSyncController( } return null; } - }, [client, isDisconnectLikeError, logError, setGatewayConfigSnapshot, status]); + }, [client, isDisconnectLikeError, logError, setGatewayConfigSnapshot, status, useDomainApiReads]); useEffect(() => { + if (useDomainApiReads) { + return; + } const repairIntent = resolveSandboxRepairIntent({ status, attempted: sandboxRepairAttemptedRef.current, @@ -107,7 +115,7 @@ export function useGatewayConfigSyncController( await loadAgents(); }, }); - }, [client, enqueueConfigMutation, gatewayConfigSnapshot, loadAgents, status]); + }, [client, enqueueConfigMutation, gatewayConfigSnapshot, loadAgents, status, useDomainApiReads]); useEffect(() => { if ( @@ -135,7 +143,9 @@ export function useGatewayConfigSyncController( const loadModels = async () => { let configSnapshot: GatewayModelPolicySnapshot | null = null; try { - configSnapshot = await client.call("config.get", {}); + configSnapshot = useDomainApiReads + ? await loadDomainConfigSnapshot() + : await client.call("config.get", {}); if (!cancelled) { setGatewayConfigSnapshot(configSnapshot); } @@ -146,12 +156,12 @@ export function useGatewayConfigSyncController( } try { - const result = await client.call<{ models: GatewayModelChoice[] }>( - "models.list", - {} - ); + const catalog = useDomainApiReads + ? await loadDomainModels() + : ( + await client.call<{ models: GatewayModelChoice[] }>("models.list", {}) + ).models ?? []; if (cancelled) return; - const catalog = Array.isArray(result.models) ? result.models : []; setGatewayModels(buildGatewayModelChoices(catalog, configSnapshot)); setGatewayModelsError(null); } catch (err) { @@ -177,6 +187,7 @@ export function useGatewayConfigSyncController( setGatewayModels, setGatewayModelsError, status, + useDomainApiReads, ]); return { diff --git a/src/features/agents/state/gatewayEventIngressWorkflow.ts b/src/features/agents/state/gatewayEventIngressWorkflow.ts index 265b89b..e1a8995 100644 --- a/src/features/agents/state/gatewayEventIngressWorkflow.ts +++ b/src/features/agents/state/gatewayEventIngressWorkflow.ts @@ -1,6 +1,6 @@ import { resolveExecApprovalEventEffects, type ExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow"; import type { AgentState } from "@/features/agents/state/store"; -import type { EventFrame } from "@/lib/gateway/GatewayClient"; +import type { EventFrame } from "@/lib/gateway/gateway-frames"; import { parseAgentIdFromSessionKey } from "@/lib/gateway/session-keys"; export type CronTranscriptIntent = { diff --git a/src/features/agents/state/gatewayRuntimeEventHandler.ts b/src/features/agents/state/gatewayRuntimeEventHandler.ts index 0ada055..c42d00d 100644 --- a/src/features/agents/state/gatewayRuntimeEventHandler.ts +++ b/src/features/agents/state/gatewayRuntimeEventHandler.ts @@ -22,7 +22,7 @@ import { type RuntimeCoordinatorDispatchAction, type RuntimeCoordinatorEffectCommand, } from "@/features/agents/state/runtimeEventCoordinatorWorkflow"; -import type { EventFrame } from "@/lib/gateway/GatewayClient"; +import type { EventFrame } from "@/lib/gateway/gateway-frames"; import { isSameSessionKey } from "@/lib/gateway/session-keys"; import { normalizeAssistantDisplayText } from "@/lib/text/assistantText"; import { @@ -277,8 +277,15 @@ export function createGatewayRuntimeEventHandler( const activeRunId = agent?.runId?.trim() ?? ""; const role = resolveRole(payload.message); const nowMs = now(); + const allowAbortedRunMismatchRecovery = + payload.state === "aborted" && agent?.status === "running"; - if (payload.runId && activeRunId && activeRunId !== payload.runId) { + if ( + payload.runId && + activeRunId && + activeRunId !== payload.runId && + !allowAbortedRunMismatchRecovery + ) { clearRunTracking(payload.runId); return; } diff --git a/src/features/agents/state/runtimeChatEventWorkflow.ts b/src/features/agents/state/runtimeChatEventWorkflow.ts index 151acdc..97d594e 100644 --- a/src/features/agents/state/runtimeChatEventWorkflow.ts +++ b/src/features/agents/state/runtimeChatEventWorkflow.ts @@ -227,7 +227,10 @@ export const planRuntimeChatEvent = ( isStaleTerminal: chatTerminalDecision?.isStaleTerminal ?? false, shouldRequestHistoryRefresh, shouldUpdateLastResult, - shouldSetRunIdle: Boolean(payload.runId && agent?.runId === payload.runId && payload.state !== "error"), + shouldSetRunIdle: + payload.state === "aborted" + ? agent?.status === "running" + : Boolean(payload.runId && agent?.runId === payload.runId && payload.state !== "error"), shouldSetRunError: Boolean(payload.runId && agent?.runId === payload.runId && payload.state === "error"), lastResultText: shouldUpdateLastResult ? finalAssistantText : null, assistantCompletionAt: payload.state === "final" ? assistantCompletionAt : null, diff --git a/src/features/agents/state/runtimeEventBridge.ts b/src/features/agents/state/runtimeEventBridge.ts index 30fd4c1..f50f540 100644 --- a/src/features/agents/state/runtimeEventBridge.ts +++ b/src/features/agents/state/runtimeEventBridge.ts @@ -87,7 +87,7 @@ export type SummarySnapshotAgent = { status?: AgentState["status"]; }; -export type SummarySessionStatusEntry = { +type SummarySessionStatusEntry = { key: string; updatedAt: number | null; }; @@ -105,7 +105,7 @@ type SummaryPreviewItem = { timestamp?: number | string; }; -export type SummaryPreviewEntry = { +type SummaryPreviewEntry = { key: string; status: "ok" | "empty" | "missing" | "error"; items: SummaryPreviewItem[]; diff --git a/src/features/agents/state/runtimeEventPolicy.ts b/src/features/agents/state/runtimeEventPolicy.ts index 3da5242..e8ae1f8 100644 --- a/src/features/agents/state/runtimeEventPolicy.ts +++ b/src/features/agents/state/runtimeEventPolicy.ts @@ -114,7 +114,9 @@ export const decideRuntimeChatEvent = ( return intents; } - if (runId && activeRunId && activeRunId !== runId) { + const allowAbortedRunMismatchRecovery = + input.state === "aborted" && input.agentStatus === "running"; + if (runId && activeRunId && activeRunId !== runId && !allowAbortedRunMismatchRecovery) { return [{ kind: "clearRunTracking", runId }]; } if (runId && input.isStaleTerminal) { diff --git a/src/features/agents/state/runtimeTerminalWorkflow.ts b/src/features/agents/state/runtimeTerminalWorkflow.ts index f64e8f5..2c42817 100644 --- a/src/features/agents/state/runtimeTerminalWorkflow.ts +++ b/src/features/agents/state/runtimeTerminalWorkflow.ts @@ -1,6 +1,6 @@ -export type RuntimeTerminalCommitSource = "chat-final" | "lifecycle-fallback"; +type RuntimeTerminalCommitSource = "chat-final" | "lifecycle-fallback"; -export type RuntimeTerminalRunState = { +type RuntimeTerminalRunState = { chatFinalSeen: boolean; terminalCommitted: boolean; lastTerminalSeq: number | null; diff --git a/src/features/agents/state/sessionSettingsMutations.ts b/src/features/agents/state/sessionSettingsMutations.ts index 21466f4..f52cd49 100644 --- a/src/features/agents/state/sessionSettingsMutations.ts +++ b/src/features/agents/state/sessionSettingsMutations.ts @@ -1,9 +1,11 @@ import { - isWebchatSessionMutationBlockedError, - syncGatewaySessionSettings, type GatewayClient, - type GatewaySessionsPatchResult, } from "@/lib/gateway/GatewayClient"; +import { isWebchatSessionMutationBlockedError } from "@/lib/gateway/gateway-disconnect"; +import { + syncGatewaySessionSettings, + type GatewaySessionsPatchResult, +} from "@/lib/gateway/session-settings-sync"; import type { RuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport"; type SessionSettingField = "model" | "thinkingLevel"; diff --git a/src/lib/controlplane/domain-runtime-client.ts b/src/lib/controlplane/domain-runtime-client.ts new file mode 100644 index 0000000..ff41e0c --- /dev/null +++ b/src/lib/controlplane/domain-runtime-client.ts @@ -0,0 +1,219 @@ +import type { AgentFileName } from "@/lib/agents/agentFiles"; +import type { GatewayModelChoice, GatewayModelPolicySnapshot } from "@/lib/gateway/models"; +import { fetchJson } from "@/lib/http"; +import type { + CronJobCreateInput, + CronJobSummary, + CronRunResult, +} from "@/lib/cron/types"; +import type { SkillStatusReport } from "@/lib/skills/types"; + +type Envelope = { + ok?: boolean; + payload?: T; + error?: string; +}; + +type SessionsListEntry = { + key?: string; + updatedAt?: number | null; + origin?: { label?: string | null } | null; +}; + +type SessionsListResult = { + sessions?: SessionsListEntry[]; +}; + +type ChatHistoryResult = { + messages?: Record[]; +}; + +const unwrapPayload = (result: Envelope): T => { + if (result && result.ok === true && "payload" in result) { + return result.payload as T; + } + throw new Error(result?.error ?? "Request failed."); +}; + +export const loadDomainConfigSnapshot = async (): Promise => { + const result = await fetchJson>("/api/runtime/config", { + cache: "no-store", + }); + return unwrapPayload(result); +}; + +export const loadDomainModels = async (): Promise => { + const result = await fetchJson>("/api/runtime/models", { + cache: "no-store", + }); + const payload = unwrapPayload(result); + return Array.isArray(payload.models) ? payload.models : []; +}; + +export const loadDomainSkillStatus = async (agentId: string): Promise => { + const encodedAgentId = encodeURIComponent(agentId.trim()); + const result = await fetchJson>( + `/api/runtime/skills/status?agentId=${encodedAgentId}`, + { cache: "no-store" } + ); + return unwrapPayload(result); +}; + +export const installDomainSkill = async (params: { + name: string; + installId: string; + timeoutMs?: number; +}): Promise<{ ok: boolean; message: string; stdout: string; stderr: string; code: number | null; warnings?: string[] }> => { + const result = await fetchJson>( + "/api/intents/skills-install", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + } + ); + return unwrapPayload(result); +}; + +export const updateDomainSkill = async (params: { + skillKey: string; + enabled?: boolean; + apiKey?: string; +}): Promise<{ ok: boolean; skillKey: string; config: Record }> => { + const result = await fetchJson }>>( + "/api/intents/skills-update", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + } + ); + return unwrapPayload(result); +}; + +export const setDomainAgentSkillsAllowlist = async (params: { + agentId: string; + mode: "all" | "none" | "allowlist"; + skillNames?: string[]; +}): Promise => { + const result = await fetchJson>("/api/intents/agent-skills-allowlist", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + unwrapPayload(result); +}; + +export const listDomainCronJobs = async (params: { + includeDisabled?: boolean; +} = {}): Promise<{ jobs: CronJobSummary[] }> => { + const includeDisabled = params.includeDisabled ?? true; + const result = await fetchJson>( + `/api/runtime/cron?includeDisabled=${includeDisabled ? "true" : "false"}`, + { cache: "no-store" } + ); + const payload = unwrapPayload(result); + return { jobs: Array.isArray(payload.jobs) ? payload.jobs : [] }; +}; + +export const createDomainCronJob = async (input: CronJobCreateInput): Promise => { + const result = await fetchJson>("/api/intents/cron-add", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + return unwrapPayload(result); +}; + +export const runDomainCronJobNow = async (jobId: string): Promise => { + const result = await fetchJson>("/api/intents/cron-run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: jobId.trim() }), + }); + return unwrapPayload(result); +}; + +export const removeDomainCronJob = async (jobId: string): Promise<{ ok: true; removed: boolean } | { ok: false; removed: false }> => { + const result = await fetchJson>( + "/api/intents/cron-remove", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: jobId.trim() }), + } + ); + return unwrapPayload(result); +}; + +export const readDomainAgentFile = async (params: { + agentId: string; + name: AgentFileName; +}): Promise<{ exists: boolean; content: string }> => { + const query = new URLSearchParams({ + agentId: params.agentId.trim(), + name: params.name, + }); + const result = await fetchJson>( + `/api/runtime/agent-file?${query.toString()}`, + { cache: "no-store" } + ); + const payload = unwrapPayload(result); + const file = payload?.file; + const record = file && typeof file === "object" ? (file as Record) : null; + const missing = record?.missing === true; + const content = typeof record?.content === "string" ? record.content : ""; + return { exists: !missing, content }; +}; + +export const writeDomainAgentFile = async (params: { + agentId: string; + name: AgentFileName; + content: string; +}): Promise => { + const result = await fetchJson>("/api/intents/agent-file-set", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + unwrapPayload(result); +}; + +export const listDomainSessions = async (params: { + agentId: string; + includeGlobal?: boolean; + includeUnknown?: boolean; + search?: string; + limit?: number; +}): Promise => { + const query = new URLSearchParams(); + query.set("agentId", params.agentId.trim()); + query.set("includeGlobal", params.includeGlobal === true ? "true" : "false"); + query.set("includeUnknown", params.includeUnknown === true ? "true" : "false"); + if (params.search?.trim()) { + query.set("search", params.search.trim()); + } + if (typeof params.limit === "number" && Number.isFinite(params.limit) && params.limit > 0) { + query.set("limit", String(Math.floor(params.limit))); + } + + const result = await fetchJson>(`/api/runtime/sessions?${query.toString()}`, { + cache: "no-store", + }); + return unwrapPayload(result); +}; + +export const loadDomainChatHistory = async (params: { + sessionKey: string; + limit?: number; +}): Promise => { + const query = new URLSearchParams({ sessionKey: params.sessionKey.trim() }); + if (typeof params.limit === "number" && Number.isFinite(params.limit) && params.limit > 0) { + query.set("limit", String(Math.floor(params.limit))); + } + const result = await fetchJson>( + `/api/runtime/chat-history?${query.toString()}`, + { cache: "no-store" } + ); + return unwrapPayload(result); +}; diff --git a/src/lib/controlplane/openclaw-adapter.ts b/src/lib/controlplane/openclaw-adapter.ts index 190986a..639d097 100644 --- a/src/lib/controlplane/openclaw-adapter.ts +++ b/src/lib/controlplane/openclaw-adapter.ts @@ -18,24 +18,33 @@ const MAX_RECONNECT_DELAY_MS = 15_000; const CONNECT_PROTOCOL = 3; const CONNECT_CLIENT_ID = "openclaw-control-ui"; const CONNECT_CLIENT_MODE = "webchat"; +const CONNECT_CAPABILITIES = ["tool-events"]; const DEFAULT_METHOD_ALLOWLIST = new Set([ "status", "chat.send", "chat.abort", + "chat.history", "agents.create", "agents.update", "agents.delete", "agents.list", + "agents.files.get", + "agents.files.set", "sessions.list", "sessions.preview", "sessions.patch", "sessions.reset", "cron.list", + "cron.run", "cron.remove", "cron.add", "config.get", "config.set", + "models.list", + "skills.status", + "skills.install", + "skills.update", "exec.approval.resolve", "exec.approvals.get", "exec.approvals.set", @@ -330,7 +339,7 @@ export class OpenClawGatewayAdapter { "operator.approvals", "operator.pairing", ], - caps: [], + caps: CONNECT_CAPABILITIES, auth: { token }, }, }) diff --git a/src/lib/controlplane/projection-store.ts b/src/lib/controlplane/projection-store.ts index 3107a03..a054947 100644 --- a/src/lib/controlplane/projection-store.ts +++ b/src/lib/controlplane/projection-store.ts @@ -1,7 +1,8 @@ import fs from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; -import Database from "better-sqlite3"; +import type BetterSqlite3 from "better-sqlite3"; import type { ControlPlaneDomainEvent, @@ -11,6 +12,8 @@ import type { import { deriveControlPlaneEventKey } from "@/lib/controlplane/outbox"; import { resolveStateDir } from "@/lib/clawdbot/paths"; +const require = createRequire(import.meta.url); + const RUNTIME_DB_DIRNAME = "openclaw-studio"; const RUNTIME_DB_FILENAME = "runtime.db"; @@ -41,6 +44,14 @@ type OutboxColumnInfo = { name: string; }; +type BetterSqlite3Factory = typeof BetterSqlite3; +type BetterSqlite3Database = BetterSqlite3.Database; +type BetterSqlite3Statement = + BetterSqlite3.Statement; + +const loadBetterSqlite3 = (): BetterSqlite3Factory => + require("better-sqlite3") as BetterSqlite3Factory; + const parseDomainEvent = (raw: string): ControlPlaneDomainEvent => { return JSON.parse(raw) as ControlPlaneDomainEvent; }; @@ -92,23 +103,26 @@ export type BackfillAgentOutboxResult = { }; 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, string]>; - private readonly updateProcessedOutboxStmt: Database.Statement<[number, string]>; - private readonly updateOutboxAgentIdIfNullStmt: Database.Statement<[string, number]>; - private readonly upsertStatusProjectionStmt: Database.Statement< + private readonly db: BetterSqlite3Database; + private readonly readProjectionStmt: BetterSqlite3Statement<[], ProjectionRow | undefined>; + private readonly readOutboxHeadStmt: BetterSqlite3Statement<[], { head: number }>; + private readonly readOutboxAfterStmt: BetterSqlite3Statement<[number, number], OutboxRow>; + private readonly readOutboxBeforeStmt: BetterSqlite3Statement<[number, number], OutboxRow>; + private readonly readAgentOutboxBeforeStmt: BetterSqlite3Statement<[string, number, number], OutboxRow>; + private readonly readOutboxByIdStmt: BetterSqlite3Statement<[number], OutboxRow | undefined>; + private readonly readBackfillCandidatesStmt: BetterSqlite3Statement<[number, number], LegacyBackfillRow>; + private readonly readProcessedStmt: BetterSqlite3Statement< + [string], + { outbox_id: number | null } | undefined + >; + private readonly insertProcessedStmt: BetterSqlite3Statement<[string, string]>; + private readonly insertOutboxStmt: BetterSqlite3Statement<[string, string, string, string]>; + private readonly updateProcessedOutboxStmt: BetterSqlite3Statement<[number, string]>; + private readonly updateOutboxAgentIdIfNullStmt: BetterSqlite3Statement<[string, number]>; + private readonly upsertStatusProjectionStmt: BetterSqlite3Statement< [string, string | null, string, string] >; - private readonly upsertGatewayProjectionStmt: Database.Statement<[string, string]>; + private readonly upsertGatewayProjectionStmt: BetterSqlite3Statement<[string, string]>; private readonly applyEventTx: ( event: ControlPlaneDomainEvent, eventKey: string @@ -120,7 +134,8 @@ export class SQLiteControlPlaneProjectionStore { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - this.db = new Database(dbPath); + const BetterSqlite3 = loadBetterSqlite3(); + this.db = new BetterSqlite3(dbPath); this.db.pragma("journal_mode = WAL"); this.db.pragma("foreign_keys = ON"); this.migrate(); diff --git a/src/lib/controlplane/runtime-read-route.ts b/src/lib/controlplane/runtime-read-route.ts new file mode 100644 index 0000000..fbe3c79 --- /dev/null +++ b/src/lib/controlplane/runtime-read-route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; + +import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter"; +import { serializeRuntimeInitFailure } from "@/lib/controlplane/runtime-init-errors"; +import { bootstrapDomainRuntime } from "@/lib/controlplane/runtime-route-bootstrap"; + +const mapGatewayError = (error: unknown): NextResponse => { + if (error instanceof ControlPlaneGatewayError) { + if (error.code.trim().toUpperCase() === "GATEWAY_UNAVAILABLE") { + return NextResponse.json( + { + error: error.message, + code: "GATEWAY_UNAVAILABLE", + reason: "gateway_unavailable", + }, + { status: 503 } + ); + } + return NextResponse.json( + { + error: error.message, + code: error.code, + details: error.details, + }, + { status: 400 } + ); + } + + const message = error instanceof Error ? error.message : "runtime_read_failed"; + return NextResponse.json({ error: message }, { status: 500 }); +}; + +export const executeRuntimeGatewayRead = async (method: string, params: unknown) => { + const bootstrap = await bootstrapDomainRuntime(); + if (bootstrap.kind === "mode-disabled") { + return NextResponse.json({ enabled: false, error: "domain_api_mode_disabled" }, { status: 404 }); + } + if (bootstrap.kind === "runtime-init-failed") { + return NextResponse.json( + { + enabled: true, + ...serializeRuntimeInitFailure(bootstrap.failure), + }, + { status: 503 } + ); + } + if (bootstrap.kind === "start-failed") { + return NextResponse.json( + { + enabled: true, + error: bootstrap.message, + code: "GATEWAY_UNAVAILABLE", + reason: "gateway_unavailable", + }, + { status: 503 } + ); + } + + try { + const payload = await bootstrap.runtime.callGateway(method, params); + return NextResponse.json({ ok: true, payload }); + } catch (error) { + return mapGatewayError(error); + } +}; diff --git a/src/lib/controlplane/runtime.ts b/src/lib/controlplane/runtime.ts index 837910b..e691006 100644 --- a/src/lib/controlplane/runtime.ts +++ b/src/lib/controlplane/runtime.ts @@ -9,20 +9,6 @@ import { type BackfillAgentOutboxResult, } from "@/lib/controlplane/projection-store"; -const DOMAIN_MODE_FALSE_VALUES = new Set(["0", "false", "no", "off"]); - -const readDomainModeRawValue = (env: NodeJS.ProcessEnv = process.env): string => { - const serverValue = env.STUDIO_DOMAIN_API_MODE?.trim().toLowerCase() ?? ""; - if (serverValue) return serverValue; - return env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE?.trim().toLowerCase() ?? ""; -}; - -const readDomainApiMode = (env: NodeJS.ProcessEnv = process.env): boolean => { - const raw = readDomainModeRawValue(env); - if (!raw) return true; - return !DOMAIN_MODE_FALSE_VALUES.has(raw); -}; - type ControlPlaneRuntimeOptions = { adapterOptions?: OpenClawAdapterOptions; dbPath?: string; @@ -49,6 +35,16 @@ export class ControlPlaneRuntime { await this.adapter.stop(); } + connectionStatus() { + return this.adapter.getStatus(); + } + + async reconnectForGatewaySettingsChange(): Promise { + if (this.adapter.getStatus() === "stopped") return; + await this.adapter.stop(); + await this.adapter.start(); + } + snapshot(): ControlPlaneRuntimeSnapshot { return this.store.snapshot(); } @@ -108,10 +104,16 @@ export const getControlPlaneRuntime = (options?: ControlPlaneRuntimeOptions): Co return globalState.__openclawStudioControlPlaneRuntime; }; +export const peekControlPlaneRuntime = (): ControlPlaneRuntime | null => { + const globalState = globalThis as GlobalControlPlaneState; + return globalState.__openclawStudioControlPlaneRuntime ?? null; +}; + export const resetControlPlaneRuntimeForTests = (): void => { const globalState = globalThis as GlobalControlPlaneState; delete globalState.__openclawStudioControlPlaneRuntime; }; -export const isStudioDomainApiModeEnabled = (env: NodeJS.ProcessEnv = process.env): boolean => - readDomainApiMode(env); +export const isStudioDomainApiModeEnabled = (): boolean => { + return true; +}; diff --git a/src/lib/cron/types.ts b/src/lib/cron/types.ts index 1e651b8..88afcea 100644 --- a/src/lib/cron/types.ts +++ b/src/lib/cron/types.ts @@ -31,7 +31,7 @@ export type CronPayload = bestEffortDeliver?: boolean; }; -export type CronJobState = { +type CronJobState = { nextRunAtMs?: number; runningAtMs?: number; lastRunAtMs?: number; diff --git a/src/lib/gateway/GatewayClient.ts b/src/lib/gateway/GatewayClient.ts index 6498099..5cd5f53 100644 --- a/src/lib/gateway/GatewayClient.ts +++ b/src/lib/gateway/GatewayClient.ts @@ -1,720 +1,12 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { - GatewayBrowserClient, - type GatewayHelloOk, -} from "./openclaw/GatewayBrowserClient"; -import type { - StudioGatewaySettings, - StudioSettings, - StudioSettingsPatch, -} from "@/lib/studio/settings"; -import type { StudioSettingsResponse } from "@/lib/studio/coordinator"; -import { resolveStudioProxyGatewayUrl } from "@/lib/gateway/proxy-url"; -import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode"; -import { GatewayResponseError } from "@/lib/gateway/errors"; -import { - buildAgentMainSessionKey, - parseAgentIdFromSessionKey, - isSameSessionKey, -} from "@/lib/gateway/session-keys"; - -type ReqFrame = { - type: "req"; - id: string; - method: string; - params: unknown; -}; - -type ResFrame = { - type: "res"; - id: string; - ok: boolean; - payload?: unknown; - error?: { - code: string; - message: string; - details?: unknown; - retryable?: boolean; - retryAfterMs?: number; - }; -}; - -export type GatewayStateVersion = { - presence: number; - health: number; -}; - -export type EventFrame = { - type: "event"; - event: string; - payload?: unknown; - seq?: number; - stateVersion?: GatewayStateVersion; -}; - -type GatewayFrame = ReqFrame | ResFrame | EventFrame; - -export const parseGatewayFrame = (raw: string): GatewayFrame | null => { - try { - return JSON.parse(raw) as GatewayFrame; - } catch { - return null; - } -}; - -export { buildAgentMainSessionKey, parseAgentIdFromSessionKey, isSameSessionKey }; - -const CONNECT_FAILED_CLOSE_CODE = 4008; - -const parseConnectFailedCloseReason = ( - reason: string -): { code: string; message: string } | null => { - const trimmed = reason.trim(); - if (!trimmed.toLowerCase().startsWith("connect failed:")) return null; - const remainder = trimmed.slice("connect failed:".length).trim(); - if (!remainder) return null; - const idx = remainder.indexOf(" "); - const code = (idx === -1 ? remainder : remainder.slice(0, idx)).trim(); - if (!code) return null; - const message = (idx === -1 ? "" : remainder.slice(idx + 1)).trim(); - return { code, message: message || "connect failed" }; -}; - -const DEFAULT_UPSTREAM_GATEWAY_URL = - process.env.NEXT_PUBLIC_GATEWAY_URL || "ws://localhost:18789"; - -const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => { - if (!value || typeof value !== "object") return null; - const raw = value as { url?: unknown; token?: unknown }; - const url = typeof raw.url === "string" ? raw.url.trim() : ""; - const token = typeof raw.token === "string" ? raw.token.trim() : ""; - if (!url) return null; - return { url, token }; -}; - -type StatusHandler = (status: GatewayStatus) => void; - -type EventHandler = (event: EventFrame) => void; - -export type GatewayGapInfo = { expected: number; received: number }; - -type GapHandler = (info: GatewayGapInfo) => void; - -export type GatewayStatus = "disconnected" | "connecting" | "connected"; - -type GatewayConnectOptions = { - gatewayUrl: string; - token?: string; - authScopeKey?: string; - clientName?: string; - disableDeviceAuth?: boolean; -}; +import type { EventFrame } from "@/lib/gateway/gateway-frames"; +import type { GatewayGapInfo } from "@/lib/gateway/gateway-status"; +export type { EventFrame } from "@/lib/gateway/gateway-frames"; +export type { GatewayGapInfo } from "@/lib/gateway/gateway-status"; export { GatewayResponseError } from "@/lib/gateway/errors"; -; -export class GatewayClient { - private client: GatewayBrowserClient | null = null; - private statusHandlers = new Set(); - private eventHandlers = new Set(); - private gapHandlers = new Set(); - private status: GatewayStatus = "disconnected"; - private pendingConnect: Promise | null = null; - private resolveConnect: (() => void) | null = null; - private rejectConnect: ((error: Error) => void) | null = null; - private manualDisconnect = false; - private lastHello: GatewayHelloOk | null = null; - - onStatus(handler: StatusHandler) { - this.statusHandlers.add(handler); - handler(this.status); - return () => { - this.statusHandlers.delete(handler); - }; - } - - onEvent(handler: EventHandler) { - this.eventHandlers.add(handler); - return () => { - this.eventHandlers.delete(handler); - }; - } - - onGap(handler: GapHandler) { - this.gapHandlers.add(handler); - return () => { - this.gapHandlers.delete(handler); - }; - } - - async connect(options: GatewayConnectOptions) { - if (!options.gatewayUrl.trim()) { - throw new Error("Gateway URL is required."); - } - if (this.client) { - throw new Error("Gateway is already connected or connecting."); - } - - this.manualDisconnect = false; - this.updateStatus("connecting"); - - this.pendingConnect = new Promise((resolve, reject) => { - this.resolveConnect = resolve; - this.rejectConnect = reject; - }); - - const nextClient = new GatewayBrowserClient({ - url: options.gatewayUrl, - token: options.token, - authScopeKey: options.authScopeKey, - clientName: options.clientName, - disableDeviceAuth: options.disableDeviceAuth, - onHello: (hello) => { - if (this.client !== nextClient) return; - this.lastHello = hello; - this.updateStatus("connected"); - this.resolveConnect?.(); - this.clearConnectPromise(); - }, - onEvent: (event) => { - if (this.client !== nextClient) return; - this.eventHandlers.forEach((handler) => handler(event)); - }, - onClose: ({ code, reason }) => { - if (this.client !== nextClient) return; - const connectFailed = - code === CONNECT_FAILED_CLOSE_CODE ? parseConnectFailedCloseReason(reason) : null; - const err = connectFailed - ? new GatewayResponseError({ - code: connectFailed.code, - message: connectFailed.message, - }) - : new Error(`Gateway closed (${code}): ${reason}`); - if (this.rejectConnect) { - this.rejectConnect(err); - this.clearConnectPromise(); - } - if (!this.manualDisconnect) { - nextClient.stop(); - } - if (this.client === nextClient) { - this.client = null; - } - this.updateStatus("disconnected"); - if (this.manualDisconnect) { - console.info("Gateway disconnected."); - } - }, - onGap: ({ expected, received }) => { - if (this.client !== nextClient) return; - this.gapHandlers.forEach((handler) => handler({ expected, received })); - }, - }); - - this.client = nextClient; - nextClient.start(); - - try { - await this.pendingConnect; - } catch (err) { - const activeClient = this.client; - activeClient?.stop(); - if (this.client === activeClient) { - this.client = null; - } - this.updateStatus("disconnected"); - throw err; - } - } - - disconnect() { - if (!this.client) { - return; - } - - this.manualDisconnect = true; - this.client.stop(); - this.client = null; - this.clearConnectPromise(); - this.updateStatus("disconnected"); - console.info("Gateway disconnected."); - } - - async call(method: string, params: unknown): Promise { - if (!method.trim()) { - throw new Error("Gateway method is required."); - } - if (!this.client || !this.client.connected) { - throw new Error("Gateway is not connected."); - } - - const payload = await this.client.request(method, params); - return payload as T; - } - - getLastHello() { - return this.lastHello; - } - - private updateStatus(status: GatewayStatus) { - this.status = status; - this.statusHandlers.forEach((handler) => handler(status)); - } - - private clearConnectPromise() { - this.pendingConnect = null; - this.resolveConnect = null; - this.rejectConnect = null; - } -} - -export const isGatewayDisconnectLikeError = (err: unknown): boolean => { - if (!(err instanceof Error)) return false; - const msg = err.message.toLowerCase(); - if (!msg) return false; - if ( - msg.includes("gateway not connected") || - msg.includes("gateway is not connected") || - msg.includes("gateway client stopped") - ) { - return true; - } - - const match = msg.match(/gateway closed \\((\\d+)\\)/); - if (!match) return false; - const code = Number(match[1]); - return Number.isFinite(code) && code === 1012; -}; - -const WEBCHAT_SESSION_MUTATION_BLOCKED_RE = /webchat clients cannot (patch|delete) sessions/i; -const WEBCHAT_SESSION_MUTATION_HINT_RE = /use chat\.send for session-scoped updates/i; - -export const isWebchatSessionMutationBlockedError = (error: unknown): boolean => { - if (!(error instanceof GatewayResponseError)) return false; - if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false; - const message = error.message.trim(); - if (!message) return false; - return ( - WEBCHAT_SESSION_MUTATION_BLOCKED_RE.test(message) && - WEBCHAT_SESSION_MUTATION_HINT_RE.test(message) - ); -}; - -type SessionSettingsPatchPayload = { - key: string; - model?: string | null; - thinkingLevel?: string | null; - execHost?: "sandbox" | "gateway" | "node" | null; - execSecurity?: "deny" | "allowlist" | "full" | null; - execAsk?: "off" | "on-miss" | "always" | null; -}; - -export type GatewaySessionsPatchResult = { - ok: true; - key: string; - entry?: { - thinkingLevel?: string; - }; - resolved?: { - modelProvider?: string; - model?: string; - }; -}; - -type SyncGatewaySessionSettingsParams = { - client: GatewayClient; - sessionKey: string; - model?: string | null; - thinkingLevel?: string | null; - execHost?: "sandbox" | "gateway" | "node" | null; - execSecurity?: "deny" | "allowlist" | "full" | null; - execAsk?: "off" | "on-miss" | "always" | null; -}; - -export const syncGatewaySessionSettings = async ({ - client, - sessionKey, - model, - thinkingLevel, - execHost, - execSecurity, - execAsk, -}: SyncGatewaySessionSettingsParams) => { - const key = sessionKey.trim(); - if (!key) { - throw new Error("Session key is required."); - } - 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."); - } - const payload: SessionSettingsPatchPayload = { key }; - if (includeModel) { - payload.model = model ?? null; - } - if (includeThinkingLevel) { - payload.thinkingLevel = thinkingLevel ?? null; - } - if (includeExecHost) { - payload.execHost = execHost ?? null; - } - if (includeExecSecurity) { - payload.execSecurity = execSecurity ?? null; - } - if (includeExecAsk) { - payload.execAsk = execAsk ?? null; - } - return await client.call("sessions.patch", payload); -}; - -const doctorFixHint = - "Run `npx openclaw doctor --fix` on the gateway host (or `pnpm openclaw doctor --fix` in a source checkout)."; - -const formatGatewayError = (error: unknown) => { - if (error instanceof GatewayResponseError) { - if (error.code === "INVALID_REQUEST" && /invalid config/i.test(error.message)) { - return `Gateway error (${error.code}): ${error.message}. ${doctorFixHint}`; - } - return `Gateway error (${error.code}): ${error.message}`; - } - if (error instanceof Error) { - return error.message; - } - return "Unknown gateway error."; -}; - -type GatewayConnectionState = { - client: GatewayClient; - status: GatewayStatus; - gatewayUrl: string; - token: string; - localGatewayDefaults: StudioGatewaySettings | null; - domainApiModeEnabled: boolean | null; - error: string | null; - connect: () => Promise; - disconnect: () => void; - useLocalGatewayDefaults: () => void; - setGatewayUrl: (value: string) => void; - setToken: (value: string) => void; - clearError: () => void; -}; - -type StudioSettingsCoordinatorLike = { - loadSettings: () => Promise; - loadSettingsEnvelope?: () => Promise; - schedulePatch: (patch: StudioSettingsPatch, debounceMs?: number) => void; - flushPending: () => Promise; -}; - -const isAuthError = (errorMessage: string | null): boolean => { - if (!errorMessage) return false; - const lower = errorMessage.toLowerCase(); - return ( - lower.includes("auth") || - lower.includes("unauthorized") || - lower.includes("forbidden") || - lower.includes("invalid token") || - lower.includes("token required") || - (lower.includes("token") && lower.includes("not configured")) || - lower.includes("gateway_token_missing") - ); -}; - -const MAX_AUTO_RETRY_ATTEMPTS = 20; -const INITIAL_RETRY_DELAY_MS = 2_000; -const MAX_RETRY_DELAY_MS = 30_000; - -const NON_RETRYABLE_CONNECT_ERROR_CODES = new Set([ - "studio.gateway_url_missing", - "studio.gateway_token_missing", - "studio.gateway_url_invalid", - "studio.settings_load_failed", -]); - -const isNonRetryableConnectErrorCode = (code: string | null): boolean => { - const normalized = code?.trim().toLowerCase() ?? ""; - if (!normalized) return false; - return NON_RETRYABLE_CONNECT_ERROR_CODES.has(normalized); -}; - -export const resolveGatewayAutoRetryDelayMs = (params: { - status: GatewayStatus; - didAutoConnect: boolean; - wasManualDisconnect: boolean; - gatewayUrl: string; - errorMessage: string | null; - connectErrorCode: string | null; - attempt: number; -}): number | null => { - if (params.status !== "disconnected") return null; - if (!params.didAutoConnect) return null; - if (params.wasManualDisconnect) return null; - if (!params.gatewayUrl.trim()) return null; - if (params.attempt >= MAX_AUTO_RETRY_ATTEMPTS) return null; - if (isNonRetryableConnectErrorCode(params.connectErrorCode)) return null; - if (params.connectErrorCode === null && isAuthError(params.errorMessage)) return null; - - return Math.min( - INITIAL_RETRY_DELAY_MS * Math.pow(1.5, params.attempt), - MAX_RETRY_DELAY_MS - ); -}; - -export const useGatewayConnection = ( - settingsCoordinator: StudioSettingsCoordinatorLike -): GatewayConnectionState => { - const [client] = useState(() => new GatewayClient()); - const didAutoConnect = useRef(false); - const loadedGatewaySettings = useRef<{ gatewayUrl: string } | null>(null); - const retryAttemptRef = useRef(0); - const retryTimerRef = useRef | null>(null); - const wasManualDisconnectRef = useRef(false); - const tokenDirtyRef = useRef(false); - - const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_UPSTREAM_GATEWAY_URL); - const [token, setTokenState] = useState(""); - const [localGatewayDefaults, setLocalGatewayDefaults] = useState( - null - ); - const [domainApiModeEnabled, setDomainApiModeEnabled] = useState(null); - const [status, setStatus] = useState("disconnected"); - const [error, setError] = useState(null); - const [connectErrorCode, setConnectErrorCode] = useState(null); - const [settingsLoaded, setSettingsLoaded] = useState(false); - - useEffect(() => { - let cancelled = false; - const loadSettings = async () => { - try { - const envelope = - typeof settingsCoordinator.loadSettingsEnvelope === "function" - ? await settingsCoordinator.loadSettingsEnvelope() - : { settings: await settingsCoordinator.loadSettings(), localGatewayDefaults: null }; - const settings = envelope.settings ?? null; - const gateway = settings?.gateway ?? null; - if (cancelled) return; - setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults)); - const envelopeDomainMode = - "domainApiModeEnabled" in envelope ? envelope.domainApiModeEnabled : undefined; - setDomainApiModeEnabled( - typeof envelopeDomainMode === "boolean" ? envelopeDomainMode : null - ); - const nextGatewayUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL; - const nextToken = typeof gateway?.token === "string" ? gateway.token : ""; - loadedGatewaySettings.current = { - gatewayUrl: nextGatewayUrl.trim(), - }; - setGatewayUrl(nextGatewayUrl); - setTokenState(nextToken); - tokenDirtyRef.current = false; - } catch (err) { - if (!cancelled) { - const message = err instanceof Error ? err.message : "Failed to load gateway settings."; - setError(message); - } - } finally { - if (!cancelled) { - if (!loadedGatewaySettings.current) { - loadedGatewaySettings.current = { - gatewayUrl: DEFAULT_UPSTREAM_GATEWAY_URL.trim(), - }; - } - setSettingsLoaded(true); - } - } - }; - void loadSettings(); - return () => { - cancelled = true; - }; - }, [settingsCoordinator]); - - useEffect(() => { - return client.onStatus((nextStatus) => { - setStatus(nextStatus); - if (nextStatus !== "connecting") { - setError(null); - if (nextStatus === "connected") { - setConnectErrorCode(null); - } - } - }); - }, [client]); - - useEffect(() => { - return () => { - if (retryTimerRef.current) { - clearTimeout(retryTimerRef.current); - retryTimerRef.current = null; - } - client.disconnect(); - }; - }, [client]); - - const connect = useCallback(async () => { - setError(null); - setConnectErrorCode(null); - wasManualDisconnectRef.current = false; - try { - await settingsCoordinator.flushPending(); - await client.connect({ - gatewayUrl: resolveStudioProxyGatewayUrl(), - token, - authScopeKey: gatewayUrl, - clientName: "openclaw-control-ui", - disableDeviceAuth: true, - }); - await ensureGatewayReloadModeHotForLocalStudio({ - client, - upstreamGatewayUrl: gatewayUrl, - }); - retryAttemptRef.current = 0; - } catch (err) { - setConnectErrorCode(err instanceof GatewayResponseError ? err.code : null); - setError(formatGatewayError(err)); - } - }, [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, 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, - didAutoConnect: didAutoConnect.current, - wasManualDisconnect: wasManualDisconnectRef.current, - gatewayUrl, - errorMessage: error, - connectErrorCode, - attempt, - }); - if (delay === null) return; - retryTimerRef.current = setTimeout(() => { - retryAttemptRef.current = attempt + 1; - void connect(); - }, delay); - - return () => { - if (retryTimerRef.current) { - clearTimeout(retryTimerRef.current); - retryTimerRef.current = null; - } - }; - }, [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(() => { - if (status === "connected") { - retryAttemptRef.current = 0; - } - }, [status]); - - useEffect(() => { - if (!settingsLoaded) return; - const baseline = loadedGatewaySettings.current; - if (!baseline) return; - const nextGatewayUrl = gatewayUrl.trim(); - const shouldPersistToken = tokenDirtyRef.current; - if (!shouldPersistToken && nextGatewayUrl === baseline.gatewayUrl) { - return; - } - const gatewayPatch: { url: string; token?: string } = { url: nextGatewayUrl }; - if (shouldPersistToken) { - gatewayPatch.token = token; - tokenDirtyRef.current = false; - } - settingsCoordinator.schedulePatch( - { - gateway: gatewayPatch, - }, - 400 - ); - loadedGatewaySettings.current = { gatewayUrl: nextGatewayUrl }; - }, [gatewayUrl, settingsCoordinator, settingsLoaded, token]); - - const useLocalGatewayDefaults = useCallback(() => { - if (!localGatewayDefaults) { - return; - } - setGatewayUrl(localGatewayDefaults.url); - setTokenState(localGatewayDefaults.token); - tokenDirtyRef.current = false; - setError(null); - setConnectErrorCode(null); - }, [localGatewayDefaults]); - - const setToken = useCallback((value: string) => { - tokenDirtyRef.current = true; - setTokenState(value); - }, []); - - const disconnect = useCallback(() => { - setError(null); - setConnectErrorCode(null); - wasManualDisconnectRef.current = true; - client.disconnect(); - }, [client]); - - const clearError = useCallback(() => { - setError(null); - setConnectErrorCode(null); - }, []); - - return { - client, - status, - gatewayUrl, - token, - localGatewayDefaults, - domainApiModeEnabled, - error, - connect, - disconnect, - useLocalGatewayDefaults, - setGatewayUrl, - setToken, - clearError, - }; +export type GatewayClient = { + call: (method: string, params: unknown) => Promise; + onEvent?: (handler: (event: EventFrame) => void) => () => void; + onGap?: (handler: (info: GatewayGapInfo) => void) => () => void; }; diff --git a/src/lib/gateway/agentConfig.ts b/src/lib/gateway/agentConfig.ts index eaba566..5ec0df7 100644 --- a/src/lib/gateway/agentConfig.ts +++ b/src/lib/gateway/agentConfig.ts @@ -53,6 +53,20 @@ const DEFAULT_ACK_MAX_CHARS = 300; const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === "object" && !Array.isArray(value)); +const callGateway = async ( + client: GatewayClient, + method: string, + params: unknown +): Promise => { + const invoke = ( + client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise } + ).call; + if (typeof invoke !== "function") { + throw new Error("Legacy gateway client call transport is unavailable."); + } + return (await invoke(method, params)) as T; +}; + export type ConfigAgentEntry = Record & { id: string }; type GatewayAgentSandboxOverrides = { @@ -272,8 +286,8 @@ export const listHeartbeatsForAgent = async ( ): Promise => { const resolvedAgentId = resolveHeartbeatAgentId(agentId); const [snapshot, status] = await Promise.all([ - client.call("config.get", {}), - client.call("status", {}), + callGateway(client, "config.get", {}), + callGateway(client, "status", {}), ]); const config = isRecord(snapshot.config) ? snapshot.config : {}; const resolved = resolveHeartbeatSettings(config, resolvedAgentId); @@ -302,7 +316,7 @@ export const triggerHeartbeatNow = async ( agentId: string ): Promise => { const resolvedAgentId = resolveHeartbeatAgentId(agentId); - return client.call("wake", { + return callGateway(client, "wake", { mode: "now", text: `OpenClaw Studio heartbeat trigger (${resolvedAgentId}).`, }); @@ -331,10 +345,10 @@ const applyGatewayConfigPatch = async (params: { }; if (baseHash) payload.baseHash = baseHash; try { - await params.client.call("config.patch", payload); + await callGateway(params.client, "config.patch", payload); } catch (err) { if (attempt < 1 && shouldRetryConfigWrite(err)) { - const snapshot = await params.client.call("config.get", {}); + const snapshot = await callGateway(params.client, "config.get", {}); return applyGatewayConfigPatch({ ...params, baseHash: snapshot.hash ?? undefined, @@ -355,7 +369,10 @@ export const renameGatewayAgent = async (params: { if (!trimmed) { throw new Error("Agent name is required."); } - await params.client.call("agents.update", { agentId: params.agentId, name: trimmed }); + await callGateway(params.client, "agents.update", { + agentId: params.agentId, + name: trimmed, + }); return { id: params.agentId, name: trimmed }; }; @@ -382,7 +399,7 @@ export const createGatewayAgent = async (params: { throw new Error("Agent name is required."); } - const snapshot = await params.client.call("config.get", {}); + const snapshot = await callGateway(params.client, "config.get", {}); const configPath = typeof snapshot.path === "string" ? snapshot.path.trim() : ""; if (!configPath) { throw new Error( @@ -398,10 +415,14 @@ export const createGatewayAgent = async (params: { const idGuess = slugifyAgentName(trimmed); const workspace = joinPathLike(stateDir, `workspace-${idGuess}`); - const result = (await params.client.call("agents.create", { + const result = await callGateway<{ ok?: boolean; agentId?: string; name?: string; workspace?: string }>( + params.client, + "agents.create", + { name: trimmed, workspace, - })) as { ok?: boolean; agentId?: string; name?: string; workspace?: string }; + } + ); const agentId = typeof result?.agentId === "string" ? result.agentId.trim() : ""; if (!agentId) { throw new Error("Gateway returned an invalid agents.create response (missing agentId)."); @@ -414,9 +435,9 @@ export const deleteGatewayAgent = async (params: { agentId: string; }) => { try { - const result = (await params.client.call("agents.delete", { + const result = await callGateway<{ ok?: boolean; removedBindings?: unknown }>(params.client, "agents.delete", { agentId: params.agentId, - })) as { ok?: boolean; removedBindings?: unknown }; + }); const removedBindings = typeof result?.removedBindings === "number" && Number.isFinite(result.removedBindings) ? Math.max(0, Math.floor(result.removedBindings)) @@ -435,7 +456,7 @@ export const updateGatewayHeartbeat = async (params: { agentId: string; payload: AgentHeartbeatUpdatePayload; }): Promise => { - const snapshot = await params.client.call("config.get", {}); + const snapshot = await callGateway(params.client, "config.get", {}); const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; const list = readConfigAgentList(baseConfig); const { list: nextList } = upsertConfigAgentEntry(list, params.agentId, (entry) => { @@ -461,7 +482,7 @@ export const removeGatewayHeartbeatOverride = async (params: { client: GatewayClient; agentId: string; }): Promise => { - const snapshot = await params.client.call("config.get", {}); + const snapshot = await callGateway(params.client, "config.get", {}); const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; const list = readConfigAgentList(baseConfig); const nextList = list.map((entry) => { @@ -580,7 +601,7 @@ export const readGatewayAgentSkillsAllowlist = async (params: { agentId: string; }): Promise => { const agentId = resolveRequiredAgentId(params.agentId); - const snapshot = await params.client.call("config.get", {}); + const snapshot = await callGateway(params.client, "config.get", {}); const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; const list = readConfigAgentList(baseConfig); const entry = list.find((item) => item.id === agentId); @@ -606,7 +627,7 @@ export const updateGatewayAgentSkillsAllowlist = async (params: { } const attemptWrite = async (attempt: number): Promise => { - const snapshot = await params.client.call("config.get", {}); + const snapshot = await callGateway(params.client, "config.get", {}); const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; const nextConfig = buildAgentSkillsConfig({ baseConfig, @@ -629,7 +650,7 @@ export const updateGatewayAgentSkillsAllowlist = async (params: { payload.baseHash = baseHash; } try { - await params.client.call("config.set", payload); + await callGateway(params.client, "config.set", payload); } catch (err) { if (attempt < 1 && shouldRetryConfigWrite(err)) { return attemptWrite(attempt + 1); @@ -735,7 +756,7 @@ export const updateGatewayAgentOverrides = async (params: { }; const attemptWrite = async (attempt: number): Promise => { - const snapshot = await params.client.call("config.get", {}); + const snapshot = await callGateway(params.client, "config.get", {}); const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; const nextConfig = buildNextConfig(baseConfig); const payload: Record = { @@ -748,7 +769,7 @@ export const updateGatewayAgentOverrides = async (params: { } if (baseHash) payload.baseHash = baseHash; try { - await params.client.call("config.set", payload); + await callGateway(params.client, "config.set", payload); } catch (err) { if (attempt < 1 && shouldRetryConfigWrite(err)) { return attemptWrite(attempt + 1); diff --git a/src/lib/gateway/agentFiles.ts b/src/lib/gateway/agentFiles.ts index c2de710..7174c3e 100644 --- a/src/lib/gateway/agentFiles.ts +++ b/src/lib/gateway/agentFiles.ts @@ -5,6 +5,20 @@ type AgentsFilesGetResponse = { file?: { missing?: unknown; content?: unknown }; }; +const callGateway = async ( + client: GatewayClient, + method: string, + params: unknown +): Promise => { + const invoke = ( + client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise } + ).call; + if (typeof invoke !== "function") { + throw new Error("Legacy gateway client call transport is unavailable."); + } + return (await invoke(method, params)) as T; +}; + const resolveAgentId = (value: string) => { const trimmed = value.trim(); if (!trimmed) { @@ -19,7 +33,7 @@ export const readGatewayAgentFile = async (params: { name: AgentFileName; }): Promise<{ exists: boolean; content: string }> => { const agentId = resolveAgentId(params.agentId); - const response = await params.client.call("agents.files.get", { + const response = await callGateway(params.client, "agents.files.get", { agentId, name: params.name, }); @@ -31,20 +45,6 @@ export const readGatewayAgentFile = async (params: { return { exists: !missing, content }; }; -export const writeGatewayAgentFile = async (params: { - client: GatewayClient; - agentId: string; - name: AgentFileName; - content: string; -}): Promise => { - const agentId = resolveAgentId(params.agentId); - await params.client.call("agents.files.set", { - agentId, - name: params.name, - content: params.content, - }); -}; - export const writeGatewayAgentFiles = async (params: { client: GatewayClient; agentId: string; @@ -55,7 +55,7 @@ export const writeGatewayAgentFiles = async (params: { (entry): entry is [AgentFileName, string] => typeof entry[1] === "string" ); for (const [name, content] of entries) { - await params.client.call("agents.files.set", { + await callGateway(params.client, "agents.files.set", { agentId, name, content, diff --git a/src/lib/gateway/execApprovals.ts b/src/lib/gateway/execApprovals.ts index aadb147..1485eff 100644 --- a/src/lib/gateway/execApprovals.ts +++ b/src/lib/gateway/execApprovals.ts @@ -41,6 +41,20 @@ type ExecApprovalsSnapshot = { file?: ExecApprovalsFile; }; +const callGateway = async ( + client: GatewayClient, + method: string, + params: unknown +): Promise => { + const invoke = ( + client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise } + ).call; + if (typeof invoke !== "function") { + throw new Error("Legacy gateway client call transport is unavailable."); + } + return (await invoke(method, params)) as T; +}; + const shouldRetrySet = (err: unknown): boolean => { if (!(err instanceof GatewayResponseError)) return false; return /re-run exec\.approvals\.get|changed since last load/i.test(err.message); @@ -69,10 +83,14 @@ const setExecApprovalsWithRetry = async (params: { const payload: Record = { file: params.file }; if (baseHash) payload.baseHash = baseHash; try { - await params.client.call("exec.approvals.set", payload); + await callGateway(params.client, "exec.approvals.set", payload); } catch (err) { if (attempt < 1 && shouldRetrySet(err)) { - const snapshot = await params.client.call("exec.approvals.get", {}); + const snapshot = await callGateway( + params.client, + "exec.approvals.get", + {} + ); return setExecApprovalsWithRetry({ ...params, baseHash: snapshot.hash ?? undefined, @@ -98,7 +116,11 @@ export async function upsertGatewayAgentExecApprovals(params: { throw new Error("Agent id is required."); } - const snapshot = await params.client.call("exec.approvals.get", {}); + const snapshot = await callGateway( + params.client, + "exec.approvals.get", + {} + ); const baseFile: ExecApprovalsFile = snapshot.file && typeof snapshot.file === "object" ? { @@ -152,7 +174,11 @@ export async function readGatewayAgentExecApprovals(params: { throw new Error("Agent id is required."); } - const snapshot = await params.client.call("exec.approvals.get", {}); + const snapshot = await callGateway( + params.client, + "exec.approvals.get", + {} + ); const entry = snapshot.file?.agents?.[agentId]; if (!entry) return null; diff --git a/src/lib/gateway/gateway-disconnect.ts b/src/lib/gateway/gateway-disconnect.ts new file mode 100644 index 0000000..0bd5358 --- /dev/null +++ b/src/lib/gateway/gateway-disconnect.ts @@ -0,0 +1,33 @@ +import { GatewayResponseError } from "@/lib/gateway/errors"; + +export const isGatewayDisconnectLikeError = (err: unknown): boolean => { + if (!(err instanceof Error)) return false; + const msg = err.message.toLowerCase(); + if (!msg) return false; + if ( + msg.includes("gateway not connected") || + msg.includes("gateway is not connected") || + msg.includes("gateway client stopped") + ) { + return true; + } + + const match = msg.match(/gateway closed \\((\\d+)\\)/); + if (!match) return false; + const code = Number(match[1]); + return Number.isFinite(code) && code === 1012; +}; + +const WEBCHAT_SESSION_MUTATION_BLOCKED_RE = /webchat clients cannot (patch|delete) sessions/i; +const WEBCHAT_SESSION_MUTATION_HINT_RE = /use chat\.send for session-scoped updates/i; + +export const isWebchatSessionMutationBlockedError = (error: unknown): boolean => { + if (!(error instanceof GatewayResponseError)) return false; + if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false; + const message = error.message.trim(); + if (!message) return false; + return ( + WEBCHAT_SESSION_MUTATION_BLOCKED_RE.test(message) && + WEBCHAT_SESSION_MUTATION_HINT_RE.test(message) + ); +}; diff --git a/src/lib/gateway/gateway-frames.ts b/src/lib/gateway/gateway-frames.ts new file mode 100644 index 0000000..6889e85 --- /dev/null +++ b/src/lib/gateway/gateway-frames.ts @@ -0,0 +1,43 @@ +type GatewayStateVersion = { + presence: number; + health: number; +}; + +type ReqFrame = { + type: "req"; + id: string; + method: string; + params: unknown; +}; + +type ResFrame = { + type: "res"; + id: string; + ok: boolean; + payload?: unknown; + error?: { + code: string; + message: string; + details?: unknown; + retryable?: boolean; + retryAfterMs?: number; + }; +}; + +export type EventFrame = { + type: "event"; + event: string; + payload?: unknown; + seq?: number; + stateVersion?: GatewayStateVersion; +}; + +type GatewayFrame = ReqFrame | ResFrame | EventFrame; + +export const parseGatewayFrame = (raw: string): GatewayFrame | null => { + try { + return JSON.parse(raw) as GatewayFrame; + } catch { + return null; + } +}; diff --git a/src/lib/gateway/gateway-status.ts b/src/lib/gateway/gateway-status.ts new file mode 100644 index 0000000..10fcbed --- /dev/null +++ b/src/lib/gateway/gateway-status.ts @@ -0,0 +1,6 @@ +export type GatewayStatus = "disconnected" | "connecting" | "connected"; + +export type GatewayGapInfo = { + expected: number; + received: number; +}; diff --git a/src/lib/gateway/gatewayReloadMode.ts b/src/lib/gateway/gatewayReloadMode.ts index 3666447..2cc6bac 100644 --- a/src/lib/gateway/gatewayReloadMode.ts +++ b/src/lib/gateway/gatewayReloadMode.ts @@ -10,6 +10,20 @@ type GatewayConfigSnapshot = { const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === "object" && !Array.isArray(value)); +const callGateway = async ( + client: GatewayClient, + method: string, + params: unknown +): Promise => { + const invoke = ( + client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise } + ).call; + if (typeof invoke !== "function") { + throw new Error("Legacy gateway client call transport is unavailable."); + } + return (await invoke(method, params)) as T; +}; + const shouldRetryConfigWrite = (err: unknown) => { if (!(err instanceof GatewayResponseError)) return false; return /re-run config\.get|config changed since last load/i.test(err.message); @@ -37,7 +51,7 @@ export async function shouldAwaitDisconnectRestartForRemoteMutation(params: { return shouldAwaitDisconnectRestartForReloadMode(cachedMode); } try { - const snapshot = await params.client.call("config.get", {}); + const snapshot = await callGateway(params.client, "config.get", {}); const mode = resolveReloadModeFromConfig(snapshot.config); return shouldAwaitDisconnectRestartForReloadMode(mode); } catch (err) { @@ -58,7 +72,7 @@ export async function ensureGatewayReloadModeHotForLocalStudio(params: { } const attemptWrite = async (attempt: number): Promise => { - const snapshot = await params.client.call("config.get", {}); + const snapshot = await callGateway(params.client, "config.get", {}); const exists = snapshot.exists !== false; const baseHash = exists ? snapshot.hash?.trim() : undefined; if (exists && !baseHash) { @@ -93,7 +107,7 @@ export async function ensureGatewayReloadModeHotForLocalStudio(params: { } try { - await params.client.call("config.set", payload); + await callGateway(params.client, "config.set", payload); } catch (err) { if (attempt < 1 && shouldRetryConfigWrite(err)) { await attemptWrite(attempt + 1); diff --git a/src/lib/gateway/openclaw/GatewayBrowserClient.ts b/src/lib/gateway/openclaw/GatewayBrowserClient.ts deleted file mode 100644 index 234b6a3..0000000 --- a/src/lib/gateway/openclaw/GatewayBrowserClient.ts +++ /dev/null @@ -1,643 +0,0 @@ -import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519"; -import { GatewayResponseError } from "@/lib/gateway/errors"; - -const GATEWAY_CLIENT_NAMES = { - CONTROL_UI: "openclaw-control-ui", -} as const; - -const GATEWAY_CLIENT_MODES = { - WEBCHAT: "webchat", -} as const; - -type CryptoLike = { - randomUUID?: (() => string) | undefined; - getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined; -}; - -let warnedWeakCrypto = false; - -function uuidFromBytes(bytes: Uint8Array): string { - bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 - - let hex = ""; - for (let i = 0; i < bytes.length; i++) { - hex += bytes[i]!.toString(16).padStart(2, "0"); - } - - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice( - 16, - 20 - )}-${hex.slice(20)}`; -} - -function weakRandomBytes(): Uint8Array { - const bytes = new Uint8Array(16); - const now = Date.now(); - for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); - bytes[0] ^= now & 0xff; - bytes[1] ^= (now >>> 8) & 0xff; - bytes[2] ^= (now >>> 16) & 0xff; - bytes[3] ^= (now >>> 24) & 0xff; - return bytes; -} - -function warnWeakCryptoOnce() { - if (warnedWeakCrypto) return; - warnedWeakCrypto = true; - console.warn("[uuid] crypto API missing; falling back to weak randomness"); -} - -function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string { - if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID(); - - if (cryptoLike && typeof cryptoLike.getRandomValues === "function") { - const bytes = new Uint8Array(16); - cryptoLike.getRandomValues(bytes); - return uuidFromBytes(bytes); - } - - warnWeakCryptoOnce(); - return uuidFromBytes(weakRandomBytes()); -} - -type DeviceAuthPayloadParams = { - deviceId: string; - clientId: string; - clientMode: string; - role: string; - scopes: string[]; - signedAtMs: number; - token?: string | null; - nonce?: string | null; - version?: "v1" | "v2"; -}; - -function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string { - const version = params.version ?? (params.nonce ? "v2" : "v1"); - const scopes = params.scopes.join(","); - const token = params.token ?? ""; - const base = [ - version, - params.deviceId, - params.clientId, - params.clientMode, - params.role, - scopes, - String(params.signedAtMs), - token, - ]; - if (version === "v2") { - base.push(params.nonce ?? ""); - } - return base.join("|"); -} - -type DeviceAuthEntry = { - token: string; - role: string; - scopes: string[]; - updatedAtMs: number; -}; - -type DeviceAuthStore = { - version: 1; - deviceId: string; - tokens: Record; -}; - -const DEVICE_AUTH_STORAGE_KEY = "openclaw.device.auth.v1"; - -function normalizeAuthScope(scope: string | undefined): string { - const trimmed = scope?.trim(); - if (!trimmed) return "default"; - return trimmed.toLowerCase(); -} - -function buildScopedTokenKey(scope: string, role: string): string { - return `${scope}::${role}`; -} - -function normalizeRole(role: string): string { - return role.trim(); -} - -function normalizeScopes(scopes: string[] | undefined): string[] { - if (!Array.isArray(scopes)) return []; - const out = new Set(); - for (const scope of scopes) { - const trimmed = scope.trim(); - if (trimmed) out.add(trimmed); - } - return [...out].sort(); -} - -function readDeviceAuthStore(): DeviceAuthStore | null { - try { - const raw = window.localStorage.getItem(DEVICE_AUTH_STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as DeviceAuthStore; - if (!parsed || parsed.version !== 1) return null; - if (!parsed.deviceId || typeof parsed.deviceId !== "string") return null; - if (!parsed.tokens || typeof parsed.tokens !== "object") return null; - return parsed; - } catch { - return null; - } -} - -function writeDeviceAuthStore(store: DeviceAuthStore) { - try { - window.localStorage.setItem(DEVICE_AUTH_STORAGE_KEY, JSON.stringify(store)); - } catch { - // best-effort - } -} - -function loadDeviceAuthToken(params: { deviceId: string; role: string; scope: string }): DeviceAuthEntry | null { - const store = readDeviceAuthStore(); - if (!store || store.deviceId !== params.deviceId) return null; - const role = normalizeRole(params.role); - const scope = normalizeAuthScope(params.scope); - const key = buildScopedTokenKey(scope, role); - const entry = store.tokens[key]; - if (!entry || typeof entry.token !== "string") return null; - return entry; -} - -function storeDeviceAuthToken(params: { - deviceId: string; - role: string; - scope: string; - token: string; - scopes?: string[]; -}): DeviceAuthEntry { - const role = normalizeRole(params.role); - const scope = normalizeAuthScope(params.scope); - const key = buildScopedTokenKey(scope, role); - const next: DeviceAuthStore = { - version: 1, - deviceId: params.deviceId, - tokens: {}, - }; - const existing = readDeviceAuthStore(); - if (existing && existing.deviceId === params.deviceId) { - next.tokens = { ...existing.tokens }; - } - const entry: DeviceAuthEntry = { - token: params.token, - role, - scopes: normalizeScopes(params.scopes), - updatedAtMs: Date.now(), - }; - next.tokens[key] = entry; - writeDeviceAuthStore(next); - return entry; -} - -function clearDeviceAuthToken(params: { deviceId: string; role: string; scope: string }) { - const store = readDeviceAuthStore(); - if (!store || store.deviceId !== params.deviceId) return; - const role = normalizeRole(params.role); - const scope = normalizeAuthScope(params.scope); - const key = buildScopedTokenKey(scope, role); - const hasScoped = Boolean(store.tokens[key]); - const hasLegacy = Boolean(store.tokens[role]); - if (!hasScoped && !hasLegacy) return; - const next = { ...store, tokens: { ...store.tokens } }; - delete next.tokens[key]; - delete next.tokens[role]; - writeDeviceAuthStore(next); -} - -type StoredIdentity = { - version: 1; - deviceId: string; - publicKey: string; - privateKey: string; - createdAtMs: number; -}; - -type DeviceIdentity = { - deviceId: string; - publicKey: string; - privateKey: string; -}; - -const DEVICE_IDENTITY_STORAGE_KEY = "openclaw-device-identity-v1"; - -function base64UrlEncode(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); -} - -function base64UrlDecode(input: string): Uint8Array { - const normalized = input.replaceAll("-", "+").replaceAll("_", "/"); - const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); - const binary = atob(padded); - const out = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); - return out; -} - -function bytesToHex(bytes: Uint8Array): string { - return Array.from(bytes) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -async function fingerprintPublicKey(publicKey: Uint8Array): Promise { - const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(publicKey)); - return bytesToHex(new Uint8Array(hash)); -} - -async function generateIdentity(): Promise { - const privateKey = utils.randomSecretKey(); - const publicKey = await getPublicKeyAsync(privateKey); - const deviceId = await fingerprintPublicKey(publicKey); - return { - deviceId, - publicKey: base64UrlEncode(publicKey), - privateKey: base64UrlEncode(privateKey), - }; -} - -async function loadOrCreateDeviceIdentity(): Promise { - try { - const raw = localStorage.getItem(DEVICE_IDENTITY_STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw) as StoredIdentity; - if ( - parsed?.version === 1 && - typeof parsed.deviceId === "string" && - typeof parsed.publicKey === "string" && - typeof parsed.privateKey === "string" - ) { - const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey)); - if (derivedId !== parsed.deviceId) { - const updated: StoredIdentity = { - ...parsed, - deviceId: derivedId, - }; - localStorage.setItem(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(updated)); - return { - deviceId: derivedId, - publicKey: parsed.publicKey, - privateKey: parsed.privateKey, - }; - } - return { - deviceId: parsed.deviceId, - publicKey: parsed.publicKey, - privateKey: parsed.privateKey, - }; - } - } - } catch { - // fall through to regenerate - } - - const identity = await generateIdentity(); - const stored: StoredIdentity = { - version: 1, - deviceId: identity.deviceId, - publicKey: identity.publicKey, - privateKey: identity.privateKey, - createdAtMs: Date.now(), - }; - localStorage.setItem(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(stored)); - return identity; -} - -async function signDevicePayload(privateKeyBase64Url: string, payload: string) { - const key = base64UrlDecode(privateKeyBase64Url); - const data = new TextEncoder().encode(payload); - const sig = await signAsync(data, key); - return base64UrlEncode(sig); -} - -type GatewayEventFrame = { - type: "event"; - event: string; - payload?: unknown; - seq?: number; - stateVersion?: { presence: number; health: number }; -}; - -type GatewayResponseFrame = { - type: "res"; - id: string; - ok: boolean; - payload?: unknown; - error?: { code: string; message: string; details?: unknown }; -}; - -export type GatewayHelloOk = { - type: "hello-ok"; - protocol: number; - features?: { methods?: string[]; events?: string[] }; - snapshot?: unknown; - auth?: { - deviceToken?: string; - role?: string; - scopes?: string[]; - issuedAtMs?: number; - }; - policy?: { tickIntervalMs?: number }; -}; - -type Pending = { - resolve: (value: unknown) => void; - reject: (err: unknown) => void; -}; - -type GatewayBrowserClientOptions = { - url: string; - token?: string; - password?: string; - authScopeKey?: string; - disableDeviceAuth?: boolean; - clientName?: string; - clientVersion?: string; - platform?: string; - mode?: string; - instanceId?: string; - onHello?: (hello: GatewayHelloOk) => void; - onEvent?: (evt: GatewayEventFrame) => void; - onClose?: (info: { code: number; reason: string }) => void; - onGap?: (info: { expected: number; received: number }) => void; -}; - -const CONNECT_FAILED_CLOSE_CODE = 4008; -const WS_CLOSE_REASON_MAX_BYTES = 123; - -function truncateWsCloseReason(reason: string, maxBytes = WS_CLOSE_REASON_MAX_BYTES): string { - const trimmed = reason.trim(); - if (!trimmed) return "connect failed"; - const encoder = new TextEncoder(); - if (encoder.encode(trimmed).byteLength <= maxBytes) return trimmed; - - let out = ""; - for (const char of trimmed) { - const next = out + char; - if (encoder.encode(next).byteLength > maxBytes) break; - out = next; - } - return out.trimEnd() || "connect failed"; -} - -export class GatewayBrowserClient { - private ws: WebSocket | null = null; - private pending = new Map(); - private closed = false; - private lastSeq: number | null = null; - private connectNonce: string | null = null; - private connectSent = false; - private connectTimer: number | null = null; - private backoffMs = 800; - - constructor(private opts: GatewayBrowserClientOptions) {} - - start() { - this.closed = false; - this.connect(); - } - - stop() { - this.closed = true; - this.ws?.close(); - this.ws = null; - this.flushPending(new Error("gateway client stopped")); - } - - get connected() { - return this.ws?.readyState === WebSocket.OPEN; - } - - private connect() { - if (this.closed) return; - this.ws = new WebSocket(this.opts.url); - this.ws.onopen = () => this.queueConnect(); - this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? "")); - this.ws.onclose = (ev) => { - const reason = String(ev.reason ?? ""); - this.ws = null; - this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`)); - this.opts.onClose?.({ code: ev.code, reason }); - this.scheduleReconnect(); - }; - this.ws.onerror = () => { - // ignored; close handler will fire - }; - } - - private scheduleReconnect() { - if (this.closed) return; - const delay = this.backoffMs; - this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000); - window.setTimeout(() => this.connect(), delay); - } - - private flushPending(err: Error) { - for (const [, p] of this.pending) p.reject(err); - this.pending.clear(); - } - - private async sendConnect() { - if (this.connectSent) return; - this.connectSent = true; - if (this.connectTimer !== null) { - window.clearTimeout(this.connectTimer); - this.connectTimer = null; - } - - const isSecureContext = - !this.opts.disableDeviceAuth && typeof crypto !== "undefined" && !!crypto.subtle; - - const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; - const role = "operator"; - const authScopeKey = normalizeAuthScope(this.opts.authScopeKey ?? this.opts.url); - let deviceIdentity: Awaited> | null = null; - let canFallbackToShared = false; - let authToken = this.opts.token; - - if (isSecureContext) { - deviceIdentity = await loadOrCreateDeviceIdentity(); - const storedToken = loadDeviceAuthToken({ - deviceId: deviceIdentity.deviceId, - role, - scope: authScopeKey, - })?.token; - authToken = storedToken ?? this.opts.token; - canFallbackToShared = Boolean(storedToken && this.opts.token); - } - const auth = - authToken || this.opts.password - ? { - token: authToken, - password: this.opts.password, - } - : undefined; - - let device: - | { - id: string; - publicKey: string; - signature: string; - signedAt: number; - nonce: string | undefined; - } - | undefined; - - if (isSecureContext && deviceIdentity) { - const signedAtMs = Date.now(); - const nonce = this.connectNonce ?? undefined; - const payload = buildDeviceAuthPayload({ - deviceId: deviceIdentity.deviceId, - clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, - clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, - role, - scopes, - signedAtMs, - token: authToken ?? null, - nonce, - }); - const signature = await signDevicePayload(deviceIdentity.privateKey, payload); - device = { - id: deviceIdentity.deviceId, - publicKey: deviceIdentity.publicKey, - signature, - signedAt: signedAtMs, - nonce, - }; - } - const params = { - minProtocol: 3, - maxProtocol: 3, - client: { - id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, - version: this.opts.clientVersion ?? "dev", - platform: this.opts.platform ?? navigator.platform ?? "web", - mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, - instanceId: this.opts.instanceId, - }, - role, - scopes, - device, - caps: [], - auth, - userAgent: navigator.userAgent, - locale: navigator.language, - }; - - void this.request("connect", params) - .then((hello) => { - if (hello?.auth?.deviceToken && deviceIdentity) { - storeDeviceAuthToken({ - deviceId: deviceIdentity.deviceId, - role: hello.auth.role ?? role, - scope: authScopeKey, - token: hello.auth.deviceToken, - scopes: hello.auth.scopes ?? [], - }); - } - this.backoffMs = 800; - this.opts.onHello?.(hello); - }) - .catch((err) => { - if (canFallbackToShared && deviceIdentity) { - clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role, scope: authScopeKey }); - } - const rawReason = - err instanceof GatewayResponseError - ? `connect failed: ${err.code} ${err.message}` - : "connect failed"; - const reason = truncateWsCloseReason(rawReason); - if (reason !== rawReason) { - console.warn("[gateway] connect close reason truncated to 123 UTF-8 bytes"); - } - this.ws?.close(CONNECT_FAILED_CLOSE_CODE, reason); - }); - } - - private handleMessage(raw: string) { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return; - } - - const frame = parsed as { type?: unknown }; - if (frame.type === "event") { - const evt = parsed as GatewayEventFrame; - if (evt.event === "connect.challenge") { - const payload = evt.payload as { nonce?: unknown } | undefined; - const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null; - if (nonce) { - this.connectNonce = nonce; - void this.sendConnect(); - } - return; - } - const seq = typeof evt.seq === "number" ? evt.seq : null; - if (seq !== null) { - if (this.lastSeq !== null && seq > this.lastSeq + 1) { - this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq }); - } - this.lastSeq = seq; - } - try { - this.opts.onEvent?.(evt); - } catch (err) { - console.error("[gateway] event handler error:", err); - } - return; - } - - if (frame.type === "res") { - const res = parsed as GatewayResponseFrame; - const pending = this.pending.get(res.id); - if (!pending) return; - this.pending.delete(res.id); - if (res.ok) pending.resolve(res.payload); - else { - if (res.error && typeof res.error.code === "string") { - pending.reject( - new GatewayResponseError({ - code: res.error.code, - message: res.error.message ?? "request failed", - details: res.error.details, - }) - ); - return; - } - pending.reject(new Error(res.error?.message ?? "request failed")); - } - return; - } - } - - request(method: string, params?: unknown): Promise { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - return Promise.reject(new Error("gateway not connected")); - } - const id = generateUUID(); - const frame = { type: "req", id, method, params }; - const p = new Promise((resolve, reject) => { - this.pending.set(id, { resolve: (v) => resolve(v as T), reject }); - }); - this.ws.send(JSON.stringify(frame)); - return p; - } - - private queueConnect() { - this.connectNonce = null; - this.connectSent = false; - if (this.connectTimer !== null) window.clearTimeout(this.connectTimer); - this.connectTimer = window.setTimeout(() => { - void this.sendConnect(); - }, 750); - } -} diff --git a/src/lib/gateway/proxy-url.ts b/src/lib/gateway/proxy-url.ts deleted file mode 100644 index d556104..0000000 --- a/src/lib/gateway/proxy-url.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const resolveStudioProxyGatewayUrl = (): string => { - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const host = window.location.host; - return `${protocol}://${host}/api/gateway/ws`; -}; - diff --git a/src/lib/gateway/session-settings-sync.ts b/src/lib/gateway/session-settings-sync.ts new file mode 100644 index 0000000..2c623f6 --- /dev/null +++ b/src/lib/gateway/session-settings-sync.ts @@ -0,0 +1,80 @@ +type SessionSettingsPatchPayload = { + key: string; + model?: string | null; + thinkingLevel?: string | null; + execHost?: "sandbox" | "gateway" | "node" | null; + execSecurity?: "deny" | "allowlist" | "full" | null; + execAsk?: "off" | "on-miss" | "always" | null; +}; + +export type GatewaySessionsPatchResult = { + ok: true; + key: string; + entry?: { + thinkingLevel?: string; + }; + resolved?: { + modelProvider?: string; + model?: string; + }; +}; + +type GatewaySessionSettingsSyncClient = { + call: (method: string, params: unknown) => Promise; +}; + +type SyncGatewaySessionSettingsParams = { + client: GatewaySessionSettingsSyncClient; + sessionKey: string; + model?: string | null; + thinkingLevel?: string | null; + execHost?: "sandbox" | "gateway" | "node" | null; + execSecurity?: "deny" | "allowlist" | "full" | null; + execAsk?: "off" | "on-miss" | "always" | null; +}; + +export const syncGatewaySessionSettings = async ({ + client, + sessionKey, + model, + thinkingLevel, + execHost, + execSecurity, + execAsk, +}: SyncGatewaySessionSettingsParams) => { + const key = sessionKey.trim(); + if (!key) { + throw new Error("Session key is required."); + } + 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."); + } + const payload: SessionSettingsPatchPayload = { key }; + if (includeModel) { + payload.model = model ?? null; + } + if (includeThinkingLevel) { + payload.thinkingLevel = thinkingLevel ?? null; + } + if (includeExecHost) { + payload.execHost = execHost ?? null; + } + if (includeExecSecurity) { + payload.execSecurity = execSecurity ?? null; + } + if (includeExecAsk) { + payload.execAsk = execAsk ?? null; + } + return await client.call("sessions.patch", payload); +}; diff --git a/src/lib/skills/remove.ts b/src/lib/skills/remove.ts index 1425bb8..922a847 100644 --- a/src/lib/skills/remove.ts +++ b/src/lib/skills/remove.ts @@ -20,7 +20,7 @@ export const removeSkillFromGateway = async ( managedSkillsDir: normalizeRequired(request.managedSkillsDir, "managedSkillsDir"), }; - const response = await fetchJson<{ result: SkillRemoveResult }>("/api/gateway/skills/remove", { + const response = await fetchJson<{ result: SkillRemoveResult }>("/api/intents/skills-remove", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts index 5d6cab6..214930e 100644 --- a/src/lib/skills/types.ts +++ b/src/lib/skills/types.ts @@ -1,11 +1,11 @@ import type { GatewayClient } from "@/lib/gateway/GatewayClient"; -export type SkillStatusConfigCheck = { +type SkillStatusConfigCheck = { path: string; satisfied: boolean; }; -export type SkillRequirementSet = { +type SkillRequirementSet = { bins: string[]; anyBins: string[]; env: string[]; diff --git a/src/lib/studio/settings.ts b/src/lib/studio/settings.ts index 58c0288..25e76ce 100644 --- a/src/lib/studio/settings.ts +++ b/src/lib/studio/settings.ts @@ -3,13 +3,13 @@ export type StudioGatewaySettings = { token: string; }; -export type StudioGatewaySettingsPatch = { +type StudioGatewaySettingsPatch = { url?: string | null; token?: string | null; }; -export type FocusFilter = "all" | "running" | "approvals"; -export type StudioViewMode = "focused"; +type FocusFilter = "all" | "running" | "approvals"; +type StudioViewMode = "focused"; export type StudioFocusedPreference = { mode: StudioViewMode; diff --git a/src/lib/studio/useStudioGatewaySettings.ts b/src/lib/studio/useStudioGatewaySettings.ts new file mode 100644 index 0000000..53bf132 --- /dev/null +++ b/src/lib/studio/useStudioGatewaySettings.ts @@ -0,0 +1,243 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import type { GatewayStatus } from "@/lib/gateway/gateway-status"; +import { fetchJson } from "@/lib/http"; +import type { + StudioGatewaySettings, + StudioSettings, + StudioSettingsPatch, +} from "@/lib/studio/settings"; +import type { StudioSettingsResponse } from "@/lib/studio/coordinator"; + +const DEFAULT_UPSTREAM_GATEWAY_URL = + process.env.NEXT_PUBLIC_GATEWAY_URL || "ws://localhost:18789"; + +const removedGatewayClient: GatewayClient = { + call: async () => { + throw new Error("Browser gateway transport has been removed. Use Studio domain APIs."); + }, + onEvent: () => () => {}, + onGap: () => () => {}, +}; + +const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => { + if (!value || typeof value !== "object") return null; + const raw = value as { url?: unknown; token?: unknown }; + const url = typeof raw.url === "string" ? raw.url.trim() : ""; + const token = typeof raw.token === "string" ? raw.token.trim() : ""; + if (!url) return null; + return { url, token }; +}; + +const formatGatewayError = (error: unknown): string => { + if (error instanceof Error) return error.message; + return "Unknown gateway error."; +}; + +type RuntimeSummaryEnvelope = { + summary?: { + status?: unknown; + } | null; + error?: unknown; +}; + +const mapRuntimeStatusToGatewayStatus = (value: unknown): GatewayStatus => { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (normalized === "connected") return "connected"; + if (normalized === "connecting" || normalized === "reconnecting") return "connecting"; + return "disconnected"; +}; + +type StudioSettingsCoordinatorLike = { + loadSettings: () => Promise; + loadSettingsEnvelope?: () => Promise; + schedulePatch: (patch: StudioSettingsPatch, debounceMs?: number) => void; + flushPending: () => Promise; +}; + +type StudioGatewaySettingsState = { + client: GatewayClient; + status: GatewayStatus; + gatewayUrl: string; + token: string; + localGatewayDefaults: StudioGatewaySettings | null; + domainApiModeEnabled: boolean; + error: string | null; + connect: () => Promise; + disconnect: () => void; + useLocalGatewayDefaults: () => void; + setGatewayUrl: (value: string) => void; + setToken: (value: string) => void; + clearError: () => void; +}; + +export const useStudioGatewaySettings = ( + settingsCoordinator: StudioSettingsCoordinatorLike +): StudioGatewaySettingsState => { + const [gatewayUrl, setGatewayUrlState] = useState(DEFAULT_UPSTREAM_GATEWAY_URL); + const [token, setTokenState] = useState(""); + const [localGatewayDefaults, setLocalGatewayDefaults] = useState( + null + ); + const domainApiModeEnabled = true; + const [status, setStatus] = useState("disconnected"); + const [error, setError] = useState(null); + const [settingsLoaded, setSettingsLoaded] = useState(false); + const manualDisconnectRef = useRef(false); + const didAutoConnectRef = useRef(false); + + const clearError = useCallback(() => { + setError(null); + }, []); + + useEffect(() => { + let cancelled = false; + const loadSettings = async () => { + try { + const envelope = + typeof settingsCoordinator.loadSettingsEnvelope === "function" + ? await settingsCoordinator.loadSettingsEnvelope() + : { settings: await settingsCoordinator.loadSettings(), localGatewayDefaults: null }; + const settings = envelope.settings ?? null; + const gateway = settings?.gateway ?? null; + if (cancelled) return; + + const nextUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL; + const nextToken = typeof gateway?.token === "string" ? gateway.token : ""; + setGatewayUrlState(nextUrl); + setTokenState(nextToken); + setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults)); + + } catch (nextError) { + if (!cancelled) { + setError(formatGatewayError(nextError)); + } + } finally { + if (!cancelled) { + setSettingsLoaded(true); + } + } + }; + void loadSettings(); + return () => { + cancelled = true; + }; + }, [settingsCoordinator]); + + const connect = useCallback(async () => { + const trimmedGatewayUrl = gatewayUrl.trim(); + if (!trimmedGatewayUrl) { + setStatus("disconnected"); + setError("Gateway URL is required."); + return; + } + setStatus("connecting"); + setError(null); + manualDisconnectRef.current = false; + try { + await settingsCoordinator.flushPending(); + const summary = await fetchJson("/api/runtime/summary", { + cache: "no-store", + }); + const nextStatus = mapRuntimeStatusToGatewayStatus(summary?.summary?.status); + setStatus(nextStatus); + const runtimeError = + typeof summary?.error === "string" ? summary.error.trim() : ""; + if (nextStatus === "connected" || !runtimeError) { + setError(null); + } else { + setError(runtimeError); + } + } catch (nextError) { + setStatus("disconnected"); + setError(formatGatewayError(nextError)); + } + }, [gatewayUrl, settingsCoordinator]); + + const disconnect = useCallback(() => { + manualDisconnectRef.current = true; + setStatus("disconnected"); + }, []); + + useEffect(() => { + if (!settingsLoaded) return; + if (manualDisconnectRef.current) return; + if (didAutoConnectRef.current) return; + if (status !== "disconnected") return; + if (!gatewayUrl.trim()) return; + didAutoConnectRef.current = true; + void connect(); + }, [connect, gatewayUrl, settingsLoaded, status]); + + const setGatewayUrl = useCallback( + (value: string) => { + setGatewayUrlState(value); + manualDisconnectRef.current = false; + setStatus("disconnected"); + setError(null); + settingsCoordinator.schedulePatch({ gateway: { url: value, token } }, 350); + }, + [settingsCoordinator, token] + ); + + const setToken = useCallback( + (value: string) => { + setTokenState(value); + manualDisconnectRef.current = false; + setStatus("disconnected"); + setError(null); + settingsCoordinator.schedulePatch({ gateway: { url: gatewayUrl, token: value } }, 350); + }, + [gatewayUrl, settingsCoordinator] + ); + + const useLocalGatewayDefaults = useCallback(() => { + if (!localGatewayDefaults) return; + manualDisconnectRef.current = false; + setGatewayUrlState(localGatewayDefaults.url); + setTokenState(localGatewayDefaults.token ?? ""); + setStatus("disconnected"); + setError(null); + settingsCoordinator.schedulePatch( + { + gateway: { url: localGatewayDefaults.url, token: localGatewayDefaults.token ?? "" }, + }, + 350 + ); + }, [localGatewayDefaults, settingsCoordinator]); + + return useMemo( + () => ({ + client: removedGatewayClient, + status, + gatewayUrl, + token, + localGatewayDefaults, + domainApiModeEnabled, + error, + connect, + disconnect, + useLocalGatewayDefaults, + setGatewayUrl, + setToken, + clearError, + }), + [ + clearError, + connect, + disconnect, + domainApiModeEnabled, + error, + gatewayUrl, + localGatewayDefaults, + setGatewayUrl, + setToken, + status, + token, + useLocalGatewayDefaults, + ] + ); +}; diff --git a/src/lib/text/media-markdown.ts b/src/lib/text/media-markdown.ts index 937f34d..7b8542d 100644 --- a/src/lib/text/media-markdown.ts +++ b/src/lib/text/media-markdown.ts @@ -14,7 +14,7 @@ const isImagePath = (value: string): boolean => { }; const toMediaUrl = (path: string): string => { - return `/api/gateway/media?path=${encodeURIComponent(path)}`; + return `/api/runtime/media?path=${encodeURIComponent(path)}`; }; /** diff --git a/tests/setup.ts b/tests/setup.ts index 2a39b9f..8de7f4f 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,12 +1,5 @@ import "@testing-library/jest-dom/vitest"; -if (!process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE) { - process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false"; -} -if (!process.env.STUDIO_DOMAIN_API_MODE) { - process.env.STUDIO_DOMAIN_API_MODE = "false"; -} - const ensureLocalStorage = () => { if (typeof window === "undefined") return; const existing = window.localStorage as unknown as Record | undefined; diff --git a/tests/unit/agentBrainPanel.test.ts b/tests/unit/agentBrainPanel.test.ts index d4c20f8..dec72fd 100644 --- a/tests/unit/agentBrainPanel.test.ts +++ b/tests/unit/agentBrainPanel.test.ts @@ -1,10 +1,50 @@ import { createElement } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { AgentState } from "@/features/agents/state/store"; import { AgentBrainPanel } from "@/features/agents/components/AgentInspectPanels"; -import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import type { AgentFileName } from "@/lib/agents/agentFiles"; + +const mockState = vi.hoisted(() => { + const filesByAgent: Record> = { + "agent-1": { + "AGENTS.md": "alpha agents", + "SOUL.md": "# SOUL.md - Who You Are\n\n## Core Truths\n\nBe useful.", + "IDENTITY.md": "# IDENTITY.md - Who Am I?\n\n- Name: Alpha\n- Creature: droid\n- Vibe: calm\n- Emoji: 🤖\n", + "USER.md": "# USER.md - About Your Human\n\n- Name: George\n- What to call them: GP\n\n## Context\n\nBuilding OpenClaw Studio.", + "TOOLS.md": "tool notes", + "HEARTBEAT.md": "heartbeat notes", + "MEMORY.md": "durable memory", + }, + "agent-2": { + "AGENTS.md": "beta agents", + }, + }; + const readCalls: Array<{ agentId: string; name: AgentFileName }> = []; + const writeCalls: Array<{ agentId: string; name: AgentFileName; content: string }> = []; + return { filesByAgent, readCalls, writeCalls }; +}); + +vi.mock("@/lib/controlplane/domain-runtime-client", () => ({ + readDomainAgentFile: vi.fn(async (params: { agentId: string; name: AgentFileName }) => { + mockState.readCalls.push({ agentId: params.agentId, name: params.name }); + const content = mockState.filesByAgent[params.agentId]?.[params.name]; + if (typeof content !== "string") { + return { exists: false, content: "" }; + } + return { exists: true, content }; + }), + writeDomainAgentFile: vi.fn( + async (params: { agentId: string; name: AgentFileName; content: string }) => { + mockState.writeCalls.push(params); + if (!mockState.filesByAgent[params.agentId]) { + mockState.filesByAgent[params.agentId] = {}; + } + mockState.filesByAgent[params.agentId][params.name] = params.content; + } + ), +})); const createAgent = (agentId: string, name: string, sessionKey: string): AgentState => ({ agentId, @@ -41,62 +81,17 @@ const createAgent = (agentId: string, name: string, sessionKey: string): AgentSt avatarUrl: null, }); -const createMockClient = () => { - const filesByAgent: Record> = { - "agent-1": { - "AGENTS.md": "alpha agents", - "SOUL.md": "# SOUL.md - Who You Are\n\n## Core Truths\n\nBe useful.", - "IDENTITY.md": "# IDENTITY.md - Who Am I?\n\n- Name: Alpha\n- Creature: droid\n- Vibe: calm\n- Emoji: 🤖\n", - "USER.md": "# USER.md - About Your Human\n\n- Name: George\n- What to call them: GP\n\n## Context\n\nBuilding OpenClaw Studio.", - "TOOLS.md": "tool notes", - "HEARTBEAT.md": "heartbeat notes", - "MEMORY.md": "durable memory", - }, - "agent-2": { - "AGENTS.md": "beta agents", - }, - }; - - const calls: Array<{ method: string; params: unknown }> = []; - - const client = { - call: vi.fn(async (method: string, params: unknown) => { - calls.push({ method, params }); - if (method === "agents.files.get") { - const record = params && typeof params === "object" ? (params as Record) : {}; - const agentId = typeof record.agentId === "string" ? record.agentId : ""; - const name = typeof record.name === "string" ? record.name : ""; - const content = filesByAgent[agentId]?.[name]; - if (typeof content !== "string") { - return { file: { name, missing: true } }; - } - return { file: { name, missing: false, content } }; - } - if (method === "agents.files.set") { - const record = params && typeof params === "object" ? (params as Record) : {}; - const agentId = typeof record.agentId === "string" ? record.agentId : ""; - const name = typeof record.name === "string" ? record.name : ""; - const content = typeof record.content === "string" ? record.content : ""; - if (!filesByAgent[agentId]) { - filesByAgent[agentId] = {}; - } - filesByAgent[agentId][name] = content; - return { ok: true }; - } - return {}; - }), - } as unknown as GatewayClient; - - return { client, calls, filesByAgent }; -}; - describe("AgentBrainPanel", () => { + beforeEach(() => { + mockState.readCalls.length = 0; + mockState.writeCalls.length = 0; + }); + afterEach(() => { cleanup(); }); it("renders_behavior_sections_and_loads_agent_files", async () => { - const { client } = createMockClient(); const agents = [ createAgent("agent-1", "Alpha", "session-1"), createAgent("agent-2", "Beta", "session-2"), @@ -104,7 +99,7 @@ describe("AgentBrainPanel", () => { render( createElement(AgentBrainPanel, { - client, + gatewayStatus: "connected", agents, selectedAgentId: "agent-1", }) @@ -125,12 +120,11 @@ describe("AgentBrainPanel", () => { }); it("shows_actionable_message_when_session_key_missing", async () => { - const { client } = createMockClient(); const agents = [createAgent("", "Alpha", "session-1")]; render( createElement(AgentBrainPanel, { - client, + gatewayStatus: "connected", agents, selectedAgentId: "", }) @@ -142,12 +136,11 @@ describe("AgentBrainPanel", () => { }); it("saves_updated_behavior_files", async () => { - const { client, calls, filesByAgent } = createMockClient(); const agents = [createAgent("agent-1", "Alpha", "session-1")]; render( createElement(AgentBrainPanel, { - client, + gatewayStatus: "connected", agents, selectedAgentId: "agent-1", }) @@ -166,18 +159,17 @@ describe("AgentBrainPanel", () => { fireEvent.click(saveButton); await waitFor(() => { - expect(calls.some((entry) => entry.method === "agents.files.set")).toBe(true); + expect(mockState.writeCalls.length).toBeGreaterThan(0); }); - expect(filesByAgent["agent-1"]["AGENTS.md"]).toBe("alpha directives updated"); + expect(mockState.filesByAgent["agent-1"]["AGENTS.md"]).toBe("alpha directives updated"); }); it("discards_unsaved_changes_without_writing_files", async () => { - const { client, calls } = createMockClient(); const agents = [createAgent("agent-1", "Alpha", "session-1")]; render( createElement(AgentBrainPanel, { - client, + gatewayStatus: "connected", agents, selectedAgentId: "agent-1", }) @@ -194,16 +186,15 @@ describe("AgentBrainPanel", () => { fireEvent.click(screen.getByRole("button", { name: "Discard" })); expect(screen.getByLabelText("Name")).toHaveValue("Alpha"); - expect(calls.some((entry) => entry.method === "agents.files.set")).toBe(false); + expect(mockState.writeCalls.length).toBe(0); }); it("does_not_render_name_editor_in_personality_panel", async () => { - const { client } = createMockClient(); const agents = [createAgent("agent-1", "Alpha", "session-1")]; render( createElement(AgentBrainPanel, { - client, + gatewayStatus: "connected", agents, selectedAgentId: "agent-1", }) @@ -215,4 +206,33 @@ describe("AgentBrainPanel", () => { expect(screen.queryByLabelText("Agent name")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Update Name" })).not.toBeInTheDocument(); }); + + it("loads_files_after_gateway_connects", async () => { + const agents = [createAgent("agent-1", "Alpha", "session-1")]; + + const view = render( + createElement(AgentBrainPanel, { + gatewayStatus: "connecting", + agents, + selectedAgentId: "agent-1", + }) + ); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Persona" })).toBeInTheDocument(); + }); + expect(mockState.readCalls.length).toBe(0); + + view.rerender( + createElement(AgentBrainPanel, { + gatewayStatus: "connected", + agents, + selectedAgentId: "agent-1", + }) + ); + + await waitFor(() => { + expect(mockState.readCalls.length).toBeGreaterThan(0); + }); + }); }); diff --git a/tests/unit/agentStateRoute.test.ts b/tests/unit/agentStateRoute.test.ts index 607110b..a5be606 100644 --- a/tests/unit/agentStateRoute.test.ts +++ b/tests/unit/agentStateRoute.test.ts @@ -5,7 +5,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { POST, PUT } from "@/app/api/gateway/agent-state/route"; +import { POST, PUT } from "@/app/api/runtime/agent-state/route"; const ORIGINAL_ENV = { ...process.env }; @@ -56,7 +56,7 @@ describe("agent state route", () => { it("rejects missing agentId", async () => { const response = await POST( - new Request("http://localhost/api/gateway/agent-state", { + new Request("http://localhost/api/runtime/agent-state", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}), @@ -67,7 +67,7 @@ describe("agent state route", () => { it("rejects unsafe agentId", async () => { const response = await POST( - new Request("http://localhost/api/gateway/agent-state", { + new Request("http://localhost/api/runtime/agent-state", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ agentId: "../nope" }), @@ -87,7 +87,7 @@ describe("agent state route", () => { } as never); const response = await POST( - new Request("http://localhost/api/gateway/agent-state", { + new Request("http://localhost/api/runtime/agent-state", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ agentId: "my-agent" }), @@ -130,7 +130,7 @@ describe("agent state route", () => { } as never); const response = await PUT( - new Request("http://localhost/api/gateway/agent-state", { + new Request("http://localhost/api/runtime/agent-state", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ agentId: "my-agent", trashDir: "/tmp/trash" }), @@ -167,7 +167,7 @@ describe("agent state route", () => { } as never); const response = await POST( - new Request("http://localhost/api/gateway/agent-state", { + new Request("http://localhost/api/runtime/agent-state", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ agentId: "my-agent" }), diff --git a/tests/unit/controlPlaneRuntime.test.ts b/tests/unit/controlPlaneRuntime.test.ts index 8a50ebe..62da8b8 100644 --- a/tests/unit/controlPlaneRuntime.test.ts +++ b/tests/unit/controlPlaneRuntime.test.ts @@ -141,30 +141,17 @@ describe("control-plane runtime", () => { expect(a).toBe(b); }); - it("parses STUDIO_DOMAIN_API_MODE values", () => { + it("always enables domain mode", () => { 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"; expect(isStudioDomainApiModeEnabled()).toBe(true); process.env.STUDIO_DOMAIN_API_MODE = "false"; - expect(isStudioDomainApiModeEnabled()).toBe(false); + expect(isStudioDomainApiModeEnabled()).toBe(true); delete process.env.STUDIO_DOMAIN_API_MODE; expect(isStudioDomainApiModeEnabled()).toBe(true); - }); - - it("falls back to NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE when server mode is unset", () => { - delete process.env.STUDIO_DOMAIN_API_MODE; process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false"; - expect(isStudioDomainApiModeEnabled()).toBe(false); - - process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true"; expect(isStudioDomainApiModeEnabled()).toBe(true); }); - - it("prefers STUDIO_DOMAIN_API_MODE over NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE", () => { - process.env.STUDIO_DOMAIN_API_MODE = "false"; - process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true"; - expect(isStudioDomainApiModeEnabled()).toBe(false); - }); }); diff --git a/tests/unit/deleteAgentOperation.test.ts b/tests/unit/deleteAgentOperation.test.ts index 2f4fa89..c42c81b 100644 --- a/tests/unit/deleteAgentOperation.test.ts +++ b/tests/unit/deleteAgentOperation.test.ts @@ -278,7 +278,7 @@ describe("delete agent via studio operation", () => { it("uses domain cron intents instead of browser cron rpc when domain intents are enabled", async () => { const fetchJson: FetchJson = vi.fn(async (input, init) => { - if (input === "/api/gateway/agent-state" && init?.method === "POST") { + if (input === "/api/runtime/agent-state" && init?.method === "POST") { return { result: createTrashResult() } as never; } if (input === "/api/intents/cron-remove-agent" && init?.method === "POST") { @@ -338,7 +338,7 @@ describe("delete agent via studio operation", () => { const backups = [createCronRestoreInput("Job R", "agent-1")]; const callOrder: string[] = []; const fetchJson: FetchJson = vi.fn(async (input, init) => { - if (input === "/api/gateway/agent-state" && init?.method === "POST") { + if (input === "/api/runtime/agent-state" && init?.method === "POST") { callOrder.push("trash"); return { result: createTrashResult({ @@ -355,7 +355,7 @@ describe("delete agent via studio operation", () => { callOrder.push("cron-restore"); return { ok: true, payload: { restored: backups.length } } as never; } - if (input === "/api/gateway/agent-state" && init?.method === "PUT") { + if (input === "/api/runtime/agent-state" && init?.method === "PUT") { callOrder.push("state-restore"); return { result: { restored: [] } } as never; } diff --git a/tests/unit/gatewayBrowserClient.test.ts b/tests/unit/gatewayBrowserClient.test.ts deleted file mode 100644 index 90b265b..0000000 --- a/tests/unit/gatewayBrowserClient.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { GatewayBrowserClient } from "@/lib/gateway/openclaw/GatewayBrowserClient"; - -const UUID_V4_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - -class MockWebSocket { - static OPEN = 1; - static CLOSED = 3; - static instances: MockWebSocket[] = []; - static sent: string[] = []; - static closes: Array<{ code: number; reason: string }> = []; - - readyState = MockWebSocket.OPEN; - onopen: (() => void) | null = null; - onmessage: ((event: MessageEvent) => void) | null = null; - onclose: ((event: CloseEvent) => void) | null = null; - onerror: (() => void) | null = null; - - constructor(public url: string) { - MockWebSocket.instances.push(this); - } - - send(data: string) { - MockWebSocket.sent.push(String(data)); - } - - close(code?: number, reason?: string) { - MockWebSocket.closes.push({ code: code ?? 1000, reason: reason ?? "" }); - this.readyState = MockWebSocket.CLOSED; - this.onclose?.({ code: code ?? 1000, reason: reason ?? "" } as CloseEvent); - } -} - -describe("GatewayBrowserClient", () => { - const originalWebSocket = globalThis.WebSocket; - const originalSubtle = globalThis.crypto?.subtle; - - beforeEach(() => { - MockWebSocket.instances = []; - MockWebSocket.sent = []; - MockWebSocket.closes = []; - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; - if (globalThis.crypto) { - Object.defineProperty(globalThis.crypto, "subtle", { - value: undefined, - configurable: true, - }); - } - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - globalThis.WebSocket = originalWebSocket; - if (globalThis.crypto) { - Object.defineProperty(globalThis.crypto, "subtle", { - value: originalSubtle, - configurable: true, - }); - } - }); - - it("sends connect when connect.challenge arrives", async () => { - const client = new GatewayBrowserClient({ url: "ws://example.com" }); - client.start(); - - const ws = MockWebSocket.instances[0]; - if (!ws) { - throw new Error("WebSocket not created"); - } - - ws.onopen?.(); - - expect(MockWebSocket.sent).toHaveLength(0); - - ws.onmessage?.({ - data: JSON.stringify({ - type: "event", - event: "connect.challenge", - payload: { nonce: "abc" }, - }), - } as MessageEvent); - - await vi.runAllTicks(); - - expect(MockWebSocket.sent).toHaveLength(1); - const frame = JSON.parse(MockWebSocket.sent[0] ?? "{}"); - expect(frame.type).toBe("req"); - expect(frame.method).toBe("connect"); - expect(typeof frame.id).toBe("string"); - expect(frame.id).toMatch(UUID_V4_RE); - expect(frame.params?.client?.id).toBe("openclaw-control-ui"); - }); - - it("truncates connect-failed close reason to websocket limit", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const client = new GatewayBrowserClient({ url: "ws://example.com", token: "secret" }); - client.start(); - - const ws = MockWebSocket.instances[0]; - if (!ws) { - throw new Error("WebSocket not created"); - } - - ws.onopen?.(); - vi.runAllTimers(); - - const connectFrame = JSON.parse(MockWebSocket.sent[0] ?? "{}"); - const connectId = String(connectFrame.id ?? ""); - expect(connectId).toMatch(UUID_V4_RE); - - ws.onmessage?.({ - data: JSON.stringify({ - type: "res", - id: connectId, - ok: false, - error: { - code: "INVALID_REQUEST", - message: `invalid config ${"x".repeat(260)}`, - }, - }), - } as MessageEvent); - - await vi.runAllTicks(); - await vi.runAllTimersAsync(); - await Promise.resolve(); - - const lastClose = MockWebSocket.closes.at(-1); - expect(lastClose?.code).toBe(4008); - expect(lastClose?.reason.startsWith("connect failed: INVALID_REQUEST")).toBe(true); - expect(new TextEncoder().encode(lastClose?.reason ?? "").byteLength).toBeLessThanOrEqual(123); - warnSpy.mockRestore(); - }); -}); diff --git a/tests/unit/gatewayClient.connectErrors.test.ts b/tests/unit/gatewayClient.connectErrors.test.ts deleted file mode 100644 index d3b19fb..0000000 --- a/tests/unit/gatewayClient.connectErrors.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { GatewayResponseError } from "@/lib/gateway/errors"; -import { GatewayClient } from "@/lib/gateway/GatewayClient"; - -let lastOpts: Record | null = null; - -vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => { - class GatewayBrowserClient { - connected = false; - constructor(opts: Record) { - lastOpts = opts; - } - start() {} - stop() {} - request() { - return Promise.resolve({}); - } - } - - return { GatewayBrowserClient }; -}); - -describe("GatewayClient connect failures", () => { - it("rejects connect with GatewayResponseError when close reason encodes connect failed", async () => { - const client = new GatewayClient(); - - const connectPromise = client.connect({ gatewayUrl: "ws://example.invalid" }); - - if (!lastOpts) { - throw new Error("Expected GatewayBrowserClient to be constructed"); - } - - const onClose = lastOpts.onClose as ((info: { code: number; reason: string }) => void) | undefined; - if (!onClose) { - throw new Error("Expected onClose callback"); - } - - onClose({ - code: 4008, - reason: - "connect failed: studio.gateway_token_missing Upstream gateway token is not configured on the Studio host.", - }); - - await expect(connectPromise).rejects.toBeInstanceOf(GatewayResponseError); - await expect(connectPromise).rejects.toMatchObject({ - name: "GatewayResponseError", - code: "studio.gateway_token_missing", - }); - }); -}); - diff --git a/tests/unit/gatewayClient.gap.test.ts b/tests/unit/gatewayClient.gap.test.ts deleted file mode 100644 index 2374546..0000000 --- a/tests/unit/gatewayClient.gap.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { GatewayClient } from "@/lib/gateway/GatewayClient"; - -let lastOpts: Record | null = null; - -vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => { - class GatewayBrowserClient { - connected = true; - constructor(opts: Record) { - lastOpts = opts; - } - start() {} - stop() {} - request() { - return Promise.resolve({}); - } - } - return { GatewayBrowserClient }; -}); - -describe("GatewayClient onGap", () => { - it("forwards gateway seq gaps to subscribers", async () => { - const client = new GatewayClient(); - const onGap = vi.fn(); - client.onGap(onGap); - - const connectPromise = client.connect({ gatewayUrl: "ws://example.invalid" }); - if (!lastOpts) throw new Error("Expected GatewayBrowserClient to be constructed"); - - const onHello = lastOpts.onHello as ((hello: unknown) => void) | undefined; - if (!onHello) throw new Error("Expected onHello callback"); - onHello({} as never); - - await connectPromise; - - const gapCb = lastOpts.onGap as ((info: { expected: number; received: number }) => void) | undefined; - if (!gapCb) throw new Error("Expected onGap callback"); - gapCb({ expected: 10, received: 13 }); - - expect(onGap).toHaveBeenCalledTimes(1); - expect(onGap).toHaveBeenCalledWith({ expected: 10, received: 13 }); - }); -}); - diff --git a/tests/unit/gatewayClient.reconnect-recovery.test.ts b/tests/unit/gatewayClient.reconnect-recovery.test.ts deleted file mode 100644 index 1dc08f6..0000000 --- a/tests/unit/gatewayClient.reconnect-recovery.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { GatewayClient } from "@/lib/gateway/GatewayClient"; - -type MockClientOptions = { - token?: unknown; - onHello?: (hello: unknown) => void; - onClose?: (info: { code: number; reason: string }) => void; -}; - -type MockInstance = { - opts: MockClientOptions; - stopped: boolean; -}; - -let instances: MockInstance[] = []; - -vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => { - class GatewayBrowserClient { - connected = false; - private index: number; - - constructor(opts: MockClientOptions) { - this.index = instances.length; - instances.push({ opts, stopped: false }); - } - - start() { - this.connected = true; - } - - stop() { - this.connected = false; - instances[this.index]!.stopped = true; - } - - request() { - return Promise.resolve({}); - } - } - - return { GatewayBrowserClient }; -}); - -afterEach(() => { - instances = []; -}); - -describe("GatewayClient reconnect recovery", () => { - it("allows a fresh connect after unexpected close", async () => { - const client = new GatewayClient(); - const statuses: string[] = []; - client.onStatus((status) => statuses.push(status)); - - const firstConnect = client.connect({ - gatewayUrl: "ws://example.invalid", - token: "old-token", - }); - const first = instances[0]; - if (!first) throw new Error("Expected first GatewayBrowserClient instance"); - - const onHelloFirst = first.opts.onHello; - const onCloseFirst = first.opts.onClose; - if (!onHelloFirst || !onCloseFirst) { - throw new Error("Expected first instance callbacks"); - } - - onHelloFirst({}); - await expect(firstConnect).resolves.toBeUndefined(); - - onCloseFirst({ code: 1012, reason: "upstream closed" }); - - expect(first.stopped).toBe(true); - expect(statuses.at(-1)).toBe("disconnected"); - - const secondConnect = client.connect({ - gatewayUrl: "ws://example.invalid", - token: "new-token", - }); - const second = instances[1]; - if (!second) throw new Error("Expected second GatewayBrowserClient instance"); - - expect(second.opts.token).toBe("new-token"); - - const onHelloSecond = second.opts.onHello; - if (!onHelloSecond) { - throw new Error("Expected second instance onHello callback"); - } - - onHelloSecond({}); - await expect(secondConnect).resolves.toBeUndefined(); - - expect(statuses.at(-1)).toBe("connected"); - }); - - it("ignores stale onClose callbacks from old instances", async () => { - const client = new GatewayClient(); - const statuses: string[] = []; - client.onStatus((status) => statuses.push(status)); - - const firstConnect = client.connect({ gatewayUrl: "ws://example.invalid" }); - const first = instances[0]; - if (!first) throw new Error("Expected first GatewayBrowserClient instance"); - - const onHelloFirst = first.opts.onHello; - const onCloseFirst = first.opts.onClose; - if (!onHelloFirst || !onCloseFirst) { - throw new Error("Expected first instance callbacks"); - } - - onHelloFirst({}); - await firstConnect; - - onCloseFirst({ code: 1012, reason: "upstream closed" }); - - const secondConnect = client.connect({ gatewayUrl: "ws://example.invalid" }); - const second = instances[1]; - if (!second) throw new Error("Expected second GatewayBrowserClient instance"); - - const onHelloSecond = second.opts.onHello; - if (!onHelloSecond) { - throw new Error("Expected second instance onHello callback"); - } - - onHelloSecond({}); - await secondConnect; - - const statusCountBeforeStaleClose = statuses.length; - onCloseFirst({ code: 1012, reason: "late stale close" }); - - expect(statuses.length).toBe(statusCountBeforeStaleClose); - expect(statuses.at(-1)).toBe("connected"); - }); -}); diff --git a/tests/unit/gatewayConnectRetryPolicy.test.ts b/tests/unit/gatewayConnectRetryPolicy.test.ts deleted file mode 100644 index 8d0917f..0000000 --- a/tests/unit/gatewayConnectRetryPolicy.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { resolveGatewayAutoRetryDelayMs } from "@/lib/gateway/GatewayClient"; - -describe("resolveGatewayAutoRetryDelayMs", () => { - it("does not retry when upstream gateway url is missing on Studio host", () => { - const delay = resolveGatewayAutoRetryDelayMs({ - status: "disconnected", - didAutoConnect: true, - wasManualDisconnect: false, - gatewayUrl: "wss://remote.example", - errorMessage: "Gateway error (studio.gateway_url_missing): Upstream gateway URL is missing.", - connectErrorCode: "studio.gateway_url_missing", - attempt: 0, - }); - - expect(delay).toBeNull(); - }); - - it("retries for non-auth connect failures", () => { - const delay = resolveGatewayAutoRetryDelayMs({ - status: "disconnected", - didAutoConnect: true, - wasManualDisconnect: false, - gatewayUrl: "wss://remote.example", - errorMessage: - "Gateway error (studio.upstream_error): Failed to connect to upstream gateway WebSocket.", - connectErrorCode: "studio.upstream_error", - attempt: 0, - }); - - expect(delay).toBeTypeOf("number"); - expect(delay).toBeGreaterThan(0); - }); -}); - diff --git a/tests/unit/gatewayFrames.test.ts b/tests/unit/gatewayFrames.test.ts index 83c0160..dfdbc14 100644 --- a/tests/unit/gatewayFrames.test.ts +++ b/tests/unit/gatewayFrames.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseGatewayFrame } from "@/lib/gateway/GatewayClient"; +import { parseGatewayFrame } from "@/lib/gateway/gateway-frames"; describe("gateway frames", () => { it("parses event stateVersion objects", () => { diff --git a/tests/unit/gatewayMediaRoute.test.ts b/tests/unit/gatewayMediaRoute.test.ts index 76d96be..4616871 100644 --- a/tests/unit/gatewayMediaRoute.test.ts +++ b/tests/unit/gatewayMediaRoute.test.ts @@ -22,7 +22,7 @@ vi.mock("node:child_process", async () => { const mockedSpawnSync = vi.mocked(spawnSync); -let GET: typeof import("@/app/api/gateway/media/route")["GET"]; +let GET: typeof import("@/app/api/runtime/media/route")["GET"]; const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); @@ -45,10 +45,10 @@ const writeStudioSettings = (stateDir: string, gatewayUrl: string) => { }; beforeAll(async () => { - ({ GET } = await import("@/app/api/gateway/media/route")); + ({ GET } = await import("@/app/api/runtime/media/route")); }); -describe("/api/gateway/media route", () => { +describe("/api/runtime/media route", () => { let tempDir: string | null = null; beforeEach(() => { @@ -89,7 +89,7 @@ describe("/api/gateway/media route", () => { const remotePath = "/home/ubuntu/.openclaw/images/pic.png"; const response = await GET( new Request( - `http://localhost/api/gateway/media?path=${encodeURIComponent(remotePath)}` + `http://localhost/api/runtime/media?path=${encodeURIComponent(remotePath)}` ) ); @@ -124,4 +124,3 @@ describe("/api/gateway/media route", () => { expect(options.maxBuffer).toBeGreaterThan(payloadBytes.length); }); }); - diff --git a/tests/unit/gatewayProxy.test.ts b/tests/unit/gatewayProxy.test.ts deleted file mode 100644 index 6bd8017..0000000 --- a/tests/unit/gatewayProxy.test.ts +++ /dev/null @@ -1,712 +0,0 @@ -// @vitest-environment node - -import { EventEmitter } from "node:events"; - -import { afterEach, describe, expect, it, vi } from "vitest"; -import { WebSocket, WebSocketServer } from "ws"; - -const waitForEvent = ( - target: { once: (event: string, cb: (...args: unknown[]) => void) => void }, - event: string -) => - new Promise((resolve) => { - target.once(event, (...args: unknown[]) => resolve(args as unknown as T)); - }); - -const closeHttpServer = (server: import("node:http").Server) => - new Promise((resolve) => server.close(() => resolve())); - -const closeWebSocketServer = (server: WebSocketServer) => - new Promise((resolve) => server.close(() => resolve())); - -const closeWebSocket = (ws: WebSocket) => - new Promise((resolve) => { - if (ws.readyState === WebSocket.CLOSED) { - resolve(); - return; - } - ws.once("close", () => resolve()); - ws.close(); - }); - -describe("createGatewayProxy", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("injects gateway token into connect request", async () => { - const upstream = new WebSocketServer({ port: 0 }); - const address = upstream.address(); - if (!address || typeof address === "string") { - throw new Error("expected upstream server to have a port"); - } - const upstreamUrl = `ws://127.0.0.1:${address.port}`; - - let seenToken: string | null = null; - let seenOrigin: string | undefined; - upstream.on("connection", (ws, req) => { - seenOrigin = req.headers.origin; - ws.on("message", (raw) => { - const parsed = JSON.parse(String(raw)); - if (parsed?.method === "connect") { - seenToken = parsed?.params?.auth?.token ?? null; - ws.send( - JSON.stringify({ - type: "res", - id: parsed.id, - ok: true, - payload: { type: "hello-ok", protocol: 3, auth: {} }, - }) - ); - } - }); - }); - - const { createGatewayProxy } = await import("../../server/gateway-proxy"); - - const proxyHttp = await import("node:http").then((m) => m.createServer()); - const proxy = createGatewayProxy({ - loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "token-123" }), - allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", - logError: () => {}, - }); - proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); - - await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); - const proxyAddr = proxyHttp.address(); - if (!proxyAddr || typeof proxyAddr === "string") { - throw new Error("expected proxy server to have a port"); - } - - const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); - try { - await waitForEvent(browser, "open"); - - browser.send( - JSON.stringify({ - type: "req", - id: "connect-1", - method: "connect", - params: { auth: {} }, - }) - ); - - await waitForEvent(browser, "message"); - - expect(seenToken).toBe("token-123"); - expect(seenOrigin).toBe(`http://localhost:${address.port}`); - } finally { - for (const client of upstream.clients) { - client.close(); - } - await Promise.all([ - closeWebSocket(browser), - closeWebSocketServer(upstream), - closeHttpServer(proxyHttp), - ]); - } - }); - - it("allows browser auth token passthrough when host token is missing", async () => { - const upstream = new WebSocketServer({ port: 0 }); - const address = upstream.address(); - if (!address || typeof address === "string") { - throw new Error("expected upstream server to have a port"); - } - const upstreamUrl = `ws://127.0.0.1:${address.port}`; - - let seenToken: string | null = null; - upstream.on("connection", (ws) => { - ws.on("message", (raw) => { - const parsed = JSON.parse(String(raw)); - if (parsed?.method === "connect") { - seenToken = parsed?.params?.auth?.token ?? null; - ws.send( - JSON.stringify({ - type: "res", - id: parsed.id, - ok: true, - payload: { type: "hello-ok", protocol: 3, auth: {} }, - }) - ); - } - }); - }); - - const { createGatewayProxy } = await import("../../server/gateway-proxy"); - - const proxyHttp = await import("node:http").then((m) => m.createServer()); - const proxy = createGatewayProxy({ - loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), - allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", - logError: () => {}, - }); - proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); - - await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); - const proxyAddr = proxyHttp.address(); - if (!proxyAddr || typeof proxyAddr === "string") { - throw new Error("expected proxy server to have a port"); - } - - const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); - try { - await waitForEvent(browser, "open"); - browser.send( - JSON.stringify({ - type: "req", - id: "connect-pass-token", - method: "connect", - params: { auth: { token: "browser-token-123" } }, - }) - ); - - const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); - const response = JSON.parse(String(rawMessage ?? "")); - expect(response).toMatchObject({ type: "res", id: "connect-pass-token", ok: true }); - expect(seenToken).toBe("browser-token-123"); - } finally { - for (const client of upstream.clients) { - client.close(); - } - await Promise.all([ - closeWebSocket(browser), - closeWebSocketServer(upstream), - closeHttpServer(proxyHttp), - ]); - } - }); - - it("preserves browser auth token when both browser and host tokens are present", async () => { - const upstream = new WebSocketServer({ port: 0 }); - const address = upstream.address(); - if (!address || typeof address === "string") { - throw new Error("expected upstream server to have a port"); - } - const upstreamUrl = `ws://127.0.0.1:${address.port}`; - - let seenToken: string | null = null; - upstream.on("connection", (ws) => { - ws.on("message", (raw) => { - const parsed = JSON.parse(String(raw)); - if (parsed?.method === "connect") { - seenToken = parsed?.params?.auth?.token ?? null; - ws.send( - JSON.stringify({ - type: "res", - id: parsed.id, - ok: true, - payload: { type: "hello-ok", protocol: 3, auth: {} }, - }) - ); - } - }); - }); - - const { createGatewayProxy } = await import("../../server/gateway-proxy"); - - const proxyHttp = await import("node:http").then((m) => m.createServer()); - const proxy = createGatewayProxy({ - loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "host-token-456" }), - allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", - logError: () => {}, - }); - proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); - - await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); - const proxyAddr = proxyHttp.address(); - if (!proxyAddr || typeof proxyAddr === "string") { - throw new Error("expected proxy server to have a port"); - } - - const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); - try { - await waitForEvent(browser, "open"); - browser.send( - JSON.stringify({ - type: "req", - id: "connect-browser-precedence", - method: "connect", - params: { auth: { token: "browser-token-789" } }, - }) - ); - - const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); - const response = JSON.parse(String(rawMessage ?? "")); - expect(response).toMatchObject({ type: "res", id: "connect-browser-precedence", ok: true }); - expect(seenToken).toBe("browser-token-789"); - } finally { - for (const client of upstream.clients) { - client.close(); - } - await Promise.all([ - closeWebSocket(browser), - closeWebSocketServer(upstream), - closeHttpServer(proxyHttp), - ]); - } - }); - - it("allows browser device signature passthrough when host token is missing", async () => { - const upstream = new WebSocketServer({ port: 0 }); - const address = upstream.address(); - if (!address || typeof address === "string") { - throw new Error("expected upstream server to have a port"); - } - const upstreamUrl = `ws://127.0.0.1:${address.port}`; - - let seenToken: string | null = null; - let seenDeviceSignature: string | null = null; - let seenDeviceId: string | null = null; - let seenDevicePublicKey: string | null = null; - let seenDeviceNonce: string | null = null; - let seenDeviceSignedAt: number | null = null; - upstream.on("connection", (ws) => { - ws.on("message", (raw) => { - const parsed = JSON.parse(String(raw)); - if (parsed?.method === "connect") { - seenToken = parsed?.params?.auth?.token ?? null; - seenDeviceSignature = parsed?.params?.device?.signature ?? null; - seenDeviceId = parsed?.params?.device?.id ?? null; - seenDevicePublicKey = parsed?.params?.device?.publicKey ?? null; - seenDeviceNonce = parsed?.params?.device?.nonce ?? null; - seenDeviceSignedAt = parsed?.params?.device?.signedAt ?? null; - ws.send( - JSON.stringify({ - type: "res", - id: parsed.id, - ok: true, - payload: { type: "hello-ok", protocol: 3, auth: {} }, - }) - ); - } - }); - }); - - const { createGatewayProxy } = await import("../../server/gateway-proxy"); - - const proxyHttp = await import("node:http").then((m) => m.createServer()); - const proxy = createGatewayProxy({ - loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), - allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", - logError: () => {}, - }); - proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); - - await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); - const proxyAddr = proxyHttp.address(); - if (!proxyAddr || typeof proxyAddr === "string") { - throw new Error("expected proxy server to have a port"); - } - - const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); - try { - await waitForEvent(browser, "open"); - browser.send( - JSON.stringify({ - type: "req", - id: "connect-pass-device", - method: "connect", - params: { - device: { - id: "device-id-123", - publicKey: "device-public-key-123", - signature: "device-signature-123", - signedAt: Date.now(), - nonce: "device-nonce-123", - }, - }, - }) - ); - - const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); - const response = JSON.parse(String(rawMessage ?? "")); - expect(response).toMatchObject({ type: "res", id: "connect-pass-device", ok: true }); - expect(seenDeviceSignature).toBe("device-signature-123"); - expect(seenDeviceId).toBe("device-id-123"); - expect(seenDevicePublicKey).toBe("device-public-key-123"); - expect(seenDeviceNonce).toBe("device-nonce-123"); - expect(typeof seenDeviceSignedAt).toBe("number"); - expect(seenToken).toBeNull(); - } finally { - for (const client of upstream.clients) { - client.close(); - } - await Promise.all([ - closeWebSocket(browser), - closeWebSocketServer(upstream), - closeHttpServer(proxyHttp), - ]); - } - }); - - it("allows browser password passthrough when host token is missing", async () => { - const upstream = new WebSocketServer({ port: 0 }); - const address = upstream.address(); - if (!address || typeof address === "string") { - throw new Error("expected upstream server to have a port"); - } - const upstreamUrl = `ws://127.0.0.1:${address.port}`; - - let seenPassword: string | null = null; - let seenToken: string | null = null; - upstream.on("connection", (ws) => { - ws.on("message", (raw) => { - const parsed = JSON.parse(String(raw)); - if (parsed?.method === "connect") { - seenPassword = parsed?.params?.auth?.password ?? null; - seenToken = parsed?.params?.auth?.token ?? null; - ws.send( - JSON.stringify({ - type: "res", - id: parsed.id, - ok: true, - payload: { type: "hello-ok", protocol: 3, auth: {} }, - }) - ); - } - }); - }); - - const { createGatewayProxy } = await import("../../server/gateway-proxy"); - - const proxyHttp = await import("node:http").then((m) => m.createServer()); - const proxy = createGatewayProxy({ - loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), - allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", - logError: () => {}, - }); - proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); - - await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); - const proxyAddr = proxyHttp.address(); - if (!proxyAddr || typeof proxyAddr === "string") { - throw new Error("expected proxy server to have a port"); - } - - const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); - try { - await waitForEvent(browser, "open"); - browser.send( - JSON.stringify({ - type: "req", - id: "connect-pass-password", - method: "connect", - params: { auth: { password: "browser-password-123" } }, - }) - ); - - const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); - const response = JSON.parse(String(rawMessage ?? "")); - expect(response).toMatchObject({ type: "res", id: "connect-pass-password", ok: true }); - expect(seenPassword).toBe("browser-password-123"); - expect(seenToken).toBeNull(); - } finally { - for (const client of upstream.clients) { - client.close(); - } - await Promise.all([ - closeWebSocket(browser), - closeWebSocketServer(upstream), - closeHttpServer(proxyHttp), - ]); - } - }); - - it("allows browser deviceToken passthrough when host token is missing", async () => { - const upstream = new WebSocketServer({ port: 0 }); - const address = upstream.address(); - if (!address || typeof address === "string") { - throw new Error("expected upstream server to have a port"); - } - const upstreamUrl = `ws://127.0.0.1:${address.port}`; - - let seenDeviceToken: string | null = null; - let seenToken: string | null = null; - upstream.on("connection", (ws) => { - ws.on("message", (raw) => { - const parsed = JSON.parse(String(raw)); - if (parsed?.method === "connect") { - seenDeviceToken = parsed?.params?.auth?.deviceToken ?? null; - seenToken = parsed?.params?.auth?.token ?? null; - ws.send( - JSON.stringify({ - type: "res", - id: parsed.id, - ok: true, - payload: { type: "hello-ok", protocol: 3, auth: {} }, - }) - ); - } - }); - }); - - const { createGatewayProxy } = await import("../../server/gateway-proxy"); - - const proxyHttp = await import("node:http").then((m) => m.createServer()); - const proxy = createGatewayProxy({ - loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), - allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", - logError: () => {}, - }); - proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); - - await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); - const proxyAddr = proxyHttp.address(); - if (!proxyAddr || typeof proxyAddr === "string") { - throw new Error("expected proxy server to have a port"); - } - - const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); - try { - await waitForEvent(browser, "open"); - browser.send( - JSON.stringify({ - type: "req", - id: "connect-pass-device-token", - method: "connect", - params: { auth: { deviceToken: "browser-device-token-123" } }, - }) - ); - - const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); - const response = JSON.parse(String(rawMessage ?? "")); - expect(response).toMatchObject({ type: "res", id: "connect-pass-device-token", ok: true }); - expect(seenDeviceToken).toBe("browser-device-token-123"); - expect(seenToken).toBeNull(); - } finally { - for (const client of upstream.clients) { - client.close(); - } - await Promise.all([ - closeWebSocket(browser), - closeWebSocketServer(upstream), - closeHttpServer(proxyHttp), - ]); - } - }); - - it("returns studio.gateway_token_missing when browser auth and host token are both missing", async () => { - const upstream = new WebSocketServer({ port: 0 }); - const address = upstream.address(); - if (!address || typeof address === "string") { - throw new Error("expected upstream server to have a port"); - } - const upstreamUrl = `ws://127.0.0.1:${address.port}`; - - let upstreamConnectionCount = 0; - upstream.on("connection", () => { - upstreamConnectionCount += 1; - }); - - const { createGatewayProxy } = await import("../../server/gateway-proxy"); - - const proxyHttp = await import("node:http").then((m) => m.createServer()); - const proxy = createGatewayProxy({ - loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), - allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", - logError: () => {}, - }); - proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); - - await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); - const proxyAddr = proxyHttp.address(); - if (!proxyAddr || typeof proxyAddr === "string") { - throw new Error("expected proxy server to have a port"); - } - - const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); - try { - await waitForEvent(browser, "open"); - const closePromise = waitForEvent<[number, Buffer]>(browser, "close"); - browser.send( - JSON.stringify({ - type: "req", - id: "connect-missing-token", - 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-missing-token", - ok: false, - error: { code: "studio.gateway_token_missing" }, - }); - - const [closeCode] = await closePromise; - expect(closeCode).toBe(1011); - expect(upstreamConnectionCount).toBe(0); - } finally { - for (const client of upstream.clients) { - client.close(); - } - await Promise.all([ - closeWebSocket(browser), - closeWebSocketServer(upstream), - closeHttpServer(proxyHttp), - ]); - } - }); - - it("suppresses expected close-before-open upstream race errors", async () => { - class FakeUpstreamSocket extends EventEmitter { - readyState: number = WebSocket.CONNECTING; - - send() {} - - close() { - this.readyState = WebSocket.CLOSED; - this.emit("error", new Error("WebSocket was closed before the connection was established")); - this.emit("close", { code: 1000, reason: "closed" }); - } - } - - const logError = vi.fn(); - const log = vi.fn(); - let upstreamSocket: FakeUpstreamSocket | null = null; - const { createGatewayProxy } = await import("../../server/gateway-proxy"); - const proxyHttp = await import("node:http").then((m) => m.createServer()); - const proxy = createGatewayProxy({ - loadUpstreamSettings: async () => ({ url: "ws://127.0.0.1:65535", token: "token-123" }), - allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", - log, - logError, - createUpstreamWebSocket: () => { - upstreamSocket = new FakeUpstreamSocket(); - return upstreamSocket; - }, - }); - proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); - await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); - const proxyAddr = proxyHttp.address(); - if (!proxyAddr || typeof proxyAddr === "string") { - throw new Error("expected proxy server to have a port"); - } - - const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); - try { - await waitForEvent(browser, "open"); - browser.send( - JSON.stringify({ - type: "req", - id: "connect-suppress", - method: "connect", - params: { auth: {} }, - }) - ); - await closeWebSocket(browser); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(upstreamSocket).not.toBeNull(); - expect(logError).not.toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith("Suppressed upstream close-before-open race."); - } finally { - await closeHttpServer(proxyHttp); - } - }); - - it("logs and forwards unexpected upstream socket errors", async () => { - class FakeUpstreamSocket extends EventEmitter { - readyState: number = WebSocket.CONNECTING; - - send() {} - - close() { - this.readyState = WebSocket.CLOSED; - this.emit("close", { code: 1000, reason: "closed" }); - } - } - - const logError = vi.fn(); - const upstreamSocket = new FakeUpstreamSocket(); - const { createGatewayProxy } = await import("../../server/gateway-proxy"); - const proxyHttp = await import("node:http").then((m) => m.createServer()); - const proxy = createGatewayProxy({ - loadUpstreamSettings: async () => ({ url: "ws://127.0.0.1:65534", token: "token-123" }), - allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", - logError, - createUpstreamWebSocket: () => upstreamSocket, - }); - proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); - await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); - const proxyAddr = proxyHttp.address(); - if (!proxyAddr || typeof proxyAddr === "string") { - throw new Error("expected proxy server to have a port"); - } - - const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); - try { - await waitForEvent(browser, "open"); - browser.send( - JSON.stringify({ - type: "req", - id: "connect-forward-error", - method: "connect", - params: { auth: {} }, - }) - ); - await new Promise((resolve) => setTimeout(resolve, 0)); - upstreamSocket.emit("error", new Error("socket boom")); - - const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); - const response = JSON.parse(String(rawMessage ?? "")); - expect(response).toMatchObject({ - type: "res", - id: "connect-forward-error", - ok: false, - error: { code: "studio.upstream_error" }, - }); - expect(logError).toHaveBeenCalledTimes(1); - } finally { - await Promise.all([closeWebSocket(browser), closeHttpServer(proxyHttp)]); - } - }); - - it("returns structured upstream error when upstream websocket creation throws", async () => { - const logError = vi.fn(); - const { createGatewayProxy } = await import("../../server/gateway-proxy"); - const proxyHttp = await import("node:http").then((m) => m.createServer()); - const proxy = createGatewayProxy({ - loadUpstreamSettings: async () => ({ url: "ws://127.0.0.1:65534", token: "token-123" }), - allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", - logError, - createUpstreamWebSocket: () => { - throw new Error("constructor failed"); - }, - }); - proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); - await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); - const proxyAddr = proxyHttp.address(); - if (!proxyAddr || typeof proxyAddr === "string") { - throw new Error("expected proxy server to have a port"); - } - - const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); - try { - await waitForEvent(browser, "open"); - browser.send( - JSON.stringify({ - type: "req", - id: "connect-creation-throw", - method: "connect", - params: { auth: {} }, - }) - ); - - const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); - const response = JSON.parse(String(rawMessage ?? "")); - expect(response).toMatchObject({ - type: "res", - id: "connect-creation-throw", - ok: false, - error: { code: "studio.upstream_error" }, - }); - expect(logError).toHaveBeenCalledTimes(1); - } finally { - await Promise.all([closeWebSocket(browser), closeHttpServer(proxyHttp)]); - } - }); -}); diff --git a/tests/unit/gatewayRuntimeEventHandler.chat.test.ts b/tests/unit/gatewayRuntimeEventHandler.chat.test.ts index 48d7619..625fa33 100644 --- a/tests/unit/gatewayRuntimeEventHandler.chat.test.ts +++ b/tests/unit/gatewayRuntimeEventHandler.chat.test.ts @@ -771,6 +771,58 @@ describe("gateway runtime event handler (chat)", () => { expect(requestHistoryRefresh).not.toHaveBeenCalled(); }); + it("applies aborted terminal cleanup even when runId mismatches active run", () => { + const agents = [ + createAgent({ + status: "running", + runId: "run-active", + runStartedAt: 900, + streamText: "still streaming", + thinkingTrace: "t", + }), + ]; + const dispatch = vi.fn(); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch, + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-old", + sessionKey: agents[0]!.sessionKey, + state: "aborted", + message: { role: "assistant", content: "" }, + }, + }); + + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: "appendOutput", agentId: "agent-1", line: "Run aborted." }) + ); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateAgent", + agentId: "agent-1", + patch: expect.objectContaining({ status: "idle", runId: null }), + }) + ); + }); + it("handles aborted/error by appending output and clearing stream fields", () => { const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })]; const dispatch = vi.fn(); diff --git a/tests/unit/mediaMarkdown.test.ts b/tests/unit/mediaMarkdown.test.ts index fb64590..cc06e88 100644 --- a/tests/unit/mediaMarkdown.test.ts +++ b/tests/unit/mediaMarkdown.test.ts @@ -7,7 +7,7 @@ describe("media-markdown", () => { const input = "Hello\nMEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png\nDone"; const out = rewriteMediaLinesToMarkdown(input); - expect(out).toContain("![](/api/gateway/media?path="); + expect(out).toContain("![](/api/runtime/media?path="); expect(out).toContain("MEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png"); expect(out).toContain("Hello"); expect(out).toContain("Done"); @@ -17,7 +17,7 @@ describe("media-markdown", () => { const input = "Hello\nMEDIA:\n/home/ubuntu/.openclaw/workspace-agent/foo.png\nDone"; const out = rewriteMediaLinesToMarkdown(input); - expect(out).toContain("![](/api/gateway/media?path="); + expect(out).toContain("![](/api/runtime/media?path="); expect(out).toContain("MEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png"); expect(out).toContain("Hello"); expect(out).toContain("Done"); diff --git a/tests/unit/openclawAdapter.test.ts b/tests/unit/openclawAdapter.test.ts index b82d1f5..cdf3480 100644 --- a/tests/unit/openclawAdapter.test.ts +++ b/tests/unit/openclawAdapter.test.ts @@ -38,6 +38,7 @@ describe("OpenClawGatewayAdapter", () => { const upstreamUrl = `ws://127.0.0.1:${address.port}`; let observedConnectClientId: string | null = null; let observedConnectClientMode: string | null = null; + let observedConnectCaps: string[] | null = null; upstream.on("connection", (ws) => { ws.send(JSON.stringify({ type: "event", event: "connect.challenge", payload: {} })); @@ -47,11 +48,13 @@ describe("OpenClawGatewayAdapter", () => { method?: string; params?: { client?: { id?: string; mode?: string }; + caps?: string[]; }; }; if (parsed?.method === "connect") { observedConnectClientId = parsed.params?.client?.id ?? null; observedConnectClientMode = parsed.params?.client?.mode ?? null; + observedConnectCaps = Array.isArray(parsed.params?.caps) ? parsed.params.caps : null; ws.send( JSON.stringify({ type: "res", @@ -80,6 +83,7 @@ describe("OpenClawGatewayAdapter", () => { expect(Date.now() - startedAt).toBeLessThan(2_000); expect(observedConnectClientId).toBe("openclaw-control-ui"); expect(observedConnectClientMode).toBe("webchat"); + expect(observedConnectCaps).toEqual(expect.arrayContaining(["tool-events"])); await adapter.stop(); }); diff --git a/tests/unit/runtimeEventPolicy.test.ts b/tests/unit/runtimeEventPolicy.test.ts index 3509b91..31e1121 100644 --- a/tests/unit/runtimeEventPolicy.test.ts +++ b/tests/unit/runtimeEventPolicy.test.ts @@ -176,6 +176,56 @@ describe("runtime event policy", () => { expect(intents).toEqual([{ kind: "ignore", reason: "stale-terminal-event" }]); }); + it("returns_idle_terminal_intents_for_aborted_mismatched_run_when_agent_is_running", () => { + const intents = decideRuntimeChatEvent({ + agentId: "agent-1", + state: "aborted", + runId: "run-old", + role: "assistant", + activeRunId: "run-active", + agentStatus: "running", + now: 2000, + agentRunStartedAt: 900, + nextThinking: null, + nextText: null, + hasThinkingStarted: true, + isClosedRun: false, + isStaleTerminal: false, + shouldRequestHistoryRefresh: false, + shouldUpdateLastResult: false, + shouldSetRunIdle: true, + shouldSetRunError: false, + lastResultText: null, + assistantCompletionAt: null, + shouldQueueLatestUpdate: false, + latestUpdateMessage: null, + }); + + expect(findIntent(intents, "clearPendingLivePatch")).toEqual({ + kind: "clearPendingLivePatch", + agentId: "agent-1", + }); + expect(findIntent(intents, "clearRunTracking")).toEqual({ + kind: "clearRunTracking", + runId: "run-old", + }); + expect(findIntent(intents, "markRunClosed")).toEqual({ + kind: "markRunClosed", + runId: "run-old", + }); + expect(intents).toContainEqual({ + kind: "dispatchUpdateAgent", + agentId: "agent-1", + patch: { + streamText: null, + thinkingTrace: null, + runStartedAt: null, + status: "idle", + runId: null, + }, + }); + }); + it("returns_agent_preflight_intents_for_closed_or_stale_runs", () => { const closed = decideRuntimeAgentEvent({ runId: "run-1", diff --git a/tests/unit/runtimeWriteTransport.test.ts b/tests/unit/runtimeWriteTransport.test.ts index 1619049..d7698f2 100644 --- a/tests/unit/runtimeWriteTransport.test.ts +++ b/tests/unit/runtimeWriteTransport.test.ts @@ -161,6 +161,37 @@ describe("runtimeWriteTransport", () => { expect(mockedPostStudioIntent).not.toHaveBeenCalled(); }); + it("propagates runId on chat-abort when provided", async () => { + const domainCall = vi.fn(async () => ({})); + const domainTransport = createRuntimeWriteTransport({ + client: { call: domainCall } as never, + useDomainIntents: true, + }); + + await domainTransport.chatAbort({ sessionKey: " agent:3 ", runId: " run-3 " }); + + expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/chat-abort", { + sessionKey: "agent:3", + runId: "run-3", + }); + expect(domainCall).not.toHaveBeenCalled(); + + mockedPostStudioIntent.mockReset(); + const gatewayCall = vi.fn(async () => ({})); + const gatewayTransport = createRuntimeWriteTransport({ + client: { call: gatewayCall } as never, + useDomainIntents: false, + }); + + await gatewayTransport.chatAbort({ sessionKey: " agent:4 ", runId: " run-4 " }); + + expect(gatewayCall).toHaveBeenCalledWith("chat.abort", { + sessionKey: "agent:4", + runId: "run-4", + }); + expect(mockedPostStudioIntent).not.toHaveBeenCalled(); + }); + it("routes rename and delete by mode", async () => { const call = vi.fn(async () => ({})); const gatewayTransport = createRuntimeWriteTransport({ diff --git a/tests/unit/sessionKey.test.ts b/tests/unit/sessionKey.test.ts index 2a8c9e8..650184e 100644 --- a/tests/unit/sessionKey.test.ts +++ b/tests/unit/sessionKey.test.ts @@ -4,7 +4,7 @@ import { buildAgentMainSessionKey, isSameSessionKey, parseAgentIdFromSessionKey, -} from "@/lib/gateway/GatewayClient"; +} from "@/lib/gateway/session-keys"; describe("sessionKey helpers", () => { it("buildAgentMainSessionKey formats agent session key", () => { diff --git a/tests/unit/sessionSettings.test.ts b/tests/unit/sessionSettings.test.ts index 7b8980d..b186f85 100644 --- a/tests/unit/sessionSettings.test.ts +++ b/tests/unit/sessionSettings.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { - isWebchatSessionMutationBlockedError, - syncGatewaySessionSettings, -} from "@/lib/gateway/GatewayClient"; import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import { isWebchatSessionMutationBlockedError } from "@/lib/gateway/gateway-disconnect"; +import { syncGatewaySessionSettings } from "@/lib/gateway/session-settings-sync"; import { GatewayResponseError } from "@/lib/gateway/errors"; describe("session settings sync helper", () => { diff --git a/tests/unit/skillsRemoveClient.test.ts b/tests/unit/skillsRemoveClient.test.ts index c56f8a3..b77e5a1 100644 --- a/tests/unit/skillsRemoveClient.test.ts +++ b/tests/unit/skillsRemoveClient.test.ts @@ -30,7 +30,7 @@ describe("skills remove client", () => { managedSkillsDir: " /tmp/managed ", }); - expect(fetchMock).toHaveBeenCalledWith("/api/gateway/skills/remove", { + expect(fetchMock).toHaveBeenCalledWith("/api/intents/skills-remove", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ diff --git a/tests/unit/skillsRemoveRoute.test.ts b/tests/unit/skillsRemoveRoute.test.ts index 800a79d..52865b5 100644 --- a/tests/unit/skillsRemoveRoute.test.ts +++ b/tests/unit/skillsRemoveRoute.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { POST } from "@/app/api/gateway/skills/remove/route"; +import { POST } from "@/app/api/intents/skills-remove/route"; const ORIGINAL_ENV = { ...process.env }; @@ -54,7 +54,7 @@ describe("skills remove route", () => { it("rejects invalid payload", async () => { const response = await POST( - new Request("http://localhost/api/gateway/skills/remove", { + new Request("http://localhost/api/intents/skills-remove", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}), @@ -79,7 +79,7 @@ describe("skills remove route", () => { } as never); const response = await POST( - new Request("http://localhost/api/gateway/skills/remove", { + new Request("http://localhost/api/intents/skills-remove", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -121,7 +121,7 @@ describe("skills remove route", () => { fs.writeFileSync(path.join(skillDir, "SKILL.md"), "# skill", "utf8"); const response = await POST( - new Request("http://localhost/api/gateway/skills/remove", { + new Request("http://localhost/api/intents/skills-remove", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ diff --git a/tests/unit/studioSettingsRoute.test.ts b/tests/unit/studioSettingsRoute.test.ts index e7ed216..cd302ec 100644 --- a/tests/unit/studioSettingsRoute.test.ts +++ b/tests/unit/studioSettingsRoute.test.ts @@ -42,7 +42,7 @@ describe("studio settings route", () => { expect(body.settings?.version).toBe(1); }); - it("GET reports domain mode from server env with STUDIO override precedence", async () => { + it("GET always reports domain mode enabled", async () => { tempDir = makeTempDir("studio-settings-domain-mode"); process.env.OPENCLAW_STATE_DIR = tempDir; process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true"; @@ -51,7 +51,7 @@ describe("studio settings route", () => { const response = await GET(); const body = (await response.json()) as { domainApiModeEnabled?: unknown }; expect(response.status).toBe(200); - expect(body.domainApiModeEnabled).toBe(false); + expect(body.domainApiModeEnabled).toBe(true); }); it("GET returns local gateway defaults from openclaw.json", async () => { diff --git a/tests/unit/useAgentSettingsMutationController.test.ts b/tests/unit/useAgentSettingsMutationController.test.ts index 2b5d494..ad23a0e 100644 --- a/tests/unit/useAgentSettingsMutationController.test.ts +++ b/tests/unit/useAgentSettingsMutationController.test.ts @@ -303,7 +303,7 @@ describe("useAgentSettingsMutationController", () => { expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled(); }); - it("domain_mode_allows_delete_when_browser_gateway_is_disconnected", async () => { + it("domain_mode_delete_is_blocked_when_gateway_is_disconnected", async () => { vi.spyOn(window, "confirm").mockReturnValue(true); mockedRunLifecycle.mockImplementation(async ({ deps }) => { deps.setQueuedBlock(); @@ -320,13 +320,9 @@ describe("useAgentSettingsMutationController", () => { await ctx.getValue().handleDeleteAgent("agent-1"); }); - expect(mockedRunLifecycle).toHaveBeenCalled(); - expect(mockedDeleteAgentViaStudio).toHaveBeenCalledWith( - expect.objectContaining({ - agentId: "agent-1", - runtimeWriteTransport: expect.anything(), - }) - ); + expect(mockedRunLifecycle).not.toHaveBeenCalled(); + expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled(); + expect(ctx.enqueueConfigMutation).not.toHaveBeenCalled(); }); it("delete_cancelled_by_confirmation_does_not_run_delete_side_effect", async () => { diff --git a/tests/unit/useGatewayConfigSyncController.test.ts b/tests/unit/useGatewayConfigSyncController.test.ts index ac57391..25d959a 100644 --- a/tests/unit/useGatewayConfigSyncController.test.ts +++ b/tests/unit/useGatewayConfigSyncController.test.ts @@ -29,6 +29,7 @@ type RenderControllerContext = { rerenderWith: ( overrides: Partial<{ status: "disconnected" | "connecting" | "connected"; + useDomainApiReads: boolean; settingsRouteActive: boolean; inspectSidebarAgentId: string | null; logError: (message: string, err: unknown) => void; @@ -46,6 +47,7 @@ const countMethodCalls = (callMock: ReturnType, method: string) => type RenderControllerParams = { status: "disconnected" | "connecting" | "connected"; + useDomainApiReads: boolean; settingsRouteActive: boolean; inspectSidebarAgentId: string | null; initialGatewayConfigSnapshot?: GatewayModelPolicySnapshot | null; @@ -83,6 +85,7 @@ const renderController = ( let currentParams: RenderControllerParams = { status: "connected" as const, + useDomainApiReads: false, settingsRouteActive: false, inspectSidebarAgentId: null as string | null, isDisconnectLikeError: overrides?.isDisconnectLikeError ?? (() => false), @@ -109,6 +112,7 @@ const renderController = ( const { refreshGatewayConfigSnapshot } = useGatewayConfigSyncController({ client: client as unknown as GatewayClient, status: params.status, + useDomainApiReads: params.useDomainApiReads, settingsRouteActive: params.settingsRouteActive, inspectSidebarAgentId: params.inspectSidebarAgentId, gatewayConfigSnapshot, diff --git a/tests/unit/useGatewayConnection.test.ts b/tests/unit/useGatewayConnection.test.ts deleted file mode 100644 index e0edc74..0000000 --- a/tests/unit/useGatewayConnection.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { createElement } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; - -const ORIGINAL_ENV = { ...process.env }; - -const setupAndImportHook = async (gatewayUrl: string | null) => { - process.env = { ...ORIGINAL_ENV }; - if (gatewayUrl === null) { - delete process.env.NEXT_PUBLIC_GATEWAY_URL; - } else { - process.env.NEXT_PUBLIC_GATEWAY_URL = gatewayUrl; - } - - vi.resetModules(); - vi.spyOn(console, "info").mockImplementation(() => {}); - - const captured: { - url: string | null; - token: unknown; - authScopeKey: unknown; - startCount: number; - stopCount: number; - } = { - url: null, - token: null, - authScopeKey: null, - startCount: 0, - stopCount: 0, - }; - - vi.doMock("../../src/lib/gateway/openclaw/GatewayBrowserClient", () => { - class GatewayBrowserClient { - connected = false; - private opts: { - onHello?: (hello: unknown) => void; - onEvent?: (event: unknown) => void; - onClose?: (info: { code: number; reason: string }) => void; - onGap?: (info: { expected: number; received: number }) => void; - }; - - constructor(opts: Record) { - captured.url = typeof opts.url === "string" ? opts.url : null; - captured.token = "token" in opts ? opts.token : null; - captured.authScopeKey = "authScopeKey" in opts ? opts.authScopeKey : null; - this.opts = { - onHello: typeof opts.onHello === "function" ? (opts.onHello as (hello: unknown) => void) : undefined, - onEvent: typeof opts.onEvent === "function" ? (opts.onEvent as (event: unknown) => void) : undefined, - onClose: typeof opts.onClose === "function" ? (opts.onClose as (info: { code: number; reason: string }) => void) : undefined, - onGap: typeof opts.onGap === "function" ? (opts.onGap as (info: { expected: number; received: number }) => void) : undefined, - }; - } - - start() { - captured.startCount += 1; - this.connected = true; - this.opts.onHello?.({ type: "hello-ok", protocol: 1 }); - } - - stop() { - captured.stopCount += 1; - this.connected = false; - this.opts.onClose?.({ code: 1000, reason: "stopped" }); - } - - async request(method: string, params: unknown): Promise { - void method; - void params; - return {} as T; - } - } - - return { GatewayBrowserClient }; - }); - - const mod = await import("@/lib/gateway/GatewayClient"); - return { - useGatewayConnection: mod.useGatewayConnection as (settingsCoordinator: { - loadSettings: () => Promise; - loadSettingsEnvelope?: () => Promise; - schedulePatch: (patch: unknown) => void; - flushPending: () => Promise; - }) => { - gatewayUrl: string; - token: string; - localGatewayDefaults: { url: string; token: string } | null; - domainApiModeEnabled: boolean | null; - useLocalGatewayDefaults: () => void; - setGatewayUrl: (value: string) => void; - setToken: (value: string) => void; - }, - captured, - }; -}; - -describe("useGatewayConnection", () => { - afterEach(() => { - cleanup(); - process.env = { ...ORIGINAL_ENV }; - vi.resetModules(); - vi.restoreAllMocks(); - }); - - it("defaults_to_env_url_when_set", async () => { - const { useGatewayConnection } = await setupAndImportHook("ws://example.test:1234"); - const coordinator = { - loadSettings: async () => null, - schedulePatch: () => {}, - flushPending: async () => {}, - }; - - const Probe = () => - createElement( - "div", - { "data-testid": "gatewayUrl" }, - useGatewayConnection(coordinator).gatewayUrl - ); - - render(createElement(Probe)); - - await waitFor(() => { - expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("ws://example.test:1234"); - }); - }); - - it("falls_back_to_local_default_when_env_unset", async () => { - const { useGatewayConnection } = await setupAndImportHook(null); - const coordinator = { - loadSettings: async () => null, - schedulePatch: () => {}, - flushPending: async () => {}, - }; - - const Probe = () => - createElement( - "div", - { "data-testid": "gatewayUrl" }, - useGatewayConnection(coordinator).gatewayUrl - ); - - render(createElement(Probe)); - - await waitFor(() => { - expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("ws://localhost:18789"); - }); - }); - - it("connects_via_studio_proxy_ws_and_does_not_pass_token", async () => { - const { useGatewayConnection, captured } = await setupAndImportHook(null); - const coordinator = { - loadSettings: async () => null, - schedulePatch: () => {}, - flushPending: async () => {}, - }; - - const Probe = () => { - useGatewayConnection(coordinator); - return createElement("div", null, "ok"); - }; - - render(createElement(Probe)); - - await waitFor(() => { - expect(captured.url).toBe("ws://localhost:3000/api/gateway/ws"); - }); - expect(captured.token).toBe(""); - expect(captured.authScopeKey).toBe("ws://localhost:18789"); - }); - - it("applies_local_defaults_from_settings_envelope", async () => { - const { useGatewayConnection } = await setupAndImportHook(null); - const coordinator = { - loadSettings: async () => ({ - version: 1, - gateway: null, - focused: {}, - avatars: {}, - }), - loadSettingsEnvelope: async () => ({ - settings: { - version: 1, - gateway: { url: "wss://remote.example", token: "" }, - focused: {}, - avatars: {}, - }, - localGatewayDefaults: { url: "ws://localhost:18789", token: "" }, - domainApiModeEnabled: true, - }), - schedulePatch: () => {}, - flushPending: async () => {}, - }; - - const Probe = () => { - const state = useGatewayConnection(coordinator); - return createElement( - "div", - null, - createElement("div", { "data-testid": "gatewayUrl" }, state.gatewayUrl), - createElement("div", { "data-testid": "token" }, state.token), - createElement( - "div", - { "data-testid": "localDefaultsUrl" }, - state.localGatewayDefaults?.url ?? "" - ), - createElement( - "div", - { "data-testid": "domainApiModeEnabled" }, - state.domainApiModeEnabled === null ? "null" : String(state.domainApiModeEnabled) - ), - createElement( - "button", - { - type: "button", - onClick: state.useLocalGatewayDefaults, - "data-testid": "useLocalDefaults", - }, - "use" - ) - ); - }; - - render(createElement(Probe)); - - await waitFor(() => { - expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("wss://remote.example"); - }); - expect(screen.getByTestId("token")).toHaveTextContent(""); - expect(screen.getByTestId("localDefaultsUrl")).toHaveTextContent("ws://localhost:18789"); - expect(screen.getByTestId("domainApiModeEnabled")).toHaveTextContent("true"); - - fireEvent.click(screen.getByTestId("useLocalDefaults")); - - await waitFor(() => { - expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("ws://localhost:18789"); - }); - expect(screen.getByTestId("token")).toHaveTextContent(""); - }); - - it("does_not_auto_connect_when_domain_mode_is_enabled", async () => { - const { useGatewayConnection, captured } = await setupAndImportHook(null); - const coordinator = { - loadSettings: async () => null, - loadSettingsEnvelope: async () => ({ - settings: { - version: 1, - gateway: { url: "wss://remote.example", token: "" }, - focused: {}, - avatars: {}, - }, - localGatewayDefaults: null, - domainApiModeEnabled: true, - }), - schedulePatch: () => {}, - flushPending: async () => {}, - }; - - const Probe = () => { - const state = useGatewayConnection(coordinator); - return createElement( - "div", - { "data-testid": "domainApiModeEnabled" }, - state.domainApiModeEnabled === null ? "null" : String(state.domainApiModeEnabled) - ); - }; - - render(createElement(Probe)); - - await waitFor(() => { - expect(screen.getByTestId("domainApiModeEnabled")).toHaveTextContent("true"); - }); - expect(captured.url).toBeNull(); - expect(captured.startCount).toBe(0); - expect(captured.stopCount).toBe(0); - }); - - it("disconnects legacy websocket when domain mode flips to enabled", async () => { - const { useGatewayConnection, captured } = await setupAndImportHook(null); - const legacyCoordinator = { - loadSettings: async () => null, - loadSettingsEnvelope: async () => ({ - settings: { - version: 1, - gateway: { url: "wss://legacy.example", token: "" }, - focused: {}, - avatars: {}, - }, - localGatewayDefaults: null, - domainApiModeEnabled: false, - }), - schedulePatch: () => {}, - flushPending: async () => {}, - }; - const domainCoordinator = { - ...legacyCoordinator, - loadSettingsEnvelope: async () => ({ - settings: { - version: 1, - gateway: { url: "wss://legacy.example", token: "" }, - focused: {}, - avatars: {}, - }, - localGatewayDefaults: null, - domainApiModeEnabled: true, - }), - }; - - const Probe = ({ coordinator }: { coordinator: typeof legacyCoordinator }) => { - const state = useGatewayConnection(coordinator); - return createElement( - "div", - { "data-testid": "domainApiModeEnabled" }, - state.domainApiModeEnabled === null ? "null" : String(state.domainApiModeEnabled) - ); - }; - - const view = render(createElement(Probe, { coordinator: legacyCoordinator })); - - await waitFor(() => { - expect(captured.startCount).toBe(1); - }); - expect(captured.stopCount).toBe(0); - - view.rerender(createElement(Probe, { coordinator: domainCoordinator })); - - await waitFor(() => { - expect(screen.getByTestId("domainApiModeEnabled")).toHaveTextContent("true"); - }); - await waitFor(() => { - expect(captured.stopCount).toBeGreaterThanOrEqual(1); - }); - }); - - it("persists gateway url changes without sending token", async () => { - const { useGatewayConnection } = await setupAndImportHook(null); - const schedulePatch = vi.fn(); - const coordinator = { - loadSettings: async () => null, - loadSettingsEnvelope: async () => ({ - settings: { - version: 1, - gateway: { url: "wss://remote.example", token: "" }, - focused: {}, - avatars: {}, - }, - localGatewayDefaults: null, - }), - schedulePatch, - flushPending: async () => {}, - }; - - const Probe = () => { - const state = useGatewayConnection(coordinator); - return createElement( - "div", - null, - createElement("div", { "data-testid": "gatewayUrl" }, state.gatewayUrl), - createElement( - "button", - { - type: "button", - onClick: () => state.setGatewayUrl("wss://remote-next.example"), - "data-testid": "changeUrl", - }, - "change" - ) - ); - }; - - render(createElement(Probe)); - - await waitFor(() => { - expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("wss://remote.example"); - }); - expect(schedulePatch).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByTestId("changeUrl")); - - await waitFor(() => { - expect(schedulePatch).toHaveBeenCalled(); - }); - expect(schedulePatch).toHaveBeenLastCalledWith( - { gateway: { url: "wss://remote-next.example" } }, - 400 - ); - }); - - it("persists token only after explicit token edit", async () => { - const { useGatewayConnection } = await setupAndImportHook(null); - const schedulePatch = vi.fn(); - const coordinator = { - loadSettings: async () => null, - loadSettingsEnvelope: async () => ({ - settings: { - version: 1, - gateway: { url: "wss://remote.example", token: "" }, - focused: {}, - avatars: {}, - }, - localGatewayDefaults: null, - }), - schedulePatch, - flushPending: async () => {}, - }; - - const Probe = () => { - const state = useGatewayConnection(coordinator); - return createElement( - "div", - null, - createElement("div", { "data-testid": "gatewayUrl" }, state.gatewayUrl), - createElement( - "button", - { - type: "button", - onClick: () => state.setToken("manual-token"), - "data-testid": "setToken", - }, - "token" - ) - ); - }; - - render(createElement(Probe)); - - await waitFor(() => { - expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("wss://remote.example"); - }); - expect(schedulePatch).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByTestId("setToken")); - - await waitFor(() => { - expect(schedulePatch).toHaveBeenCalled(); - }); - expect(schedulePatch).toHaveBeenLastCalledWith( - { gateway: { url: "wss://remote.example", token: "manual-token" } }, - 400 - ); - }); -});