mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
big refactor in progress
This commit is contained in:
+76
-189
@@ -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: <CODE> ...`) 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: <CODE> ...`; `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 `<state dir>/openclaw-studio/settings.json` and the WS proxy loads them via `server/studio-settings.js`. State path resolution lives in `lib/clawdbot/paths.ts`, honoring `OPENCLAW_STATE_DIR`. When Studio token is missing, settings loaders can fall back to token/port from `<state dir>/openclaw.json`. Loopback-IP gateway URLs are normalized to `localhost` in Studio settings, and the WS proxy rewrites loopback upstream origins to `localhost` for control-UI secure-context compatibility. The optional Studio access gate is enabled by `STUDIO_ACCESS_TOKEN` (`server/access-gate.js`).
|
||||
- **Runtime durability + startup guard**: domain-mode runtime projection/outbox persistence is stored in `${resolveStateDir()}/openclaw-studio/runtime.db` via `better-sqlite3` (`src/lib/controlplane/projection-store.ts`). Startup scripts (`verify:native-runtime:repair` for `dev` and `verify:native-runtime:check` for `start`) verify native addon compatibility before server boot.
|
||||
- **Testing**: Playwright e2e runs Studio with an isolated `OPENCLAW_STATE_DIR` so the Studio WS proxy does not read real upstream gateway settings from the developer machine.
|
||||
- **Logging**: API routes and the gateway client use built-in `console.*` logging.
|
||||
- **Error handling**:
|
||||
- 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: <CODE> ...` are preserved as `GatewayResponseError` codes so auto-retry gating can be code-driven (instead of message-driven).
|
||||
- Gateway browser client truncates close reasons to WebSocket protocol limits (123 UTF-8 bytes) to avoid client-side close exceptions on long error messages.
|
||||
- **Filesystem helpers**: server-only filesystem operations live at the API route boundaries. 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`.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
+31
-373
@@ -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:<agentId>:<mainKey>` 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: <CODE> <MESSAGE>` 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 | <reasoning stream>`
|
||||
- `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 (``) 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=<n>&beforeOutboxId=<id>`
|
||||
- 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.
|
||||
|
||||
Generated
+4
-10
@@ -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,
|
||||
|
||||
+3
-5
@@ -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",
|
||||
|
||||
@@ -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}.`);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
+16
-41
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown> & { id: string };
|
||||
type GatewayConfigSnapshot = {
|
||||
config?: unknown;
|
||||
hash?: string;
|
||||
exists?: boolean;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const readConfigAgentList = (
|
||||
config: Record<string, unknown> | 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<string, unknown>,
|
||||
list: ConfigAgentEntry[]
|
||||
): Record<string, unknown> => {
|
||||
const agents = isRecord(config.agents) ? { ...config.agents } : {};
|
||||
return { ...config, agents: { ...agents, list } };
|
||||
};
|
||||
|
||||
const upsertConfigAgentEntry = (
|
||||
list: ConfigAgentEntry[],
|
||||
agentId: string,
|
||||
updater: (entry: ConfigAgentEntry) => ConfigAgentEntry
|
||||
): { 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<string, unknown>;
|
||||
agentId: string;
|
||||
mode: AgentSkillsAccessMode;
|
||||
skillNames?: string[];
|
||||
}): Record<string, unknown> => {
|
||||
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<void> => {
|
||||
const attempt = params.attempt ?? 0;
|
||||
const snapshot = await params.runtime.callGateway<GatewayConfigSnapshot>("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<string, unknown> = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown>, key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(value, key);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const bodyOrError = await parseIntentBody(request);
|
||||
if (bodyOrError instanceof Response) {
|
||||
return bodyOrError as NextResponse;
|
||||
}
|
||||
|
||||
const 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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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) } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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", {});
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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", {});
|
||||
}
|
||||
@@ -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) } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<typeof loadStudioSettings>) => {
|
||||
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<typeof loadStudioSettings>,
|
||||
next: ReturnType<typeof loadStudioSettings>
|
||||
) => {
|
||||
const left = normalizeGatewaySettings(previous);
|
||||
const right = normalizeGatewaySettings(next);
|
||||
return left.url !== right.url || left.token !== right.token;
|
||||
};
|
||||
|
||||
const reconnectRuntimeForGatewaySettingsChange = async (
|
||||
previous: ReturnType<typeof loadStudioSettings>,
|
||||
next: ReturnType<typeof loadStudioSettings>
|
||||
): Promise<RuntimeReconnectMetadata | null> => {
|
||||
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);
|
||||
|
||||
+100
-73
@@ -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<unknown> => {
|
||||
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<string>;
|
||||
try {
|
||||
const parsed = JSON.parse(message.data) as {
|
||||
event?: string;
|
||||
payload?: unknown;
|
||||
seq?: number;
|
||||
};
|
||||
if (typeof parsed.event !== "string") return;
|
||||
const frame: EventFrame = {
|
||||
type: "event",
|
||||
event: parsed.event,
|
||||
payload: parsed.payload,
|
||||
...(typeof parsed.seq === "number" ? { seq: parsed.seq } : {}),
|
||||
};
|
||||
handler.handleEvent(frame);
|
||||
handleGatewayEventIngress(frame);
|
||||
} catch {}
|
||||
});
|
||||
stream.addEventListener("runtime.status", () => {
|
||||
void loadSummarySnapshot();
|
||||
});
|
||||
stream.onerror = () => {
|
||||
// EventSource performs automatic reconnect; keep warning low-noise.
|
||||
};
|
||||
} else {
|
||||
unsubscribeGatewayEvents = client.onEvent((event: EventFrame) => {
|
||||
handler.handleEvent(event);
|
||||
handleGatewayEventIngress(event);
|
||||
});
|
||||
}
|
||||
stream = new EventSource("/api/runtime/stream");
|
||||
stream.addEventListener("gateway.event", (raw) => {
|
||||
const message = raw as MessageEvent<string>;
|
||||
try {
|
||||
const parsed = JSON.parse(message.data) as {
|
||||
event?: string;
|
||||
payload?: unknown;
|
||||
seq?: number;
|
||||
};
|
||||
if (typeof parsed.event !== "string") return;
|
||||
const frame: EventFrame = {
|
||||
type: "event",
|
||||
event: parsed.event,
|
||||
payload: parsed.payload,
|
||||
...(typeof parsed.seq === "number" ? { seq: parsed.seq } : {}),
|
||||
};
|
||||
handler.handleEvent(frame);
|
||||
handleGatewayEventIngress(frame);
|
||||
} catch {}
|
||||
});
|
||||
stream.addEventListener("runtime.status", () => {
|
||||
void loadSummarySnapshot();
|
||||
});
|
||||
stream.onerror = () => {
|
||||
// EventSource performs automatic reconnect; keep warning low-noise.
|
||||
};
|
||||
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 = () => {
|
||||
<div className="relative min-h-screen w-screen overflow-hidden bg-background">
|
||||
<div className="relative z-10 flex h-screen flex-col">
|
||||
<HeaderBar
|
||||
status={status}
|
||||
status={gatewayStatus}
|
||||
onConnectionSettings={() => setShowConnectionPanel(true)}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 px-3 pb-3 pt-3 sm:px-4 sm:pb-4 sm:pt-4 md:px-6 md:pb-6 md:pt-4">
|
||||
@@ -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}
|
||||
<div className="relative z-10 flex h-screen flex-col">
|
||||
<HeaderBar
|
||||
status={status}
|
||||
status={gatewayStatus}
|
||||
onConnectionSettings={() => setShowConnectionPanel(true)}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 px-3 pb-3 pt-2 sm:px-4 sm:pb-4 sm:pt-3 md:px-5 md:pb-5 md:pt-3">
|
||||
@@ -1457,7 +1478,7 @@ const AgentStudioPage = () => {
|
||||
<ConnectionPanel
|
||||
gatewayUrl={gatewayUrl}
|
||||
token={token}
|
||||
status={status}
|
||||
status={gatewayStatus}
|
||||
error={gatewayError}
|
||||
onGatewayUrlChange={setGatewayUrl}
|
||||
onTokenChange={setToken}
|
||||
@@ -1557,7 +1578,7 @@ const AgentStudioPage = () => {
|
||||
{inspectSidebarAgent ? (
|
||||
effectiveSettingsTab === "personality" ? (
|
||||
<AgentBrainPanel
|
||||
client={client}
|
||||
gatewayStatus={gatewayStatus}
|
||||
agents={agents}
|
||||
selectedAgentId={inspectSidebarAgent.agentId}
|
||||
onUnsavedChangesChange={setPersonalityHasUnsavedChanges}
|
||||
@@ -1707,7 +1728,7 @@ const AgentStudioPage = () => {
|
||||
onCreateAgent={() => {
|
||||
handleOpenCreateAgentModal();
|
||||
}}
|
||||
createDisabled={!coreConnected || createAgentBusy || state.loading}
|
||||
createDisabled={!gatewayConnected || createAgentBusy || state.loading}
|
||||
createBusy={createAgentBusy}
|
||||
onSelectAgent={handleFleetSelectAgent}
|
||||
/>
|
||||
@@ -1722,7 +1743,7 @@ const AgentStudioPage = () => {
|
||||
<AgentChatPanel
|
||||
agent={focusedAgent}
|
||||
isSelected={false}
|
||||
canSend={coreConnected}
|
||||
canSend={gatewayConnected}
|
||||
models={gatewayModels}
|
||||
stopBusy={stopBusyAgentId === focusedAgent.agentId}
|
||||
stopDisabledReason={focusedAgentStopDisabledReason}
|
||||
@@ -1751,7 +1772,13 @@ 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."
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<AgentStatus, string> = {
|
||||
idle: "Idle",
|
||||
|
||||
@@ -13,6 +13,20 @@ type GatewayClientLike = {
|
||||
call: (method: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const callGateway = async <T>(
|
||||
client: GatewayClientLike,
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<T> => {
|
||||
const invoke = (
|
||||
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
|
||||
).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<GatewayModelPolicySnapshot>(
|
||||
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<ExecApprovalsSnapshot>(
|
||||
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<AgentsListResult>(params.client, "agents.list", {});
|
||||
const mainKey = agentsResult.mainKey?.trim() || "main";
|
||||
|
||||
const mainSessionKeyByAgent = new Map<string, SessionsListEntry | null>();
|
||||
@@ -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<SessionsListResult>(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<SummaryStatusSnapshot>,
|
||||
params.client.call("sessions.preview", {
|
||||
callGateway<SummaryStatusSnapshot>(params.client, "status", {}),
|
||||
callGateway<SummaryPreviewSnapshot>(params.client, "sessions.preview", {
|
||||
keys: sessionKeys,
|
||||
limit: 8,
|
||||
maxChars: 240,
|
||||
}) as Promise<SummaryPreviewSnapshot>,
|
||||
}),
|
||||
]);
|
||||
statusSummary = snapshot[0] ?? null;
|
||||
previewResult = snapshot[1] ?? null;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,6 +9,20 @@ type GatewayClientLike = {
|
||||
call: (method: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const callGateway = async <T>(
|
||||
client: GatewayClientLike,
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<T> => {
|
||||
const invoke = (
|
||||
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
|
||||
).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<AgentState> }
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -34,10 +34,14 @@ type CronBusyState = {
|
||||
type CronCreateDeps = {
|
||||
buildInput?: (agentId: string, draft: CronCreateDraft) => CronJobCreateInput;
|
||||
createCronJob?: (client: GatewayClient, input: CronJobCreateInput) => Promise<unknown>;
|
||||
createCronJobForInput?: (input: CronJobCreateInput) => Promise<unknown>;
|
||||
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";
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<unknown>;
|
||||
agentCreate: (params: { name: string }) => Promise<{ id: string; name: string }>;
|
||||
chatAbort: (params: { sessionKey: string }) => Promise<void>;
|
||||
chatAbort: (params: { sessionKey: string; runId?: string }) => Promise<void>;
|
||||
sessionsReset: (params: { key: string }) => Promise<void>;
|
||||
agentRename: (params: { agentId: string; name: string }) => Promise<void>;
|
||||
agentDelete: (params: { agentId: string }) => Promise<void>;
|
||||
@@ -53,6 +53,20 @@ const requireNonEmpty = (value: string, fieldLabel: string): string => {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const callLegacyGateway = async <T>(
|
||||
client: GatewayClient,
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<T> => {
|
||||
const invoke = (
|
||||
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
|
||||
).call;
|
||||
if (typeof invoke !== "function") {
|
||||
throw new Error("Legacy gateway client call transport is unavailable.");
|
||||
}
|
||||
return (await invoke(method, params)) as T;
|
||||
};
|
||||
|
||||
const unwrapIntentPayload = <T>(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<unknown>(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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> =>
|
||||
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<string, unknown>).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<SkillStatusReport | null>(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 {
|
||||
|
||||
@@ -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<void>;
|
||||
removeQueuedMessage: (agentId: string, index: number) => void;
|
||||
handleNewSession: (agentId: string) => Promise<void>;
|
||||
handleStopRun: (agentId: string, sessionKey: string) => Promise<void>;
|
||||
handleStopRun: (agentId: string, sessionKey: string, runId?: string | null) => Promise<void>;
|
||||
queueLivePatch: (agentId: string, patch: Partial<AgentState>) => 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.";
|
||||
|
||||
@@ -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<GatewayModelPolicySnapshot>("config.get", {});
|
||||
const snapshot = useDomainApiReads
|
||||
? await loadDomainConfigSnapshot()
|
||||
: await client.call<GatewayModelPolicySnapshot>("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<GatewayModelPolicySnapshot>("config.get", {});
|
||||
configSnapshot = useDomainApiReads
|
||||
? await loadDomainConfigSnapshot()
|
||||
: await client.call<GatewayModelPolicySnapshot>("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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<T> = {
|
||||
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<string, unknown>[];
|
||||
};
|
||||
|
||||
const unwrapPayload = <T>(result: Envelope<T>): 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<GatewayModelPolicySnapshot> => {
|
||||
const result = await fetchJson<Envelope<GatewayModelPolicySnapshot>>("/api/runtime/config", {
|
||||
cache: "no-store",
|
||||
});
|
||||
return unwrapPayload(result);
|
||||
};
|
||||
|
||||
export const loadDomainModels = async (): Promise<GatewayModelChoice[]> => {
|
||||
const result = await fetchJson<Envelope<{ models?: GatewayModelChoice[] }>>("/api/runtime/models", {
|
||||
cache: "no-store",
|
||||
});
|
||||
const payload = unwrapPayload(result);
|
||||
return Array.isArray(payload.models) ? payload.models : [];
|
||||
};
|
||||
|
||||
export const loadDomainSkillStatus = async (agentId: string): Promise<SkillStatusReport> => {
|
||||
const encodedAgentId = encodeURIComponent(agentId.trim());
|
||||
const result = await fetchJson<Envelope<SkillStatusReport>>(
|
||||
`/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<Envelope<{ ok: boolean; message: string; stdout: string; stderr: string; code: number | null; warnings?: string[] }>>(
|
||||
"/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<string, unknown> }> => {
|
||||
const result = await fetchJson<Envelope<{ ok: boolean; skillKey: string; config: Record<string, unknown> }>>(
|
||||
"/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<void> => {
|
||||
const result = await fetchJson<Envelope<{ updated: boolean }>>("/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<Envelope<{ jobs?: CronJobSummary[] }>>(
|
||||
`/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<CronJobSummary> => {
|
||||
const result = await fetchJson<Envelope<CronJobSummary>>("/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<CronRunResult> => {
|
||||
const result = await fetchJson<Envelope<CronRunResult>>("/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<Envelope<{ ok: true; removed: boolean } | { ok: false; removed: false }>>(
|
||||
"/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<Envelope<{ file?: { missing?: unknown; content?: unknown } }>>(
|
||||
`/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<string, unknown>) : 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<void> => {
|
||||
const result = await fetchJson<Envelope<unknown>>("/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<SessionsListResult> => {
|
||||
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<Envelope<SessionsListResult>>(`/api/runtime/sessions?${query.toString()}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
return unwrapPayload(result);
|
||||
};
|
||||
|
||||
export const loadDomainChatHistory = async (params: {
|
||||
sessionKey: string;
|
||||
limit?: number;
|
||||
}): Promise<ChatHistoryResult> => {
|
||||
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<Envelope<ChatHistoryResult>>(
|
||||
`/api/runtime/chat-history?${query.toString()}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
return unwrapPayload(result);
|
||||
};
|
||||
@@ -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<string>([
|
||||
"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 },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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<BindParams extends unknown[] = unknown[], Result = unknown> =
|
||||
BetterSqlite3.Statement<BindParams, Result>;
|
||||
|
||||
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();
|
||||
|
||||
@@ -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 <T>(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<T>(method, params);
|
||||
return NextResponse.json({ ok: true, payload });
|
||||
} catch (error) {
|
||||
return mapGatewayError(error);
|
||||
}
|
||||
};
|
||||
@@ -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<void> {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export type CronPayload =
|
||||
bestEffortDeliver?: boolean;
|
||||
};
|
||||
|
||||
export type CronJobState = {
|
||||
type CronJobState = {
|
||||
nextRunAtMs?: number;
|
||||
runningAtMs?: number;
|
||||
lastRunAtMs?: number;
|
||||
|
||||
@@ -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<StatusHandler>();
|
||||
private eventHandlers = new Set<EventHandler>();
|
||||
private gapHandlers = new Set<GapHandler>();
|
||||
private status: GatewayStatus = "disconnected";
|
||||
private pendingConnect: Promise<void> | 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<void>((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<T = unknown>(method: string, params: unknown): Promise<T> {
|
||||
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<T>(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<GatewaySessionsPatchResult>("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<void>;
|
||||
disconnect: () => void;
|
||||
useLocalGatewayDefaults: () => void;
|
||||
setGatewayUrl: (value: string) => void;
|
||||
setToken: (value: string) => void;
|
||||
clearError: () => void;
|
||||
};
|
||||
|
||||
type StudioSettingsCoordinatorLike = {
|
||||
loadSettings: () => Promise<StudioSettings | null>;
|
||||
loadSettingsEnvelope?: () => Promise<StudioSettingsResponse>;
|
||||
schedulePatch: (patch: StudioSettingsPatch, debounceMs?: number) => void;
|
||||
flushPending: () => Promise<void>;
|
||||
};
|
||||
|
||||
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<ReturnType<typeof setTimeout> | 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<StudioGatewaySettings | null>(
|
||||
null
|
||||
);
|
||||
const [domainApiModeEnabled, setDomainApiModeEnabled] = useState<boolean | null>(null);
|
||||
const [status, setStatus] = useState<GatewayStatus>("disconnected");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectErrorCode, setConnectErrorCode] = useState<string | null>(null);
|
||||
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: <T = unknown>(method: string, params: unknown) => Promise<T>;
|
||||
onEvent?: (handler: (event: EventFrame) => void) => () => void;
|
||||
onGap?: (handler: (info: GatewayGapInfo) => void) => () => void;
|
||||
};
|
||||
|
||||
@@ -53,6 +53,20 @@ const DEFAULT_ACK_MAX_CHARS = 300;
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const callGateway = async <T>(
|
||||
client: GatewayClient,
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<T> => {
|
||||
const invoke = (
|
||||
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
|
||||
).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<string, unknown> & { id: string };
|
||||
|
||||
type GatewayAgentSandboxOverrides = {
|
||||
@@ -272,8 +286,8 @@ export const listHeartbeatsForAgent = async (
|
||||
): Promise<HeartbeatListResult> => {
|
||||
const resolvedAgentId = resolveHeartbeatAgentId(agentId);
|
||||
const [snapshot, status] = await Promise.all([
|
||||
client.call<GatewayConfigSnapshot>("config.get", {}),
|
||||
client.call<GatewayStatusSnapshot>("status", {}),
|
||||
callGateway<GatewayConfigSnapshot>(client, "config.get", {}),
|
||||
callGateway<GatewayStatusSnapshot>(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<HeartbeatWakeResult> => {
|
||||
const resolvedAgentId = resolveHeartbeatAgentId(agentId);
|
||||
return client.call<HeartbeatWakeResult>("wake", {
|
||||
return callGateway<HeartbeatWakeResult>(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<GatewayConfigSnapshot>("config.get", {});
|
||||
const snapshot = await callGateway<GatewayConfigSnapshot>(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<GatewayConfigSnapshot>("config.get", {});
|
||||
const snapshot = await callGateway<GatewayConfigSnapshot>(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<AgentHeartbeatResult> => {
|
||||
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
|
||||
const snapshot = await callGateway<GatewayConfigSnapshot>(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<AgentHeartbeatResult> => {
|
||||
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
|
||||
const snapshot = await callGateway<GatewayConfigSnapshot>(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<string[] | undefined> => {
|
||||
const agentId = resolveRequiredAgentId(params.agentId);
|
||||
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
|
||||
const snapshot = await callGateway<GatewayConfigSnapshot>(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<void> => {
|
||||
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
|
||||
const snapshot = await callGateway<GatewayConfigSnapshot>(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<void> => {
|
||||
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
|
||||
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
|
||||
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
|
||||
const nextConfig = buildNextConfig(baseConfig);
|
||||
const payload: Record<string, unknown> = {
|
||||
@@ -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);
|
||||
|
||||
@@ -5,6 +5,20 @@ type AgentsFilesGetResponse = {
|
||||
file?: { missing?: unknown; content?: unknown };
|
||||
};
|
||||
|
||||
const callGateway = async <T>(
|
||||
client: GatewayClient,
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<T> => {
|
||||
const invoke = (
|
||||
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
|
||||
).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<AgentsFilesGetResponse>("agents.files.get", {
|
||||
const response = await callGateway<AgentsFilesGetResponse>(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<void> => {
|
||||
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,
|
||||
|
||||
@@ -41,6 +41,20 @@ type ExecApprovalsSnapshot = {
|
||||
file?: ExecApprovalsFile;
|
||||
};
|
||||
|
||||
const callGateway = async <T>(
|
||||
client: GatewayClient,
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<T> => {
|
||||
const invoke = (
|
||||
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
|
||||
).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<string, unknown> = { 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<ExecApprovalsSnapshot>("exec.approvals.get", {});
|
||||
const snapshot = await callGateway<ExecApprovalsSnapshot>(
|
||||
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<ExecApprovalsSnapshot>("exec.approvals.get", {});
|
||||
const snapshot = await callGateway<ExecApprovalsSnapshot>(
|
||||
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<ExecApprovalsSnapshot>("exec.approvals.get", {});
|
||||
const snapshot = await callGateway<ExecApprovalsSnapshot>(
|
||||
params.client,
|
||||
"exec.approvals.get",
|
||||
{}
|
||||
);
|
||||
const entry = snapshot.file?.agents?.[agentId];
|
||||
if (!entry) return null;
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type GatewayStatus = "disconnected" | "connecting" | "connected";
|
||||
|
||||
export type GatewayGapInfo = {
|
||||
expected: number;
|
||||
received: number;
|
||||
};
|
||||
@@ -10,6 +10,20 @@ type GatewayConfigSnapshot = {
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const callGateway = async <T>(
|
||||
client: GatewayClient,
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<T> => {
|
||||
const invoke = (
|
||||
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
|
||||
).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<GatewayConfigSnapshot>("config.get", {});
|
||||
const snapshot = await callGateway<GatewayConfigSnapshot>(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<void> => {
|
||||
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
|
||||
const snapshot = await callGateway<GatewayConfigSnapshot>(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);
|
||||
|
||||
@@ -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<string, DeviceAuthEntry>;
|
||||
};
|
||||
|
||||
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<string>();
|
||||
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<string> {
|
||||
const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(publicKey));
|
||||
return bytesToHex(new Uint8Array(hash));
|
||||
}
|
||||
|
||||
async function generateIdentity(): Promise<DeviceIdentity> {
|
||||
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<DeviceIdentity> {
|
||||
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<string, Pending>();
|
||||
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<ReturnType<typeof loadOrCreateDeviceIdentity>> | 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<GatewayHelloOk>("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<T = unknown>(method: string, params?: unknown): Promise<T> {
|
||||
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<T>((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);
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
};
|
||||
|
||||
@@ -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: <T = unknown>(method: string, params: unknown) => Promise<T>;
|
||||
};
|
||||
|
||||
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<GatewaySessionsPatchResult>("sessions.patch", payload);
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<StudioSettings | null>;
|
||||
loadSettingsEnvelope?: () => Promise<StudioSettingsResponse>;
|
||||
schedulePatch: (patch: StudioSettingsPatch, debounceMs?: number) => void;
|
||||
flushPending: () => Promise<void>;
|
||||
};
|
||||
|
||||
type StudioGatewaySettingsState = {
|
||||
client: GatewayClient;
|
||||
status: GatewayStatus;
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
localGatewayDefaults: StudioGatewaySettings | null;
|
||||
domainApiModeEnabled: boolean;
|
||||
error: string | null;
|
||||
connect: () => Promise<void>;
|
||||
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<StudioGatewaySettings | null>(
|
||||
null
|
||||
);
|
||||
const domainApiModeEnabled = true;
|
||||
const [status, setStatus] = useState<GatewayStatus>("disconnected");
|
||||
const [error, setError] = useState<string | null>(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<RuntimeSummaryEnvelope>("/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,
|
||||
]
|
||||
);
|
||||
};
|
||||
@@ -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)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, unknown> | undefined;
|
||||
|
||||
@@ -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<string, Record<string, string>> = {
|
||||
"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<string, Record<string, string>> = {
|
||||
"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<string, unknown>) : {};
|
||||
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<string, unknown>) : {};
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | null = null;
|
||||
|
||||
vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => {
|
||||
class GatewayBrowserClient {
|
||||
connected = false;
|
||||
constructor(opts: Record<string, unknown>) {
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
let lastOpts: Record<string, unknown> | null = null;
|
||||
|
||||
vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => {
|
||||
class GatewayBrowserClient {
|
||||
connected = true;
|
||||
constructor(opts: Record<string, unknown>) {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = <T = unknown>(
|
||||
target: { once: (event: string, cb: (...args: unknown[]) => void) => void },
|
||||
event: string
|
||||
) =>
|
||||
new Promise<T>((resolve) => {
|
||||
target.once(event, (...args: unknown[]) => resolve(args as unknown as T));
|
||||
});
|
||||
|
||||
const closeHttpServer = (server: import("node:http").Server) =>
|
||||
new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
|
||||
const closeWebSocketServer = (server: WebSocketServer) =>
|
||||
new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
|
||||
const closeWebSocket = (ws: WebSocket) =>
|
||||
new Promise<void>((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<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-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<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-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<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-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<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-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<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-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<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-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<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
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<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-suppress",
|
||||
method: "connect",
|
||||
params: { auth: {} },
|
||||
})
|
||||
);
|
||||
await closeWebSocket(browser);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(upstreamSocket).not.toBeNull();
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
expect(log).toHaveBeenCalledWith("Suppressed upstream close-before-open race.");
|
||||
} finally {
|
||||
await closeHttpServer(proxyHttp);
|
||||
}
|
||||
});
|
||||
|
||||
it("logs and forwards unexpected upstream socket errors", async () => {
|
||||
class FakeUpstreamSocket extends EventEmitter {
|
||||
readyState: number = WebSocket.CONNECTING;
|
||||
|
||||
send() {}
|
||||
|
||||
close() {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
this.emit("close", { code: 1000, reason: "closed" });
|
||||
}
|
||||
}
|
||||
|
||||
const logError = vi.fn();
|
||||
const upstreamSocket = new FakeUpstreamSocket();
|
||||
const { createGatewayProxy } = await import("../../server/gateway-proxy");
|
||||
const proxyHttp = await import("node:http").then((m) => m.createServer());
|
||||
const proxy = createGatewayProxy({
|
||||
loadUpstreamSettings: async () => ({ url: "ws://127.0.0.1:65534", token: "token-123" }),
|
||||
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
|
||||
logError,
|
||||
createUpstreamWebSocket: () => upstreamSocket,
|
||||
});
|
||||
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
|
||||
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-forward-error",
|
||||
method: "connect",
|
||||
params: { auth: {} },
|
||||
})
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
upstreamSocket.emit("error", new Error("socket boom"));
|
||||
|
||||
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
|
||||
const response = JSON.parse(String(rawMessage ?? ""));
|
||||
expect(response).toMatchObject({
|
||||
type: "res",
|
||||
id: "connect-forward-error",
|
||||
ok: false,
|
||||
error: { code: "studio.upstream_error" },
|
||||
});
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await Promise.all([closeWebSocket(browser), closeHttpServer(proxyHttp)]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns structured upstream error when upstream websocket creation throws", async () => {
|
||||
const logError = vi.fn();
|
||||
const { createGatewayProxy } = await import("../../server/gateway-proxy");
|
||||
const proxyHttp = await import("node:http").then((m) => m.createServer());
|
||||
const proxy = createGatewayProxy({
|
||||
loadUpstreamSettings: async () => ({ url: "ws://127.0.0.1:65534", token: "token-123" }),
|
||||
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
|
||||
logError,
|
||||
createUpstreamWebSocket: () => {
|
||||
throw new Error("constructor failed");
|
||||
},
|
||||
});
|
||||
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
|
||||
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-creation-throw",
|
||||
method: "connect",
|
||||
params: { auth: {} },
|
||||
})
|
||||
);
|
||||
|
||||
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
|
||||
const response = JSON.parse(String(rawMessage ?? ""));
|
||||
expect(response).toMatchObject({
|
||||
type: "res",
|
||||
id: "connect-creation-throw",
|
||||
ok: false,
|
||||
error: { code: "studio.upstream_error" },
|
||||
});
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await Promise.all([closeWebSocket(browser), closeHttpServer(proxyHttp)]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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(";
|
||||
expect(out).toContain(";
|
||||
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(";
|
||||
expect(out).toContain(";
|
||||
expect(out).toContain("MEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png");
|
||||
expect(out).toContain("Hello");
|
||||
expect(out).toContain("Done");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user