diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00021f4..f6244cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,5 +14,5 @@ Keep changes scoped to the bare package set: - `packages/codex-client` - `packages/ui` -Avoid reintroducing service, workspace, gateway, job, or host setup code on this +Avoid reintroducing service, workspace backend, job, or host setup code on this branch. diff --git a/README.md b/README.md index 94a8c01..884a6bd 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ Thin browser UI plus TypeScript client for `codex app-server`. The current source is: -- `apps/web`: React/Vite UI that connects directly to a Codex app-server WebSocket. +- `apps/web`: React/Vite UI that connects through the workspace backend. - `apps/cli`: Bun CLI that sends JSON-RPC actions to a listening Codex app-server. - `apps/discord-bridge`: Discord sidecar that connects Discord threads to - Codex app-server threads and gateway delegation. + Codex app-server threads through the workspace backend capability model. - `apps/flow-runner`: CLI for discovering and firing packaged flows. -- `apps/flow-backend-systemd-local`: local HTTP backend for executing flows from dispatch events. +- `apps/workspace-backend`: local workspace backend process with browser/control + WebSocket and optional flow HTTP routes. - `docs`: Tome documentation site for codex-flow. - `packages/codex-client`: JSON-RPC client, app-server transports, flow helpers, and generated protocol types. - `packages/flow-runtime`: flow manifest loading, event matching, and runner primitives. @@ -23,10 +24,11 @@ Install dependencies: bun install ``` -Start a Codex app-server WebSocket in a separate shell: +Start the local workspace backend in a separate shell. It can spawn a local +stdio app-server: ```bash -codex app-server --listen ws://127.0.0.1:3585 --enable apps --enable hooks +bun run workspace:backend --local-app-server ``` Start the web app: @@ -36,15 +38,14 @@ bun run dev ``` In development, the web app defaults to a same-origin Vite WebSocket proxy at -`/__codex-app-server`, which forwards to `ws://127.0.0.1:3585`. This avoids -browser `Origin` header rejections from the app-server, which can show up in -WSL and other browser-to-localhost setups. +`/__codex-workspace-backend`, which forwards to `ws://127.0.0.1:3586`. -Set `VITE_CODEX_APP_SERVER_PROXY_TARGET` to proxy to a different app-server -URL. Set `VITE_CODEX_APP_SERVER_WS_URL` only when you explicitly want the -browser to connect directly to an app-server WebSocket. +Set `VITE_CODEX_WORKSPACE_BACKEND_PROXY_TARGET` to proxy to a different +workspace backend URL. Set `VITE_CODEX_WORKSPACE_BACKEND_WS_URL` only when you +explicitly want the browser to connect directly to a workspace backend +WebSocket. -Send a command to the running app-server: +Send a command to a standalone app-server WebSocket: ```bash bun apps/cli/src/index.ts thread/list '{"limit": 20, "sourceKinds": []}' @@ -64,15 +65,15 @@ bun run build bun run test ``` -`bun run test` runs the client, flow runtime, local flow backend, CLI, and -Discord bridge tests. +`bun run test` runs the client, flow runtime, workspace backend, CLI, Discord +bridge, and web tests. ## Flow Automation Flow packages live under `flows/*` and installed copies can live under `.codex/flows/*`. The publishable Tome docs live in [docs](docs) and cover `flow.toml`, generic `FlowEvent` dispatch, Bun and Code Mode runners, local -clients, systemd-local, and Convex backends. +clients, the workspace flow backend, and Convex backends. ```bash bun run flow list @@ -172,6 +173,8 @@ The low-level app-server client package. It exports: - `@peezy.tech/codex-flows/browser`: browser entry with WebSocket transport only. - `@peezy.tech/codex-flows/flows`: framework-agnostic helpers for app servers that want to start Codex-backed workflows. - `@peezy.tech/codex-flows/workbench`: transport-neutral thread UX state reducers and app-server request descriptors. +- `@peezy.tech/codex-flows/workspace-backend`: workspace backend client, + protocol server, and built-in capability primitives. - `@peezy.tech/codex-flows/rpc`: JSON-RPC helpers and types. - `@peezy.tech/codex-flows/generated`: generated Codex app-server protocol types. @@ -188,11 +191,14 @@ OpenCode Go upstream surface. See CLI package for listing flow packages, firing every step that matches a `FlowEvent`, or running one explicit flow step. -### `flow-backend-systemd-local` +### `codex-workspace-backend-local` -HTTP and CLI backend that persists dispatched flow events/runs to SQLite and -starts matching steps locally. It is intended to run as a small systemd-managed -service, with optional transient `systemd-run` units per step. +Local workspace backend process. In networked mode, it exposes the workspace +backend browser/control WebSocket and mounts the existing flow HTTP routes. The +same flow execution and inspection behavior is a built-in workspace capability +that embedded presenters can call directly without HTTP. Flow state is persisted +to SQLite, and matching steps can run directly or through transient +`systemd-run` units. ### `@peezy.tech/flow-runtime` @@ -221,9 +227,10 @@ and expose their own authenticated wrappers for service workers. ### `web` -The browser app imports `@peezy.tech/codex-flows/browser`, opens a direct WebSocket -connection, lists threads, starts turns, interrupts running turns, and renders -thread items and live app-server events. +The browser app imports `@peezy.tech/codex-flows/workspace-backend`, opens a +workspace backend WebSocket, lists threads, starts turns, interrupts running +turns, and renders thread items and live app-server events forwarded through the +workspace backend. ### `cli` diff --git a/apps/discord-bridge/README.md b/apps/discord-bridge/README.md index 23fce0e..8c43539 100644 --- a/apps/discord-bridge/README.md +++ b/apps/discord-bridge/README.md @@ -2,12 +2,12 @@ Long-lived Discord sidecar for connecting Discord to Codex app-server threads. -## Gateway Mode +## Workspace Mode -Gateway mode is opt-in. It keeps one Discord home surface as the primary UX, or +Workspace mode is opt-in. It keeps one Discord home surface as the primary UX, or several guild-scoped surfaces when multi-guild routing is configured, and one -main Codex thread as the operator memory for the gateway. Legacy -thread-per-task behavior remains available outside the configured gateway +main Codex thread as the operator memory for the workspace. Legacy +thread-per-task behavior remains available outside the configured workspace channels. Set these environment values before starting the bridge: @@ -31,7 +31,7 @@ top-level workspace under it: ```toml # /home/peezy/crypto-workspace/.codex/workspace.toml -[[discord.gateway.surfaces]] +[[discord.workspace.surfaces]] key = "crypto" home_channel_id = "1503107617512919220" workspace_forum_channel_id = "1503107617512919221" @@ -44,20 +44,20 @@ workspace is the route. Workspaces without a Discord surface entry use the default `.env` surface. If multiple workspaces name the same surface key with the same channel ids, they are merged into one guild surface. Surface keys and channel ids must be unique, and each workspace file may contain at most one -`[[discord.gateway.surfaces]]` entry. +`[[discord.workspace.surfaces]]` entry. `CODEX_DISCORD_MAIN_THREAD_ID` is optional. If omitted, the bridge creates a new -main operator thread, attaches the privileged gateway tools to it, and stores it +main operator thread, attaches the privileged workspace tools to it, and stores it in the bridge state file. Existing configured main threads are resumed as-is; -recreate the main operator thread if you need to attach gateway tools to a -thread that predates gateway mode. +recreate the main operator thread if you need to attach workspace tools to a +thread that predates workspace mode. In each configured home channel: - normal messages are sent to the main operator thread -- bot mentions are treated as gateway messages and do not create Discord task +- bot mentions are treated as workspace messages and do not create Discord task threads -- `/status` replies directly with gateway state instead of starting a Codex turn +- `/status` replies directly with workspace state instead of starting a Codex turn - `/status` also lists active Codex threads, linking any opened Discord thread on the same surface and offering private buttons to open active threads that are not yet in Discord @@ -66,14 +66,14 @@ In each configured home channel: - `/goals` inside an opened Codex Discord thread manages that specific thread's goal; use the slash options to set the objective/status/token budget or clear it -The prompt sent to the main thread uses `[discord-gateway]` framing so the model -knows it is operating as the gateway over the codex-flows backend, not as a +The prompt sent to the main thread uses `[discord-workspace]` framing so the model +knows it is operating as the workspace over the codex-flows backend, not as a single task thread. ## Delegation Tools Discord should not become a workspace registry. The main operator thread is the -place where routing decisions happen. Privileged `codex_gateway` dynamic tools +place where routing decisions happen. Privileged `codex_workspace` dynamic tools are attached only to that main thread and expose: - `list_delegations` @@ -98,15 +98,15 @@ Those tools can: - record completed delegation results into the main operator thread - inspect flow backend state through `CODEX_FLOW_BACKEND_URL` -Gateway state stores delegation records, including optional Discord detail +Workspace state stores delegation records, including optional Discord detail thread ids for noisy work. Delegated Codex sessions do not receive the privileged -gateway tools; only the main operator thread can manage delegation. +workspace tools; only the main operator thread can manage delegation. ## Workbench Prototype -The gateway can optionally maintain a noisy Discord workbench beside each home +The workspace can optionally maintain a noisy Discord workbench beside each home channel. Configure both channels on the default `.env` surface, or on a -workspace-owned `[[discord.gateway.surfaces]]` entry, to enable it: +workspace-owned `[[discord.workspace.surfaces]]` entry, to enable it: ```bash CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID=1502107617512919221 @@ -116,7 +116,7 @@ CODEX_DISCORD_TASK_THREADS_CHANNEL_ID=1502107617512919222 The home channel remains the compact operator chat. Each surface's workspace forum gets one post for each discoverable top-level folder under `CODEX_DISCORD_DIR` that routes to that surface. `CODEX_DISCORD_DIR` is the -gateway's main workspace root. For the home-folder gateway, set +workspace's main workspace root. For the home-folder workspace, set `CODEX_DISCORD_DIR=/home/peezy`; a delegated cwd such as `/home/peezy/codex-fork-workspace/codex-flows` maps to the `/home/peezy/codex-fork-workspace` workspace post. Hidden folders and @@ -167,22 +167,22 @@ Delegations support return modes: - `wake_on_done`: inject and mirror the result, then wake the main operator when idle - `wake_on_group`: inject and mirror each result, then wake once the whole group is terminal - `record_only`: inject and mirror results without waking the main operator -- `manual`: keep results in gateway state until `flush_delegation_results` +- `manual`: keep results in workspace state until `flush_delegation_results` - `detached`: do not loop results back to the main thread; useful for human-continued threads Automatic result return uses `thread/inject_items` to append structured delegation results to the main operator thread's model-visible history. Codex hooks, not background thread polling, drive automatic result return and passive observability. The global hook writes durable lifecycle events into the spool -directory, and the gateway drains that spool on startup and while running. +directory, and the workspace drains that spool on startup and while running. Starting a main-thread turn is a separate wake step, so long-running main goals are not interrupted; wakes are queued until the main operator thread is idle. -For sessions that were not created through the gateway, the same hook stream +For sessions that were not created through the workspace, the same hook stream updates an observed-thread index used by `/threads`. ## Codex Hooks -Install the global hooks once for the Codex runtime that backs the gateway: +Install the global hooks once for the Codex runtime that backs the workspace: ```bash codex-discord-bridge hook install @@ -252,14 +252,14 @@ codex-discord-bridge hook install --bunx codex-discord-bridge hook install --bunx-package @peezy.tech/codex-flows ``` -The hook is intentionally dumb: it does not read gateway state or call the +The hook is intentionally dumb: it does not read workspace state or call the backend. It only writes idempotent lifecycle-event files and lets Codex -continue. The gateway treats known delegated `Stop` events according to their +continue. The workspace treats known delegated `Stop` events according to their return mode, uses main-operator `Stop` events to drain queued wakes, and records unknown non-main sessions as observed threads. Observed threads are visible from `/threads` for their workspace and can be opened into the task thread channel on demand. After changing hook configuration, restart the Codex runtime that backs the -gateway and trust the hook when Codex asks for review. `hooks/list` should show +workspace and trust the hook when Codex asks for review. `hooks/list` should show the hook as `trusted`; untrusted hooks are discovered but do not run. diff --git a/apps/discord-bridge/src/bridge.ts b/apps/discord-bridge/src/bridge.ts index f834df8..b8860aa 100644 --- a/apps/discord-bridge/src/bridge.ts +++ b/apps/discord-bridge/src/bridge.ts @@ -8,28 +8,28 @@ import type { DiscordBridgeTransport, } from "./types.ts"; import type { - CodexGatewayBackend, - CodexGatewayPresenter, -} from "./gateway-backend.ts"; + CodexWorkspaceBackend, + CodexWorkspacePresenter, +} from "./workspace-backend.ts"; import { - LocalCodexGatewayBackend, - type LocalCodexGatewayBackendOptions, + LocalCodexWorkspaceBackend, + type LocalCodexWorkspaceBackendOptions, parseThreadStartIntent, splitDiscordMessage, -} from "./local-gateway-backend.ts"; +} from "./local-workspace-backend.ts"; export { parseThreadStartIntent, splitDiscordMessage }; -export { LocalCodexGatewayBackend }; -export type { LocalCodexGatewayBackendOptions }; +export { LocalCodexWorkspaceBackend }; +export type { LocalCodexWorkspaceBackendOptions }; export type DiscordCodexBridgeLocalOptions = - Omit & { + Omit & { transport: DiscordBridgeTransport; backend?: undefined; }; export type DiscordCodexBridgeBackendOptions = { - backend: CodexGatewayBackend; + backend: CodexWorkspaceBackend; transport: DiscordBridgeTransport; logger?: DiscordBridgeLogger; config?: Pick; @@ -42,7 +42,7 @@ export type DiscordCodexBridgeOptions = export class DiscordCodexBridge { readonly transport: DiscordBridgeTransport; - readonly backend: CodexGatewayBackend; + readonly backend: CodexWorkspaceBackend; #logger: DiscordBridgeLogger; constructor(options: DiscordCodexBridgeOptions) { @@ -53,7 +53,7 @@ export class DiscordCodexBridge { logLevel: options.config?.logLevel, now: options.now, }); - this.backend = options.backend ?? new LocalCodexGatewayBackend({ + this.backend = options.backend ?? new LocalCodexWorkspaceBackend({ ...options, presenter: discordTransportPresenter(options.transport), }); @@ -92,7 +92,7 @@ export class DiscordCodexBridge { stateForTest(): DiscordBridgeState { if (!this.backend.stateForTest) { - throw new Error("Gateway backend does not expose test state."); + throw new Error("Workspace backend does not expose test state."); } return this.backend.stateForTest(); } @@ -104,7 +104,7 @@ export class DiscordCodexBridge { function discordTransportPresenter( transport: DiscordBridgeTransport, -): CodexGatewayPresenter { +): CodexWorkspacePresenter { return { createWorkspacePost: transport.createForumPost?.bind(transport), createThread: transport.createThread.bind(transport), diff --git a/apps/discord-bridge/src/config.ts b/apps/discord-bridge/src/config.ts index c7d4b71..d251794 100644 --- a/apps/discord-bridge/src/config.ts +++ b/apps/discord-bridge/src/config.ts @@ -11,7 +11,7 @@ import type { import type { DiscordBridgeConfig, DiscordConsoleOutputMode, - DiscordGatewaySurfaceConfig, + DiscordWorkspaceSurfaceConfig, DiscordProgressMode, } from "./types.ts"; import type { DiscordBridgeLogLevelSetting } from "./logger.ts"; @@ -67,7 +67,7 @@ const sandboxValues = new Set([ "workspace-write", "danger-full-access", ]); -const defaultGatewaySurfaceKey = "default"; +const defaultWorkspaceSurfaceKey = "default"; export function parseConfig(argv: string[], env: NodeJS.ProcessEnv): ParsedConfig { const args = parseFlags(argv); @@ -135,7 +135,7 @@ export function parseConfig(argv: string[], env: NodeJS.ProcessEnv): ParsedConfi env.CODEX_DISCORD_ALLOWED_CHANNEL_IDS, ), statePath, - gateway: gatewayConfig(args, env, cwd), + workspace: workspaceConfig(args, env, cwd), flowBackendUrl: stringFlag(args, "flow-backend-url") ?? env.CODEX_FLOW_BACKEND_URL ?? @@ -290,15 +290,15 @@ function optionalProgressMode(value: string | undefined): DiscordProgressMode | return value as DiscordProgressMode; } -function gatewayConfig( +function workspaceConfig( flags: Map, env: NodeJS.ProcessEnv, workspaceRoot: string | undefined, -): DiscordBridgeConfig["gateway"] { - const workspaceSurfaces = gatewaySurfacesConfig(workspaceRoot); +): DiscordBridgeConfig["workspace"] { + const workspaceSurfaces = workspaceSurfacesConfig(workspaceRoot); const configuredHomeChannelId = stringFlag(flags, "home-channel-id") ?? - stringFlag(flags, "gateway-home-channel-id") ?? + stringFlag(flags, "workspace-home-channel-id") ?? env.CODEX_DISCORD_HOME_CHANNEL_ID ?? env.CODEX_DISCORD_GATEWAY_HOME_CHANNEL_ID; const useWorkspacePrimaryDefaults = !configuredHomeChannelId; @@ -306,12 +306,12 @@ function gatewayConfig( workspaceSurfaces[0]?.homeChannelId; const mainThreadId = stringFlag(flags, "main-thread-id") ?? - stringFlag(flags, "gateway-main-thread-id") ?? + stringFlag(flags, "workspace-main-thread-id") ?? env.CODEX_DISCORD_MAIN_THREAD_ID ?? env.CODEX_DISCORD_GATEWAY_MAIN_THREAD_ID; const configuredWorkspaceForumChannelId = stringFlag(flags, "workspace-forum-channel-id") ?? - stringFlag(flags, "gateway-workspace-forum-channel-id") ?? + stringFlag(flags, "workspace-workspace-forum-channel-id") ?? env.CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID ?? env.CODEX_DISCORD_GATEWAY_WORKSPACE_FORUM_CHANNEL_ID; const workspaceForumChannelId = configuredWorkspaceForumChannelId ?? @@ -320,7 +320,7 @@ function gatewayConfig( : undefined); const configuredTaskThreadsChannelId = stringFlag(flags, "task-threads-channel-id") ?? - stringFlag(flags, "gateway-task-threads-channel-id") ?? + stringFlag(flags, "workspace-task-threads-channel-id") ?? env.CODEX_DISCORD_TASK_THREADS_CHANNEL_ID ?? env.CODEX_DISCORD_GATEWAY_TASK_THREADS_CHANNEL_ID; const taskThreadsChannelId = configuredTaskThreadsChannelId ?? @@ -329,10 +329,10 @@ function gatewayConfig( : undefined); if (!homeChannelId) { if (mainThreadId) { - throw new Error("Cannot set a gateway main thread without a gateway home channel."); + throw new Error("Cannot set a workspace main thread without a workspace home channel."); } if (workspaceForumChannelId || taskThreadsChannelId) { - throw new Error("Cannot set Discord workbench channels without a gateway home channel."); + throw new Error("Cannot set Discord workbench channels without a workspace home channel."); } return undefined; } @@ -349,17 +349,17 @@ function gatewayConfig( workspaceForumChannelId === taskThreadsChannelId) ) { throw new Error( - "Discord workbench channels must be separate from the gateway home channel and each other.", + "Discord workbench channels must be separate from the workspace home channel and each other.", ); } const defaultSurface = { - key: defaultGatewaySurfaceKey, + key: defaultWorkspaceSurfaceKey, homeChannelId, workspaceForumChannelId, taskThreadsChannelId, }; const surfaces = workspaceSurfaces.length > 0 - ? mergeGatewaySurfaces( + ? mergeWorkspaceSurfaces( configuredHomeChannelId ? [defaultSurface, ...workspaceSurfaces] : workspaceSurfaces, @@ -374,9 +374,9 @@ function gatewayConfig( }; } -function gatewaySurfacesConfig( +function workspaceSurfacesConfig( workspaceRoot: string | undefined, -): DiscordGatewaySurfaceConfig[] { +): DiscordWorkspaceSurfaceConfig[] { if (!workspaceRoot) { return []; } @@ -384,14 +384,14 @@ function gatewaySurfacesConfig( if (workspaceCwds.length === 0) { return []; } - const surfaces: DiscordGatewaySurfaceConfig[] = []; + const surfaces: DiscordWorkspaceSurfaceConfig[] = []; for (const workspaceCwd of workspaceCwds) { - const surface = workspaceGatewaySurfaceConfig(workspaceCwd); + const surface = workspaceWorkspaceSurfaceConfig(workspaceCwd); if (surface) { surfaces.push(surface); } } - return mergeGatewaySurfaces(surfaces); + return mergeWorkspaceSurfaces(surfaces); } function discoverWorkspaceConfigCwds(workspaceRoot: string): string[] { @@ -434,9 +434,9 @@ function isDiscoverableWorkspaceEntry(name: string): boolean { name !== "node_modules"; } -function workspaceGatewaySurfaceConfig( +function workspaceWorkspaceSurfaceConfig( workspaceCwd: string, -): DiscordGatewaySurfaceConfig | undefined { +): DiscordWorkspaceSurfaceConfig | undefined { const configPath = path.join(workspaceCwd, ".codex", "workspace.toml"); if (!existsSync(configPath)) { return undefined; @@ -449,13 +449,13 @@ function workspaceGatewaySurfaceConfig( `Invalid workspace config TOML at ${configPath}: ${errorMessage(error)}`, ); } - const surfacesInput = gatewaySurfaceEntries(parsed); + const surfacesInput = workspaceSurfaceEntries(parsed); if (surfacesInput === undefined) { return undefined; } if (!Array.isArray(surfacesInput)) { throw new Error( - `workspace.toml discord.gateway.surfaces must be an array: ${configPath}`, + `workspace.toml discord.workspace.surfaces must be an array: ${configPath}`, ); } if (surfacesInput.length === 0) { @@ -463,30 +463,30 @@ function workspaceGatewaySurfaceConfig( } if (surfacesInput.length > 1) { throw new Error( - `workspace.toml discord.gateway.surfaces must contain one surface: ${configPath}`, + `workspace.toml discord.workspace.surfaces must contain one surface: ${configPath}`, ); } - return parseGatewaySurface(surfacesInput[0], 0, workspaceCwd); + return parseWorkspaceSurface(surfacesInput[0], 0, workspaceCwd); } -function gatewaySurfaceEntries(input: unknown): unknown { +function workspaceSurfaceEntries(input: unknown): unknown { const parsed = record(input); if (parsed.discord === undefined) { return undefined; } const discord = record(parsed.discord); - if (discord.gateway === undefined) { + if (discord.workspace === undefined) { return undefined; } - const gateway = record(discord.gateway); - return gateway.surfaces; + const workspace = record(discord.workspace); + return workspace.surfaces; } -function parseGatewaySurface( +function parseWorkspaceSurface( input: unknown, index: number, workspaceCwd: string, -): DiscordGatewaySurfaceConfig { +): DiscordWorkspaceSurfaceConfig { const parsed = record(input); const key = optionalString(parsed.key) ?? optionalString(parsed.name); const homeChannelId = optionalString(parsed.homeChannelId) ?? @@ -496,14 +496,14 @@ function parseGatewaySurface( const taskThreadsChannelId = optionalString(parsed.taskThreadsChannelId) ?? optionalString(parsed.task_threads_channel_id); if (!key) { - throw new Error(`Gateway surface at index ${index} is missing key.`); + throw new Error(`Workspace surface at index ${index} is missing key.`); } if (!homeChannelId) { - throw new Error(`Gateway surface ${key} is missing homeChannelId.`); + throw new Error(`Workspace surface ${key} is missing homeChannelId.`); } if (Boolean(workspaceForumChannelId) !== Boolean(taskThreadsChannelId)) { throw new Error( - `Gateway surface ${key} requires both workspaceForumChannelId and taskThreadsChannelId.`, + `Workspace surface ${key} requires both workspaceForumChannelId and taskThreadsChannelId.`, ); } if ( @@ -513,7 +513,7 @@ function parseGatewaySurface( homeChannelId === taskThreadsChannelId || workspaceForumChannelId === taskThreadsChannelId) ) { - throw new Error(`Gateway surface ${key} channels must be distinct.`); + throw new Error(`Workspace surface ${key} channels must be distinct.`); } return { key, @@ -524,10 +524,10 @@ function parseGatewaySurface( }; } -function mergeGatewaySurfaces( - surfaces: DiscordGatewaySurfaceConfig[], -): DiscordGatewaySurfaceConfig[] { - const byKey = new Map(); +function mergeWorkspaceSurfaces( + surfaces: DiscordWorkspaceSurfaceConfig[], +): DiscordWorkspaceSurfaceConfig[] { + const byKey = new Map(); for (const surface of surfaces) { const existing = byKey.get(surface.key); if (!existing) { @@ -545,7 +545,7 @@ function mergeGatewaySurfaces( existing.taskThreadsChannelId !== surface.taskThreadsChannelId ) { throw new Error( - `Gateway surface key ${surface.key} is configured with different channels.`, + `Workspace surface key ${surface.key} is configured with different channels.`, ); } existing.workspaceCwds = existing.workspaceCwds && surface.workspaceCwds @@ -556,11 +556,11 @@ function mergeGatewaySurfaces( : undefined; } const merged = [...byKey.values()]; - validateGatewaySurfaces(merged); + validateWorkspaceSurfaces(merged); return merged; } -function validateGatewaySurfaces(surfaces: DiscordGatewaySurfaceConfig[]): void { +function validateWorkspaceSurfaces(surfaces: DiscordWorkspaceSurfaceConfig[]): void { const channelIds = new Set(); let catchAllSurfaces = 0; for (const surface of surfaces) { @@ -577,14 +577,14 @@ function validateGatewaySurfaces(surfaces: DiscordGatewaySurfaceConfig[]): void } if (channelIds.has(channelId)) { throw new Error( - `Gateway surface channel is configured more than once: ${channelId}`, + `Workspace surface channel is configured more than once: ${channelId}`, ); } channelIds.add(channelId); } } if (catchAllSurfaces > 1) { - throw new Error("Only one gateway surface may omit workspaceCwds."); + throw new Error("Only one workspace surface may omit workspaceCwds."); } } @@ -657,12 +657,12 @@ Options: --local-app-server Start a local app-server over stdio --state-path Persistent bridge state file --allowed-channel-ids Comma-separated parent channel ids - --home-channel-id Enable gateway mode for one Discord home channel - --main-thread-id Resume an existing Codex operator thread for gateway mode + --home-channel-id Enable workspace mode for one Discord home channel + --main-thread-id Resume an existing Codex operator thread for workspace mode --workspace-forum-channel-id Optional workbench forum channel for workspace posts --task-threads-channel-id Optional workbench text channel for task threads - --flow-backend-url Optional codex-flow-systemd-local backend URL + --flow-backend-url Optional workspace flow HTTP backend URL --hook-spool-dir Directory drained for Codex hook events [dir] Optional Codex thread directory, resolved from home --dir Codex thread directory, resolved from home diff --git a/apps/discord-bridge/src/discord-transport.ts b/apps/discord-bridge/src/discord-transport.ts index 656f2cc..c3829f0 100644 --- a/apps/discord-bridge/src/discord-transport.ts +++ b/apps/discord-bridge/src/discord-transport.ts @@ -732,7 +732,7 @@ function discordBridgeCommands(): ApplicationCommandDataResolvable[] { }, { name: "status", - description: "Show Codex gateway status", + description: "Show Codex workspace status", }, { name: "threads", diff --git a/apps/discord-bridge/src/hook-cli.ts b/apps/discord-bridge/src/hook-cli.ts index 63744fd..e03da23 100644 --- a/apps/discord-bridge/src/hook-cli.ts +++ b/apps/discord-bridge/src/hook-cli.ts @@ -6,7 +6,7 @@ import { writeHookSpoolEvent } from "./stop-hook-spool.ts"; const defaultHookCommand = "codex-discord-bridge hook event"; const defaultBunxPackage = "codex-discord-bridge"; -const gatewayHookEvents = [ +const workspaceHookEvents = [ "SessionStart", "UserPromptSubmit", "PreToolUse", @@ -62,7 +62,7 @@ export async function runHookEvent(): Promise { process.stdout.write(`${JSON.stringify({ continue: true })}\n`); } } catch (error) { - process.stderr.write(`discord gateway hook failed: ${errorMessage(error)}\n`); + process.stderr.write(`discord workspace hook failed: ${errorMessage(error)}\n`); if (eventSupportsContinueOutput(eventNameFromHookInput(input))) { process.stdout.write(`${JSON.stringify({ continue: true })}\n`); } @@ -125,12 +125,12 @@ export function upsertStopHookConfig( ): Record { const config = parseHooksJson(hooksText); const hooks = record(config.hooks); - for (const eventName of gatewayHookEvents) { + for (const eventName of workspaceHookEvents) { const groups = Array.isArray(hooks[eventName]) ? hooks[eventName] : []; hooks[eventName] = [ hookGroup(command), ...groups - .map(removeGatewayStopHookHandlers) + .map(removeWorkspaceStopHookHandlers) .filter((group): group is Record => group !== undefined), ]; } @@ -217,10 +217,10 @@ function hookGroup(command: string): Record { }; } -function removeGatewayStopHookHandlers(input: unknown): Record | undefined { +function removeWorkspaceStopHookHandlers(input: unknown): Record | undefined { const group = record(input); const handlers = Array.isArray(group.hooks) - ? group.hooks.filter((handler) => !isGatewayStopHookHandler(handler)) + ? group.hooks.filter((handler) => !isWorkspaceStopHookHandler(handler)) : []; if (handlers.length === 0) { return undefined; @@ -228,12 +228,12 @@ function removeGatewayStopHookHandlers(input: unknown): Record return { ...group, hooks: handlers }; } -function isGatewayStopHookHandler(input: unknown): boolean { +function isWorkspaceStopHookHandler(input: unknown): boolean { const handler = record(input); const command = typeof handler.command === "string" ? handler.command : ""; return command.includes("codex-discord-bridge hook stop") || command.includes("codex-discord-bridge hook event") || - command.includes("codex-discord-gateway-stop-hook") || + command.includes("codex-discord-workspace-stop-hook") || command.includes("apps/discord-bridge/src/stop-hook.ts"); } diff --git a/apps/discord-bridge/src/local-gateway-backend.ts b/apps/discord-bridge/src/local-workspace-backend.ts similarity index 77% rename from apps/discord-bridge/src/local-gateway-backend.ts rename to apps/discord-bridge/src/local-workspace-backend.ts index a209c62..67df4a4 100644 --- a/apps/discord-bridge/src/local-gateway-backend.ts +++ b/apps/discord-bridge/src/local-workspace-backend.ts @@ -7,6 +7,11 @@ import { createHash, randomUUID } from "node:crypto"; import type { JsonRpcNotification, JsonRpcRequest } from "@peezy.tech/codex-flows/rpc"; import type { JsonValue } from "@peezy.tech/codex-flows/generated/serde_json/JsonValue"; import type { v2 } from "@peezy.tech/codex-flows/generated"; +import { + WorkspaceDelegationCapability, + type WorkspaceDelegation, + type WorkspacePendingWake, +} from "@peezy.tech/codex-flows/workspace-backend"; import { createFlowBackendHttpClient, type FlowBackendClient, @@ -14,9 +19,9 @@ import { import type { DiscordConsoleOutput } from "./console-output.ts"; import type { - CodexGatewayBackend, - CodexGatewayPresenter, -} from "./gateway-backend.ts"; + CodexWorkspaceBackend, + CodexWorkspacePresenter, +} from "./workspace-backend.ts"; import { DiscordThreadRunner, MessageDeduplicator } from "./runner.ts"; import { createDiscordBridgeLogger, @@ -32,13 +37,12 @@ import type { CodexBridgeClient, DiscordBridgeCommandRegistration, DiscordBridgeConfig, - DiscordGatewayDelegation, - DiscordGatewayDelegationReturnMode, - DiscordGatewayHookEvent, - DiscordGatewayObservedThread, - DiscordGatewayPendingWake, - DiscordGatewaySurfaceConfig, - DiscordGatewayWorkspaceSurface, + DiscordWorkspaceDelegation, + DiscordWorkspaceHookEvent, + DiscordWorkspaceObservedThread, + DiscordWorkspacePendingWake, + DiscordWorkspaceSurfaceConfig, + DiscordWorkspaceWorkspaceSurface, DiscordBridgeSession, DiscordBridgeState, DiscordBridgeStateStore, @@ -55,7 +59,7 @@ import type { } from "./types.ts"; const maxDiscordMessageLength = 2000; -const gatewayToolsVersion = 1; +const workspaceToolsVersion = 1; const stopHookDrainDebounceMs = 100; const stopHookRetryMs = 1_000; const threadPickerReactions = [ @@ -102,32 +106,32 @@ type WorkspaceGoalSummary = WorkspaceThreadSummary & { type WorkspaceGoalPicker = { channelId: string; authorId: string; - workspace: DiscordGatewayWorkspaceSurface; + workspace: DiscordWorkspaceWorkspaceSurface; entries: WorkspaceGoalSummary[]; }; type WorkspaceGoalActionPicker = { channelId: string; authorId: string; - workspace: DiscordGatewayWorkspaceSurface; + workspace: DiscordWorkspaceWorkspaceSurface; entry: WorkspaceGoalSummary; }; -type GatewaySurface = DiscordGatewaySurfaceConfig & { +type WorkspaceSurface = DiscordWorkspaceSurfaceConfig & { workspaceCwds?: string[]; }; -type GatewayWorkbenchConfig = { +type WorkspaceWorkbenchConfig = { surfaceKey: string; workspaceForumChannelId: string; taskThreadsChannelId: string; }; -const defaultGatewaySurfaceKey = "default"; +const defaultWorkspaceSurfaceKey = "default"; -export type LocalCodexGatewayBackendOptions = { +export type LocalCodexWorkspaceBackendOptions = { client: CodexBridgeClient; - presenter: CodexGatewayPresenter; + presenter: CodexWorkspacePresenter; store: DiscordBridgeStateStore; config: DiscordBridgeConfig; now?: () => Date; @@ -136,9 +140,9 @@ export type LocalCodexGatewayBackendOptions = { flowBackendClient?: FlowBackendClient; }; -export class LocalCodexGatewayBackend implements CodexGatewayBackend { +export class LocalCodexWorkspaceBackend implements CodexWorkspaceBackend { readonly client: CodexBridgeClient; - readonly presenter: CodexGatewayPresenter; + readonly presenter: CodexWorkspacePresenter; readonly store: DiscordBridgeStateStore; readonly config: DiscordBridgeConfig; #state: DiscordBridgeState | undefined; @@ -149,9 +153,9 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { #dedupe: MessageDeduplicator; #logger: DiscordBridgeLogger; #consoleOutput: DiscordConsoleOutput | undefined; - #gatewayStopHookWatcher: FSWatcher | undefined; - #gatewayStopHookDrainTimer: Timer | undefined; - #gatewayStopHookDrainChain: Promise = Promise.resolve(); + #workspaceStopHookWatcher: FSWatcher | undefined; + #workspaceStopHookDrainTimer: Timer | undefined; + #workspaceStopHookDrainChain: Promise = Promise.resolve(); #flowBackendClient: FlowBackendClient | undefined; #transportStarted = false; #threadPickersByMessage = new Map(); @@ -159,7 +163,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { #goalPickersById = new Map(); #goalActionPickersById = new Map(); - constructor(options: LocalCodexGatewayBackendOptions) { + constructor(options: LocalCodexWorkspaceBackendOptions) { this.client = options.client; this.presenter = options.presenter; this.store = options.store; @@ -205,13 +209,13 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { this.client.on("request", (message) => this.#handleServerRequest(message)); await this.client.connect(); this.#debug("client.connected"); - await this.#ensureGatewaySession(); + await this.#ensureWorkspaceSession(); } async startTransportDependentWork(): Promise { this.#transportStarted = true; this.#debug("transport.started"); - await this.#reconcileGatewayWorkbench(); + await this.#reconcileWorkspaceWorkbench(); } async startBackgroundWork(): Promise { @@ -220,25 +224,25 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { runner.start(); } } - await this.#startGatewayStopHookSpool(); + await this.#startWorkspaceStopHookSpool(); } async stop(): Promise { this.#debug("bridge.stop", { runners: this.#runnersByDiscordThread.size, }); - if (this.#gatewayStopHookDrainTimer) { - clearTimeout(this.#gatewayStopHookDrainTimer); - this.#gatewayStopHookDrainTimer = undefined; + if (this.#workspaceStopHookDrainTimer) { + clearTimeout(this.#workspaceStopHookDrainTimer); + this.#workspaceStopHookDrainTimer = undefined; } - if (this.#gatewayStopHookWatcher) { - this.#gatewayStopHookWatcher.close(); - this.#gatewayStopHookWatcher = undefined; + if (this.#workspaceStopHookWatcher) { + this.#workspaceStopHookWatcher.close(); + this.#workspaceStopHookWatcher = undefined; } await Promise.all( [...this.#runnersByDiscordThread.values()].map((runner) => runner.stop()), ); - await this.#gatewayStopHookDrainChain.catch(() => undefined); + await this.#workspaceStopHookDrainChain.catch(() => undefined); await this.#persistChain.catch(() => undefined); this.#transportStarted = false; this.client.close(); @@ -320,8 +324,8 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } if (inbound.kind === "threadStart") { - if (this.#gatewaySurfaceForHomeChannel(inbound.channelId)) { - await this.#handleGatewayThreadStart(inbound); + if (this.#workspaceSurfaceForHomeChannel(inbound.channelId)) { + await this.#handleWorkspaceThreadStart(inbound); return; } if (!this.config.allowedUserIds.has(inbound.author.id)) { @@ -354,7 +358,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } if (!this.presenter.deleteThread) { this.#debug("clear.unsupported", { channelId: command.channelId }); - await command.reply?.("This gateway presenter cannot delete threads."); + await command.reply?.("This workspace presenter cannot delete threads."); return; } const state = this.#requireState(); @@ -433,7 +437,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } if (!this.presenter.deleteWebhookMessages) { this.#debug("clearWebhooks.unsupported", { channelId: command.channelId }); - await command.reply?.("This gateway presenter cannot delete webhook messages."); + await command.reply?.("This workspace presenter cannot delete webhook messages."); return; } this.#debug("clearWebhooks.start", { @@ -469,7 +473,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { channelId: command.channelId, authorId: command.author.id, }); - await command.reply?.("Only globally allowed Discord users can read gateway status."); + await command.reply?.("Only globally allowed Discord users can read workspace status."); return; } if (!this.#isAllowedChannel(command.channelId)) { @@ -477,16 +481,16 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { await command.reply?.("This Discord channel is not allowed for the bridge."); return; } - const surface = this.#gatewaySurfaceForChannel(command.channelId) ?? - this.#primaryGatewaySurface(); - const workbench = this.#gatewayWorkbenchConfig(surface); + const surface = this.#workspaceSurfaceForChannel(command.channelId) ?? + this.#primaryWorkspaceSurface(); + const workbench = this.#workspaceWorkbenchConfig(surface); const activeThreads = await this.#listActiveCodexThreadSummaries(surface); const openableThreads = activeThreads.filter((thread) => !thread.discordThreadId && - !this.#isGatewayMainThread(thread.id) && + !this.#isWorkspaceMainThread(thread.id) && Boolean(workbench) ).slice(0, threadPickerReactions.length); - const statusText = this.#gatewayStatusMessage({ + const statusText = this.#workspaceStatusMessage({ activeThreads, openableThreads, }, surface); @@ -543,7 +547,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } if (!command.replyPicker) { await command.reply?.( - "This gateway presenter cannot send ephemeral thread pickers.", + "This workspace presenter cannot send ephemeral thread pickers.", ); return; } @@ -602,7 +606,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } if (!command.replyPicker) { await command.reply?.( - "This gateway presenter cannot send ephemeral goal pickers.", + "This workspace presenter cannot send ephemeral goal pickers.", ); return; } @@ -729,7 +733,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { try { const session = await this.#materializeWorkspaceThread(entry.id, { author: selection.author, - surface: this.#gatewaySurfaceForChannel(picker.channelId), + surface: this.#workspaceSurfaceForChannel(picker.channelId), }); await updateOrReply( selection, @@ -781,7 +785,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { try { const session = await this.#materializeWorkspaceThread(picker.entry.id, { author: selection.author, - surface: this.#gatewaySurfaceForWorkspace(picker.workspace), + surface: this.#workspaceSurfaceForWorkspace(picker.workspace), }); const updatedEntry = { ...picker.entry, @@ -895,7 +899,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { try { const session = await this.#materializeWorkspaceThread(entry.id, { author: reaction.author, - surface: this.#gatewaySurfaceForChannel(picker.channelId), + surface: this.#workspaceSurfaceForChannel(picker.channelId), }); await this.presenter.sendMessage( picker.channelId, @@ -1064,8 +1068,8 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { }); return; } - if (this.#gatewaySurfaceForHomeChannel(message.channelId)) { - await this.#handleGatewayMessage(message); + if (this.#workspaceSurfaceForHomeChannel(message.channelId)) { + await this.#handleWorkspaceMessage(message); return; } const runner = this.#runnersByDiscordThread.get(message.channelId); @@ -1096,8 +1100,8 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { await runner.enqueueMessage(message); } - async #handleGatewayThreadStart(start: DiscordThreadStartInbound): Promise { - await this.#handleGatewayMessage({ + async #handleWorkspaceThreadStart(start: DiscordThreadStartInbound): Promise { + await this.#handleWorkspaceMessage({ kind: "message", channelId: start.channelId, guildId: start.guildId, @@ -1108,18 +1112,18 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { }); } - async #handleGatewayMessage(message: DiscordMessageInbound): Promise { + async #handleWorkspaceMessage(message: DiscordMessageInbound): Promise { if (!this.config.allowedUserIds.has(message.author.id)) { - this.#debug("gateway.message.ignored.user", { + this.#debug("workspace.message.ignored.user", { channelId: message.channelId, messageId: message.messageId, authorId: message.author.id, }); return; } - const runner = this.#gatewayRunner(); + const runner = this.#workspaceRunner(); if (!runner) { - this.#debug("gateway.message.ignored.noSession", { + this.#debug("workspace.message.ignored.noSession", { channelId: message.channelId, messageId: message.messageId, }); @@ -1128,39 +1132,39 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { await runner.enqueueMessage(message); } - #gatewayStatusMessage( + #workspaceStatusMessage( options: { activeThreads?: WorkspaceThreadSummary[]; openableThreads?: WorkspaceThreadSummary[]; } = {}, - surface: GatewaySurface | undefined = this.#primaryGatewaySurface(), + surface: WorkspaceSurface | undefined = this.#primaryWorkspaceSurface(), ): string { const state = this.#requireState(); - const gateway = state.gateway; - const session = this.#gatewaySession(); - const delegations = (gateway?.delegations ?? []).filter((delegation) => - this.#gatewaySurfaceForDelegation(delegation)?.key === surface?.key + const workspace = state.workspace; + const session = this.#workspaceSession(); + const delegations = (workspace?.delegations ?? []).filter((delegation) => + this.#workspaceSurfaceForDelegation(delegation)?.key === surface?.key ); - const workspaces = (gateway?.workspaces ?? []).filter((workspace) => - this.#gatewaySurfaceForWorkspace(workspace)?.key === surface?.key + const workspaces = (workspace?.workspaces ?? []).filter((workspace) => + this.#workspaceSurfaceForWorkspace(workspace)?.key === surface?.key ); const activeDelegations = delegations.filter((delegation) => delegation.status === "active" ); - const workbench = this.#gatewayWorkbenchConfig(surface); + const workbench = this.#workspaceWorkbenchConfig(surface); const activeThreads = options.activeThreads ?? []; const openableThreads = options.openableThreads ?? []; return [ - "**Codex Gateway**", + "**Codex Workspace**", surface ? `Surface: \`${surface.key}\`` : undefined, - `Home channel: \`${surface?.homeChannelId ?? this.config.gateway?.homeChannelId ?? "disabled"}\``, - `Main thread: \`${session?.codexThreadId ?? gateway?.mainThreadId ?? "none"}\``, + `Home channel: \`${surface?.homeChannelId ?? this.config.workspace?.homeChannelId ?? "disabled"}\``, + `Main thread: \`${session?.codexThreadId ?? workspace?.mainThreadId ?? "none"}\``, `Dir: \`${session?.cwd ?? this.config.cwd ?? "default"}\``, `Legacy thread bridge: \`enabled\``, `Delegations: ${delegations.length} tracked, ${activeDelegations.length} active`, "", "**Delegation Backend**", - `Status: ${state.gateway?.toolsVersion === gatewayToolsVersion ? "privileged gateway tools available to the main Codex operator thread" : "waiting for a tool-enabled main Codex operator thread"}.`, + `Status: ${state.workspace?.toolsVersion === workspaceToolsVersion ? "privileged workspace tools available to the main Codex operator thread" : "waiting for a tool-enabled main Codex operator thread"}.`, `Flow backend: \`${this.config.flowBackendUrl ?? "not configured"}\``, "", "**Workbench**", @@ -1203,7 +1207,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { return; } await runner.handleNotification(message); - if (message.method === "turn/completed" && this.#isGatewayMainThread(threadId)) { + if (message.method === "turn/completed" && this.#isWorkspaceMainThread(threadId)) { await this.#processPendingWakes(); await this.#persist(); } @@ -1230,8 +1234,8 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { const tool = stringValue(params.tool); if ( !threadId || - threadId !== this.#gatewaySession()?.codexThreadId || - namespace !== "codex_gateway" || + threadId !== this.#workspaceSession()?.codexThreadId || + namespace !== "codex_workspace" || !tool ) { this.client.respondError( @@ -1241,7 +1245,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { ); return; } - const result = await this.#callGatewayTool(tool, record(params.arguments)); + const result = await this.#callWorkspaceTool(tool, record(params.arguments)); this.client.respond(message.id, { contentItems: [ { @@ -1253,13 +1257,13 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { }); } - async #callGatewayTool( + async #callWorkspaceTool( tool: string, args: Record, ): Promise { if (tool === "list_delegations") { return { - delegations: this.#gatewayDelegations(), + delegations: this.#workspaceDelegations(), }; } if (tool === "start_delegation") { @@ -1291,7 +1295,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { if (tool === "list_flow_events") { return await this.#listFlowEvents(args); } - throw new Error(`Unknown gateway tool: ${tool}`); + throw new Error(`Unknown workspace tool: ${tool}`); } #registerRunner(session: DiscordBridgeSession): DiscordThreadRunner { @@ -1314,44 +1318,45 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { return runner; } - async #ensureGatewaySession(): Promise { - const gatewayConfig = this.config.gateway; - if (!gatewayConfig) { + async #ensureWorkspaceSession(): Promise { + const workspaceConfig = this.config.workspace; + if (!workspaceConfig) { return; } const state = this.#requireState(); - const existing = this.#gatewaySession(); - const explicitMainThread = Boolean(gatewayConfig.mainThreadId); - let forceCreateGatewayThread = false; + const existing = this.#workspaceSession(); + const explicitMainThread = Boolean(workspaceConfig.mainThreadId); + let forceCreateWorkspaceThread = false; const shouldReuseExisting = explicitMainThread || - state.gateway?.toolsVersion === gatewayToolsVersion; + state.workspace?.toolsVersion === workspaceToolsVersion; if (existing && shouldReuseExisting) { try { - const gatewayCwd = this.config.cwd ?? existing.cwd; + const workspaceCwd = this.config.cwd ?? existing.cwd; const resumed = await this.client.resumeThread(this.#threadResumeParams( existing.codexThreadId, - gatewayCwd, + workspaceCwd, )); - const primarySurface = this.#primaryGatewaySurface(); + const primarySurface = this.#primaryWorkspaceSurface(); this.#runnersByDiscordThread.delete(existing.discordThreadId); this.#runnersByCodexThread.delete(existing.codexThreadId); - existing.discordThreadId = gatewayConfig.homeChannelId; - existing.parentChannelId = gatewayConfig.homeChannelId; + existing.discordThreadId = workspaceConfig.homeChannelId; + existing.parentChannelId = workspaceConfig.homeChannelId; + existing.mode = "operator"; existing.surfaceKey = primarySurface?.key; - existing.cwd = gatewayCwd ?? resumeResponseCwd(resumed) ?? existing.cwd; - state.gateway = { - homeChannelId: gatewayConfig.homeChannelId, + existing.cwd = workspaceCwd ?? resumeResponseCwd(resumed) ?? existing.cwd; + state.workspace = { + homeChannelId: workspaceConfig.homeChannelId, mainThreadId: existing.codexThreadId, statusMessageId: existing.statusMessageId, createdAt: existing.createdAt, - toolsVersion: state.gateway?.toolsVersion, - delegations: state.gateway?.delegations ?? [], - workspaces: state.gateway?.workspaces ?? [], - observedThreads: state.gateway?.observedThreads ?? [], - pendingWakes: state.gateway?.pendingWakes ?? [], - processedHookEventIds: state.gateway?.processedHookEventIds ?? [], - processedStopHookEventIds: state.gateway?.processedStopHookEventIds ?? [], + toolsVersion: state.workspace?.toolsVersion, + delegations: state.workspace?.delegations ?? [], + workspaces: state.workspace?.workspaces ?? [], + observedThreads: state.workspace?.observedThreads ?? [], + pendingWakes: state.workspace?.pendingWakes ?? [], + processedHookEventIds: state.workspace?.processedHookEventIds ?? [], + processedStopHookEventIds: state.workspace?.processedStopHookEventIds ?? [], }; this.#registerRunner(existing); await this.#persist(); @@ -1360,8 +1365,8 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { if (explicitMainThread) { throw error; } - forceCreateGatewayThread = true; - this.#debug("gateway.session.recreateAfterResumeFailure", { + forceCreateWorkspaceThread = true; + this.#debug("workspace.session.recreateAfterResumeFailure", { codexThreadId: existing.codexThreadId, error: errorMessage(error), }); @@ -1374,13 +1379,13 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } const configuredThreadId = - forceCreateGatewayThread + forceCreateWorkspaceThread ? undefined - : gatewayConfig.mainThreadId ?? - (state.gateway?.toolsVersion === gatewayToolsVersion - ? state.gateway.mainThreadId + : workspaceConfig.mainThreadId ?? + (state.workspace?.toolsVersion === workspaceToolsVersion + ? state.workspace.mainThreadId : undefined); - const title = "Codex Gateway"; + const title = "Codex Workspace"; const started = configuredThreadId ? await this.client.resumeThread(this.#threadResumeParams( configuredThreadId, @@ -1388,56 +1393,56 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { )) : await this.client.startThread({ ...this.#threadStartParams(this.config.cwd), - dynamicTools: gatewayToolSpecs(), + dynamicTools: workspaceToolSpecs(), }); const codexThreadId = started.thread.id; if (!configuredThreadId) { await this.client.setThreadName({ threadId: codexThreadId, - name: "[discord-gateway] Codex Gateway", + name: "[discord-workspace] Codex Workspace", }); } const session: DiscordBridgeSession = { - discordThreadId: gatewayConfig.homeChannelId, - parentChannelId: gatewayConfig.homeChannelId, + discordThreadId: workspaceConfig.homeChannelId, + parentChannelId: workspaceConfig.homeChannelId, codexThreadId, title, createdAt: this.#now().toISOString(), cwd: resumeResponseCwd(started) ?? this.config.cwd, - mode: "gateway", - surfaceKey: this.#primaryGatewaySurface()?.key, + mode: "operator", + surfaceKey: this.#primaryWorkspaceSurface()?.key, }; - state.gateway = { - homeChannelId: gatewayConfig.homeChannelId, + state.workspace = { + homeChannelId: workspaceConfig.homeChannelId, mainThreadId: codexThreadId, createdAt: session.createdAt, toolsVersion: configuredThreadId - ? state.gateway?.toolsVersion - : gatewayToolsVersion, - delegations: state.gateway?.delegations ?? [], - workspaces: state.gateway?.workspaces ?? [], - observedThreads: state.gateway?.observedThreads ?? [], - pendingWakes: state.gateway?.pendingWakes ?? [], - processedHookEventIds: state.gateway?.processedHookEventIds ?? [], - processedStopHookEventIds: state.gateway?.processedStopHookEventIds ?? [], + ? state.workspace?.toolsVersion + : workspaceToolsVersion, + delegations: state.workspace?.delegations ?? [], + workspaces: state.workspace?.workspaces ?? [], + observedThreads: state.workspace?.observedThreads ?? [], + pendingWakes: state.workspace?.pendingWakes ?? [], + processedHookEventIds: state.workspace?.processedHookEventIds ?? [], + processedStopHookEventIds: state.workspace?.processedStopHookEventIds ?? [], }; state.sessions.push(session); this.#registerRunner(session); await this.#persist(); - this.#debug("gateway.session.ready", { - homeChannelId: gatewayConfig.homeChannelId, + this.#debug("workspace.session.ready", { + homeChannelId: workspaceConfig.homeChannelId, codexThreadId, resumed: Boolean(configuredThreadId), }); } - #gatewaySurfaces(): GatewaySurface[] { - const gateway = this.config.gateway; - if (!gateway) { + #workspaceSurfaces(): WorkspaceSurface[] { + const workspace = this.config.workspace; + if (!workspace) { return []; } - if (gateway.surfaces?.length) { - return gateway.surfaces.map((surface) => ({ + if (workspace.surfaces?.length) { + return workspace.surfaces.map((surface) => ({ ...surface, workspaceCwds: surface.workspaceCwds?.map((cwd) => workspaceCwdForPath(cwd, this.config.cwd) @@ -1446,54 +1451,54 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } return [ { - key: defaultGatewaySurfaceKey, - homeChannelId: gateway.homeChannelId, - workspaceForumChannelId: gateway.workspaceForumChannelId, - taskThreadsChannelId: gateway.taskThreadsChannelId, + key: defaultWorkspaceSurfaceKey, + homeChannelId: workspace.homeChannelId, + workspaceForumChannelId: workspace.workspaceForumChannelId, + taskThreadsChannelId: workspace.taskThreadsChannelId, }, ]; } - #primaryGatewaySurface(): GatewaySurface | undefined { - return this.#gatewaySurfaces()[0]; + #primaryWorkspaceSurface(): WorkspaceSurface | undefined { + return this.#workspaceSurfaces()[0]; } - #gatewaySurfaceByKey(key: string | undefined): GatewaySurface | undefined { + #workspaceSurfaceByKey(key: string | undefined): WorkspaceSurface | undefined { return key - ? this.#gatewaySurfaces().find((surface) => surface.key === key) + ? this.#workspaceSurfaces().find((surface) => surface.key === key) : undefined; } - #gatewaySurfaceForHomeChannel(channelId: string): GatewaySurface | undefined { - return this.#gatewaySurfaces().find((surface) => + #workspaceSurfaceForHomeChannel(channelId: string): WorkspaceSurface | undefined { + return this.#workspaceSurfaces().find((surface) => surface.homeChannelId === channelId ); } - #gatewaySurfaceForWorkspaceForumChannel(channelId: string): GatewaySurface | undefined { - return this.#gatewaySurfaces().find((surface) => + #workspaceSurfaceForWorkspaceForumChannel(channelId: string): WorkspaceSurface | undefined { + return this.#workspaceSurfaces().find((surface) => surface.workspaceForumChannelId === channelId ); } - #gatewaySurfaceForTaskThreadsChannel(channelId: string): GatewaySurface | undefined { - return this.#gatewaySurfaces().find((surface) => + #workspaceSurfaceForTaskThreadsChannel(channelId: string): WorkspaceSurface | undefined { + return this.#workspaceSurfaces().find((surface) => surface.taskThreadsChannelId === channelId ); } - #gatewaySurfaceForChannel(channelId: string): GatewaySurface | undefined { - return this.#gatewaySurfaceForHomeChannel(channelId) ?? - this.#gatewaySurfaceForWorkspaceForumChannel(channelId) ?? - this.#gatewaySurfaceForTaskThreadsChannel(channelId) ?? - this.#gatewaySurfaceForWorkspace(this.#workspaceForChannel(channelId)) ?? - this.#gatewaySurfaceForSession(this.#requireState().sessions.find((session) => + #workspaceSurfaceForChannel(channelId: string): WorkspaceSurface | undefined { + return this.#workspaceSurfaceForHomeChannel(channelId) ?? + this.#workspaceSurfaceForWorkspaceForumChannel(channelId) ?? + this.#workspaceSurfaceForTaskThreadsChannel(channelId) ?? + this.#workspaceSurfaceForWorkspace(this.#workspaceForChannel(channelId)) ?? + this.#workspaceSurfaceForSession(this.#requireState().sessions.find((session) => session.discordThreadId === channelId )); } - #gatewaySurfaceForCwd(cwd: string | undefined): GatewaySurface | undefined { - const surfaces = this.#gatewaySurfaces(); + #workspaceSurfaceForCwd(cwd: string | undefined): WorkspaceSurface | undefined { + const surfaces = this.#workspaceSurfaces(); if (surfaces.length === 0) { return undefined; } @@ -1515,79 +1520,84 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { return catchAll ?? surfaces[0]; } - #gatewaySurfaceForWorkspace( - workspace: DiscordGatewayWorkspaceSurface | undefined, - ): GatewaySurface | undefined { + #workspaceSurfaceForWorkspace( + workspace: DiscordWorkspaceWorkspaceSurface | undefined, + ): WorkspaceSurface | undefined { if (!workspace) { return undefined; } - return this.#gatewaySurfaceByKey(workspace.surfaceKey) ?? - this.#gatewaySurfaceForCwd(workspace.cwd); + return this.#workspaceSurfaceByKey(workspace.surfaceKey) ?? + this.#workspaceSurfaceForCwd(workspace.cwd); } - #gatewaySurfaceForDelegation( - delegation: DiscordGatewayDelegation, - ): GatewaySurface | undefined { - return this.#gatewaySurfaceByKey(delegation.surfaceKey) ?? - this.#gatewaySurfaceForCwd(delegation.cwd); + #workspaceSurfaceForDelegation( + delegation: DiscordWorkspaceDelegation, + ): WorkspaceSurface | undefined { + return this.#workspaceSurfaceByKey(delegation.surfaceKey) ?? + this.#workspaceSurfaceForCwd(delegation.cwd); } - #gatewaySurfaceForObserved( - observed: DiscordGatewayObservedThread, - ): GatewaySurface | undefined { - return this.#gatewaySurfaceByKey(observed.surfaceKey) ?? - this.#gatewaySurfaceForCwd(observed.cwd); + #workspaceSurfaceForObserved( + observed: DiscordWorkspaceObservedThread, + ): WorkspaceSurface | undefined { + return this.#workspaceSurfaceByKey(observed.surfaceKey) ?? + this.#workspaceSurfaceForCwd(observed.cwd); } - #gatewaySurfaceForSession( + #workspaceSurfaceForSession( session: DiscordBridgeSession | undefined, - ): GatewaySurface | undefined { + ): WorkspaceSurface | undefined { if (!session) { return undefined; } - return this.#gatewaySurfaceByKey(session.surfaceKey) ?? - this.#gatewaySurfaceForHomeChannel(session.discordThreadId) ?? - this.#gatewaySurfaceForTaskThreadsChannel(session.parentChannelId) ?? - this.#gatewaySurfaceForWorkspaceForumChannel(session.parentChannelId) ?? - this.#gatewaySurfaceForCwd(session.cwd); + return this.#workspaceSurfaceByKey(session.surfaceKey) ?? + this.#workspaceSurfaceForHomeChannel(session.discordThreadId) ?? + this.#workspaceSurfaceForTaskThreadsChannel(session.parentChannelId) ?? + this.#workspaceSurfaceForWorkspaceForumChannel(session.parentChannelId) ?? + this.#workspaceSurfaceForCwd(session.cwd); } - #gatewaySession(): DiscordBridgeSession | undefined { - const gatewayConfig = this.config.gateway; - if (!gatewayConfig) { + #workspaceSession(): DiscordBridgeSession | undefined { + const workspaceConfig = this.config.workspace; + if (!workspaceConfig) { return undefined; } - return this.#requireState().sessions.find((session) => - session.mode === "gateway" && - session.discordThreadId === gatewayConfig.homeChannelId + const state = this.#requireState(); + const expectedCodexThreadId = workspaceConfig.mainThreadId ?? + state.workspace?.mainThreadId; + return state.sessions.find((session) => + (session.mode === "operator" || session.mode === "workspace") && + session.discordThreadId === workspaceConfig.homeChannelId && + session.parentChannelId === workspaceConfig.homeChannelId && + (!expectedCodexThreadId || session.codexThreadId === expectedCodexThreadId) ); } - #gatewayRunner(): DiscordThreadRunner | undefined { - const session = this.#gatewaySession(); + #workspaceRunner(): DiscordThreadRunner | undefined { + const session = this.#workspaceSession(); return session ? this.#runnersByDiscordThread.get(session.discordThreadId) : undefined; } #shouldAutoStartRunner(session: DiscordBridgeSession): boolean { - const workbench = this.#gatewayWorkbenchConfig( - this.#gatewaySurfaceForSession(session), + const workbench = this.#workspaceWorkbenchConfig( + this.#workspaceSurfaceForSession(session), ); return session.parentChannelId !== workbench?.taskThreadsChannelId; } - #isGatewayMainThread(threadId: string): boolean { - const session = this.#gatewaySession(); + #isWorkspaceMainThread(threadId: string): boolean { + const session = this.#workspaceSession(); return Boolean( (session && session.codexThreadId === threadId) || - this.#requireState().gateway?.mainThreadId === threadId, + this.#requireState().workspace?.mainThreadId === threadId, ); } - #gatewayWorkbenchConfig( - surface: GatewaySurface | undefined = this.#primaryGatewaySurface(), - ): GatewayWorkbenchConfig | undefined { + #workspaceWorkbenchConfig( + surface: WorkspaceSurface | undefined = this.#primaryWorkspaceSurface(), + ): WorkspaceWorkbenchConfig | undefined { if (!surface?.workspaceForumChannelId || !surface.taskThreadsChannelId) { return undefined; } @@ -1598,17 +1608,17 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { }; } - #gatewayStopHookSpoolDir(): string { + #workspaceStopHookSpoolDir(): string { return this.config.hookSpoolDir ?? path.join(path.dirname(this.config.statePath), "stop-hooks"); } - #gatewayDelegations(): DiscordGatewayDelegation[] { + #workspaceDelegations(): DiscordWorkspaceDelegation[] { const state = this.#requireState(); - if (!state.gateway) { - state.gateway = { - homeChannelId: this.config.gateway?.homeChannelId ?? "", - mainThreadId: this.#gatewaySession()?.codexThreadId, + if (!state.workspace) { + state.workspace = { + homeChannelId: this.config.workspace?.homeChannelId ?? "", + mainThreadId: this.#workspaceSession()?.codexThreadId, delegations: [], workspaces: [], observedThreads: [], @@ -1617,16 +1627,16 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { processedStopHookEventIds: [], }; } - state.gateway.delegations ??= []; - return state.gateway.delegations; + state.workspace.delegations ??= []; + return state.workspace.delegations; } - #gatewayWorkspaces(): DiscordGatewayWorkspaceSurface[] { + #workspaceWorkspaces(): DiscordWorkspaceWorkspaceSurface[] { const state = this.#requireState(); - if (!state.gateway) { - state.gateway = { - homeChannelId: this.config.gateway?.homeChannelId ?? "", - mainThreadId: this.#gatewaySession()?.codexThreadId, + if (!state.workspace) { + state.workspace = { + homeChannelId: this.config.workspace?.homeChannelId ?? "", + mainThreadId: this.#workspaceSession()?.codexThreadId, delegations: [], workspaces: [], observedThreads: [], @@ -1635,16 +1645,16 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { processedStopHookEventIds: [], }; } - state.gateway.workspaces ??= []; - return state.gateway.workspaces; + state.workspace.workspaces ??= []; + return state.workspace.workspaces; } - #gatewayPendingWakes(): DiscordGatewayPendingWake[] { + #workspacePendingWakes(): DiscordWorkspacePendingWake[] { const state = this.#requireState(); - if (!state.gateway) { - state.gateway = { - homeChannelId: this.config.gateway?.homeChannelId ?? "", - mainThreadId: this.#gatewaySession()?.codexThreadId, + if (!state.workspace) { + state.workspace = { + homeChannelId: this.config.workspace?.homeChannelId ?? "", + mainThreadId: this.#workspaceSession()?.codexThreadId, delegations: [], workspaces: [], observedThreads: [], @@ -1653,16 +1663,16 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { processedStopHookEventIds: [], }; } - state.gateway.pendingWakes ??= []; - return state.gateway.pendingWakes; + state.workspace.pendingWakes ??= []; + return state.workspace.pendingWakes; } - #gatewayObservedThreads(): DiscordGatewayObservedThread[] { + #workspaceObservedThreads(): DiscordWorkspaceObservedThread[] { const state = this.#requireState(); - if (!state.gateway) { - state.gateway = { - homeChannelId: this.config.gateway?.homeChannelId ?? "", - mainThreadId: this.#gatewaySession()?.codexThreadId, + if (!state.workspace) { + state.workspace = { + homeChannelId: this.config.workspace?.homeChannelId ?? "", + mainThreadId: this.#workspaceSession()?.codexThreadId, delegations: [], workspaces: [], observedThreads: [], @@ -1671,16 +1681,16 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { processedStopHookEventIds: [], }; } - state.gateway.observedThreads ??= []; - return state.gateway.observedThreads; + state.workspace.observedThreads ??= []; + return state.workspace.observedThreads; } - #gatewayProcessedHookEventIds(): string[] { + #workspaceProcessedHookEventIds(): string[] { const state = this.#requireState(); - if (!state.gateway) { - state.gateway = { - homeChannelId: this.config.gateway?.homeChannelId ?? "", - mainThreadId: this.#gatewaySession()?.codexThreadId, + if (!state.workspace) { + state.workspace = { + homeChannelId: this.config.workspace?.homeChannelId ?? "", + mainThreadId: this.#workspaceSession()?.codexThreadId, delegations: [], workspaces: [], observedThreads: [], @@ -1689,50 +1699,26 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { processedStopHookEventIds: [], }; } - state.gateway.processedHookEventIds ??= [ - ...(state.gateway.processedStopHookEventIds ?? []), + state.workspace.processedHookEventIds ??= [ + ...(state.workspace.processedStopHookEventIds ?? []), ]; - return state.gateway.processedHookEventIds; + return state.workspace.processedHookEventIds; } - async #startDelegation(args: Record): Promise { - const cwd = requiredArg(args, "cwd"); - const title = stringValue(args.title) ?? firstLine(stringValue(args.prompt)) ?? - `Delegated ${compactId(cwd)}`; - const prompt = stringValue(args.prompt); - const groupId = stringValue(args.groupId); - const returnMode = returnModeFromArgs( - args, - groupId ? "wake_on_group" : "wake_on_done", - ); - const started = await this.client.startThread(this.#threadStartParams(cwd)); - const codexThreadId = started.thread.id; - await this.client.setThreadName({ - threadId: codexThreadId, - name: `[delegated] ${title}`, - }); - const now = this.#now().toISOString(); - const delegation = this.#upsertDelegation({ - id: delegationId(codexThreadId), - codexThreadId, - title, - status: prompt ? "active" : "idle", - cwd, - groupId, - surfaceKey: this.#gatewaySurfaceForCwd(cwd)?.key, - returnMode, - discordDetailThreadId: stringValue(args.discordDetailThreadId), - parentDiscordMessageId: stringValue(args.parentDiscordMessageId), - createdAt: now, - updatedAt: now, - }); - const workbench = await this.#ensureDelegationWorkbench(delegation); - let turnId: string | undefined; - if (prompt) { - const turn = await this.client.startTurn({ - threadId: codexThreadId, + #workspaceDelegationCapability(): WorkspaceDelegationCapability { + return new WorkspaceDelegationCapability({ + client: this.client, + state: { + delegations: this.#workspaceDelegations() as WorkspaceDelegation[], + pendingWakes: this.#workspacePendingWakes() as WorkspacePendingWake[], + }, + now: () => this.#now(), + threadStartParams: (cwd) => this.#threadStartParams(cwd), + threadResumeParams: (threadId, cwd) => this.#threadResumeParams(threadId, cwd), + turnStartParams: ({ threadId, prompt, cwd }) => ({ + threadId, input: [{ type: "text", text: prompt, text_elements: [] }], - cwd, + cwd: cwd ?? null, model: this.config.model ?? null, serviceTier: this.config.serviceTier ?? null, effort: this.config.effort ?? null, @@ -1740,165 +1726,89 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { approvalPolicy: this.config.approvalPolicy ?? null, permissions: this.config.permissions ?? null, outputSchema: null, - }); - turnId = turn.turn.id; - delegation.lastTurnId = turnId; - } + }), + metadataFromArgs: (args) => discordDelegationMetadata(args), + recordResult: async (delegation) => + await this.#recordDelegationResult(delegation as DiscordWorkspaceDelegation), + mirrorResult: async (delegation) => + await this.#mirrorDelegationResult(delegation as DiscordWorkspaceDelegation), + enqueueWake: (input) => this.#enqueueWake(input), + processPendingWakes: async () => await this.#processPendingWakes(), + }); + } + + #applyDiscordDelegationMetadata( + delegation: DiscordWorkspaceDelegation, + args: Record, + ): DiscordWorkspaceDelegation { + delegation.surfaceKey = this.#workspaceSurfaceForCwd( + delegation.cwd ?? this.config.cwd, + )?.key ?? delegation.surfaceKey; + delegation.discordDetailThreadId = stringValue(args.discordDetailThreadId) ?? + discordMetadataString(delegation, "discordDetailThreadId") ?? + delegation.discordDetailThreadId; + delegation.discordTaskThreadId = discordMetadataString( + delegation, + "discordTaskThreadId", + ) ?? delegation.discordTaskThreadId; + delegation.discordWorkspaceThreadId = discordMetadataString( + delegation, + "discordWorkspaceThreadId", + ) ?? delegation.discordWorkspaceThreadId; + delegation.parentDiscordMessageId = stringValue(args.parentDiscordMessageId) ?? + discordMetadataString(delegation, "parentDiscordMessageId") ?? + delegation.parentDiscordMessageId; + return delegation; + } + + async #startDelegation(args: Record): Promise { + const result = await this.#workspaceDelegationCapability().start(args); + const delegation = this.#applyDiscordDelegationMetadata( + result.delegation as DiscordWorkspaceDelegation, + args, + ); + const workbench = await this.#ensureDelegationWorkbench(delegation); await this.#persist(); - return { delegation, turnId, workbench }; + return { delegation, turnId: result.turnId, workbench }; } async #resumeDelegation(args: Record): Promise { - const codexThreadId = requiredArg(args, "threadId"); - const cwd = stringValue(args.cwd); - const groupId = stringValue(args.groupId); - const resumed = await this.client.resumeThread(this.#threadResumeParams(codexThreadId, cwd)); - const now = this.#now().toISOString(); - const delegation = this.#upsertDelegation({ - id: stringValue(args.id) ?? delegationId(codexThreadId), - codexThreadId, - title: stringValue(args.title) ?? `Delegated ${compactId(codexThreadId)}`, - status: "idle", - cwd: cwd ?? resumeResponseCwd(resumed), - surfaceKey: this.#gatewaySurfaceForCwd( - cwd ?? resumeResponseCwd(resumed) ?? this.config.cwd, - )?.key, - groupId, - returnMode: returnModeFromArgs(args, "manual"), - discordDetailThreadId: stringValue(args.discordDetailThreadId), - parentDiscordMessageId: stringValue(args.parentDiscordMessageId), - createdAt: this.#delegationForThread(codexThreadId)?.createdAt ?? now, - updatedAt: now, - }); + const result = await this.#workspaceDelegationCapability().resume(args); + const delegation = this.#applyDiscordDelegationMetadata( + result.delegation as DiscordWorkspaceDelegation, + args, + ); const workbench = await this.#ensureDelegationWorkbench(delegation); await this.#persist(); return { delegation, workbench }; } async #sendDelegation(args: Record): Promise { - const delegation = this.#requireDelegation(args); - const prompt = requiredArg(args, "prompt"); - const groupId = stringValue(args.groupId); - if (groupId) { - delegation.groupId = groupId; - } - delegation.returnMode = returnModeFromArgs( - args, - delegation.returnMode ?? (delegation.groupId ? "wake_on_group" : "wake_on_done"), - ); - const turn = await this.client.startTurn({ - threadId: delegation.codexThreadId, - input: [{ type: "text", text: prompt, text_elements: [] }], - cwd: delegation.cwd ?? null, - model: this.config.model ?? null, - serviceTier: this.config.serviceTier ?? null, - effort: this.config.effort ?? null, - summary: this.config.summary ?? null, - approvalPolicy: this.config.approvalPolicy ?? null, - permissions: this.config.permissions ?? null, - outputSchema: null, - }); - delegation.status = "active"; - delegation.lastTurnId = turn.turn.id; - delegation.lastStatus = undefined; - delegation.lastFinal = undefined; - delegation.completedAt = undefined; - delegation.injectedAt = undefined; - delegation.mirroredAt = undefined; - delegation.taskMirroredAt = undefined; - delegation.reportedAt = undefined; - delegation.updatedAt = this.#now().toISOString(); + const result = await this.#workspaceDelegationCapability().send(args); + const delegation = result.delegation as DiscordWorkspaceDelegation; const workbench = await this.#syncDelegationWorkbench(delegation, { includeTaskResult: false, }); await this.#persist(); - return { delegation, turnId: turn.turn.id, workbench }; + return { delegation, turnId: result.turnId, workbench }; } async #readDelegation(args: Record): Promise { - const delegation = this.#requireDelegation(args); - const response = await this.client.readThread({ - threadId: delegation.codexThreadId, - includeTurns: true, - }); - const snapshot = threadSnapshotFromThread(response.thread); - const turns = Array.isArray(response.thread.turns) ? response.thread.turns : []; - const latest = record(turns[turns.length - 1]); - const latestStatus = stringValue(latest.status); - if (latestStatus === "completed") { - delegation.status = "complete"; - } else if (latestStatus === "failed" || latestStatus === "interrupted") { - delegation.status = "failed"; - } else if (latestStatus) { - delegation.status = "active"; - } - delegation.lastTurnId = stringValue(latest.id) ?? delegation.lastTurnId; - delegation.lastStatus = latestStatus ?? delegation.lastStatus; - delegation.lastFinal = snapshot.lastFinal?.text ?? delegation.lastFinal; - if (latestStatus && isTerminalTurnStatus(latestStatus)) { - delegation.completedAt ??= this.#now().toISOString(); - } - delegation.updatedAt = this.#now().toISOString(); + const result = await this.#workspaceDelegationCapability().read(args); await this.#persist(); - return { - delegation, - latestTurnId: stringValue(latest.id), - latestStatus, - lastFinal: snapshot.lastFinal, - terminalTurnIds: snapshot.terminalTurnIds, - }; + return result; } async #setDelegationPolicy(args: Record): Promise { - const groupId = stringValue(args.groupId); - const mode = returnModeFromArgs(args, undefined); - if (!mode) { - throw new Error("Missing required argument: returnMode"); - } - const delegations = groupId - ? this.#gatewayDelegations().filter((delegation) => delegation.groupId === groupId) - : [this.#requireDelegation(args)]; - if (delegations.length === 0) { - throw new Error("No matching gateway delegations."); - } - const now = this.#now().toISOString(); - for (const delegation of delegations) { - delegation.returnMode = mode; - delegation.updatedAt = now; - } + const result = this.#workspaceDelegationCapability().setPolicy(args); await this.#persist(); - return { delegations }; + return result; } async #flushDelegationResults(args: Record): Promise { - const groupId = stringValue(args.groupId); - const delegations = groupId - ? this.#gatewayDelegations().filter((delegation) => delegation.groupId === groupId) - : stringValue(args.delegationId) || stringValue(args.threadId) || stringValue(args.id) - ? [this.#requireDelegation(args)] - : this.#gatewayDelegations(); - const flushed: DiscordGatewayDelegation[] = []; - for (const delegation of delegations) { - if (!isTerminalDelegation(delegation)) { - continue; - } - await this.#recordDelegationResult(delegation); - await this.#mirrorDelegationResult(delegation); - flushed.push(delegation); - } - if (flushed.length > 0 && stringValue(args.wake) !== "false") { - this.#enqueueWake({ - kind: groupId ? "group" : "delegation", - groupId, - delegationIds: flushed.map((delegation) => delegation.id), - reason: groupId - ? `Delegation group ${groupId} was manually flushed.` - : "Delegation results were manually flushed.", - }); - await this.#processPendingWakes(); - } + const result = await this.#workspaceDelegationCapability().flushResults(args); await this.#persist(); - return { flushed }; + return result; } #delegationGroups(): Array<{ @@ -1908,28 +1818,11 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { terminal: number; pendingWake: boolean; }> { - const groups = new Map(); - for (const delegation of this.#gatewayDelegations()) { - if (!delegation.groupId) { - continue; - } - const existing = groups.get(delegation.groupId) ?? []; - existing.push(delegation); - groups.set(delegation.groupId, existing); - } - return [...groups.entries()].map(([groupId, delegations]) => ({ - groupId, - total: delegations.length, - active: delegations.filter((delegation) => delegation.status === "active").length, - terminal: delegations.filter(isTerminalDelegation).length, - pendingWake: this.#gatewayPendingWakes().some((wake) => - wake.groupId === groupId && !wake.startedAt - ), - })); + return this.#workspaceDelegationCapability().listGroups(); } async #ensureDelegationWorkbench( - delegation: DiscordGatewayDelegation, + delegation: DiscordWorkspaceDelegation, ): Promise { return await this.#syncDelegationWorkbench(delegation, { includeTaskResult: false, @@ -1937,14 +1830,14 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } async #syncDelegationWorkbench( - delegation: DiscordGatewayDelegation, + delegation: DiscordWorkspaceDelegation, options: { includeTaskResult: boolean }, ): Promise { - const surface = this.#gatewaySurfaceForDelegation(delegation); + const surface = this.#workspaceSurfaceForDelegation(delegation); if (surface) { delegation.surfaceKey ??= surface.key; } - const config = this.#gatewayWorkbenchConfig(surface); + const config = this.#workspaceWorkbenchConfig(surface); if (!config) { return { enabled: false }; } @@ -1968,7 +1861,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { }; } catch (error) { const message = errorMessage(error); - this.#debug("gateway.workbench.sync.failed", { + this.#debug("workspace.workbench.sync.failed", { delegationId: delegation.id, codexThreadId: delegation.codexThreadId, error: message, @@ -1979,20 +1872,20 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { async #materializeWorkspaceThread( codexThreadId: string, - input: { author: { id: string }; surface?: GatewaySurface }, + input: { author: { id: string }; surface?: WorkspaceSurface }, ): Promise { const delegation = this.#delegationForThread(codexThreadId); const observed = this.#observedThreadForThread(codexThreadId); let surface = input.surface ?? - (delegation ? this.#gatewaySurfaceForDelegation(delegation) : undefined) ?? - (observed ? this.#gatewaySurfaceForObserved(observed) : undefined); - let config = this.#gatewayWorkbenchConfig(surface); + (delegation ? this.#workspaceSurfaceForDelegation(delegation) : undefined) ?? + (observed ? this.#workspaceSurfaceForObserved(observed) : undefined); + let config = this.#workspaceWorkbenchConfig(surface); const existing = this.#requireState().sessions.find((session) => session.codexThreadId === codexThreadId && (!config || session.parentChannelId === config.taskThreadsChannelId) ); if (existing) { - existing.surfaceKey ??= this.#gatewaySurfaceForSession(existing)?.key; + existing.surfaceKey ??= this.#workspaceSurfaceForSession(existing)?.key; this.#registerRunner(existing).start(); return existing; } @@ -2004,7 +1897,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { const cwd = resumeResponseCwd(resumed) ?? thread?.cwd ?? delegation?.cwd ?? observed?.cwd ?? this.config.cwd; - surface = surface ?? this.#gatewaySurfaceForCwd(cwd); + surface = surface ?? this.#workspaceSurfaceForCwd(cwd); if (surface) { if (delegation) { delegation.surfaceKey ??= surface.key; @@ -2013,9 +1906,9 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { observed.surfaceKey ??= surface.key; } } - config = this.#gatewayWorkbenchConfig(surface); + config = this.#workspaceWorkbenchConfig(surface); if (!config) { - throw new Error("Gateway workbench is not enabled for this surface."); + throw new Error("Workspace workbench is not enabled for this surface."); } const existingForSurface = this.#requireState().sessions.find((session) => session.codexThreadId === codexThreadId && @@ -2061,7 +1954,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } await this.#updateWorkspaceSurface(workspace); await this.#persist(); - this.#debug("gateway.workbench.thread.opened", { + this.#debug("workspace.workbench.thread.opened", { codexThreadId, discordThreadId, workspaceKey: workspace.key, @@ -2069,31 +1962,31 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { return session; } - async #reconcileGatewayWorkbench(): Promise { + async #reconcileWorkspaceWorkbench(): Promise { if ( - this.#gatewaySurfaces().every((surface) => - !this.#gatewayWorkbenchConfig(surface) + this.#workspaceSurfaces().every((surface) => + !this.#workspaceWorkbenchConfig(surface) ) ) { return; } - for (const cwd of await this.#discoverGatewayWorkspaceCwds()) { + for (const cwd of await this.#discoverWorkspaceWorkspaceCwds()) { try { - const surface = this.#gatewaySurfaceForCwd(cwd); - const config = this.#gatewayWorkbenchConfig(surface); + const surface = this.#workspaceSurfaceForCwd(cwd); + const config = this.#workspaceWorkbenchConfig(surface); if (!config) { continue; } const workspace = await this.#ensureWorkspaceSurfaceForCwd(cwd, config); await this.#updateWorkspaceSurface(workspace); } catch (error) { - this.#error("gateway.workbench.workspaceDiscovery.failed", { + this.#error("workspace.workbench.workspaceDiscovery.failed", { cwd, error: errorMessage(error), }); } } - for (const delegation of this.#gatewayDelegations()) { + for (const delegation of this.#workspaceDelegations()) { await this.#syncDelegationWorkbench(delegation, { includeTaskResult: false, }); @@ -2101,13 +1994,13 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { await this.#persist(); } - async #discoverGatewayWorkspaceCwds(): Promise { + async #discoverWorkspaceWorkspaceCwds(): Promise { const root = normalizeWorkspaceCwd(this.config.cwd); let entries: Dirent[]; try { entries = await readdir(root, { withFileTypes: true }); } catch (error) { - this.#debug("gateway.workbench.workspaceDiscovery.skipped", { + this.#debug("workspace.workbench.workspaceDiscovery.skipped", { root, error: errorMessage(error), }); @@ -2142,9 +2035,9 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } async #ensureWorkspaceSurface( - delegation: DiscordGatewayDelegation, - config: GatewayWorkbenchConfig, - ): Promise { + delegation: DiscordWorkspaceDelegation, + config: WorkspaceWorkbenchConfig, + ): Promise { const workspace = await this.#ensureWorkspaceSurfaceForCwd( workspaceCwdForPath(delegation.cwd ?? this.config.cwd, this.config.cwd), config, @@ -2158,17 +2051,17 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { async #ensureWorkspaceSurfaceForCwd( cwd: string, - config: GatewayWorkbenchConfig, - delegations: DiscordGatewayDelegation[] = [], - ): Promise { + config: WorkspaceWorkbenchConfig, + delegations: DiscordWorkspaceDelegation[] = [], + ): Promise { if (!this.presenter.createWorkspacePost) { - throw new Error("Gateway presenter cannot create workspace posts."); + throw new Error("Workspace presenter cannot create workspace posts."); } const normalizedCwd = normalizeWorkspaceCwd(cwd); const key = workspaceKey(normalizedCwd); const now = this.#now().toISOString(); const delegationIds = delegations.map((delegation) => delegation.id); - let workspace = this.#gatewayWorkspaces().find((candidate) => + let workspace = this.#workspaceWorkspaces().find((candidate) => candidate.key === key && (candidate.surfaceKey ?? config.surfaceKey) === config.surfaceKey ); @@ -2200,8 +2093,8 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { createdAt: now, updatedAt: now, }; - this.#gatewayWorkspaces().push(workspace); - this.#debug("gateway.workbench.workspace.created", { + this.#workspaceWorkspaces().push(workspace); + this.#debug("workspace.workbench.workspace.created", { key, cwd: normalizedCwd, discordThreadId: workspace.discordThreadId, @@ -2223,9 +2116,9 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } async #ensureDelegationTaskThread( - delegation: DiscordGatewayDelegation, - workspace: DiscordGatewayWorkspaceSurface, - config: GatewayWorkbenchConfig, + delegation: DiscordWorkspaceDelegation, + workspace: DiscordWorkspaceWorkspaceSurface, + config: WorkspaceWorkbenchConfig, ): Promise { if (!delegation.discordTaskThreadId) { delegation.discordWorkspaceThreadId = workspace.discordThreadId; @@ -2266,7 +2159,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } async #updateWorkspaceSurface( - workspace: DiscordGatewayWorkspaceSurface, + workspace: DiscordWorkspaceWorkspaceSurface, ): Promise { if (!this.presenter.updateMessage) { return; @@ -2274,7 +2167,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { if (!workspace.statusMessageId) { return; } - const delegations = this.#gatewayDelegations().filter((delegation) => + const delegations = this.#workspaceDelegations().filter((delegation) => workspace.delegationIds.includes(delegation.id) ); const threads = this.#listWorkspaceDashboardThreads(workspace); @@ -2292,7 +2185,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } #listWorkspaceDashboardThreads( - workspace: DiscordGatewayWorkspaceSurface, + workspace: DiscordWorkspaceWorkspaceSurface, ): WorkspaceThreadSummary[] { const byId = new Map(); const put = (thread: WorkspaceThreadSummary) => { @@ -2308,9 +2201,9 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { for (const thread of this.#listOpenWorkspaceThreads(workspace)) { put(thread); } - for (const delegation of this.#gatewayDelegations()) { - if (this.#gatewaySurfaceForDelegation(delegation)?.key !== - this.#gatewaySurfaceForWorkspace(workspace)?.key) { + for (const delegation of this.#workspaceDelegations()) { + if (this.#workspaceSurfaceForDelegation(delegation)?.key !== + this.#workspaceSurfaceForWorkspace(workspace)?.key) { continue; } const delegationWorkspaceKey = delegation.workspaceKey ?? @@ -2330,9 +2223,9 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { discordThreadId: delegation.discordTaskThreadId, }); } - for (const observed of this.#gatewayObservedThreads()) { - if (this.#gatewaySurfaceForObserved(observed)?.key !== - this.#gatewaySurfaceForWorkspace(workspace)?.key) { + for (const observed of this.#workspaceObservedThreads()) { + if (this.#workspaceSurfaceForObserved(observed)?.key !== + this.#workspaceSurfaceForWorkspace(workspace)?.key) { continue; } const observedWorkspaceKey = observed.workspaceKey ?? @@ -2351,7 +2244,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { updatedAt: Date.parse(observed.lastSeenAt) / 1000, discordThreadId: this.#workspaceDiscordThreadForCodexThread( observed.threadId, - this.#gatewaySurfaceForWorkspace(workspace), + this.#workspaceSurfaceForWorkspace(workspace), )?.discordThreadId, }); } @@ -2360,15 +2253,15 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } async #listWorkspaceThreads( - workspace: DiscordGatewayWorkspaceSurface, + workspace: DiscordWorkspaceWorkspaceSurface, ): Promise { const byId = new Map(); - const surface = this.#gatewaySurfaceForWorkspace(workspace); + const surface = this.#workspaceSurfaceForWorkspace(workspace); for (const thread of await this.#listCodexThreadSummaries()) { if ( workspaceKey(workspaceCwdForPath(thread.cwd, this.config.cwd)) === workspace.key && - this.#gatewaySurfaceForCwd(thread.cwd)?.key === surface?.key + this.#workspaceSurfaceForCwd(thread.cwd)?.key === surface?.key ) { byId.set(thread.id, { ...thread, @@ -2379,8 +2272,8 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { }); } } - for (const delegation of this.#gatewayDelegations()) { - if (this.#gatewaySurfaceForDelegation(delegation)?.key !== surface?.key) { + for (const delegation of this.#workspaceDelegations()) { + if (this.#workspaceSurfaceForDelegation(delegation)?.key !== surface?.key) { continue; } const delegationWorkspaceKey = delegation.workspaceKey ?? @@ -2400,8 +2293,8 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { discordThreadId: delegation.discordTaskThreadId, }); } - for (const observed of this.#gatewayObservedThreads()) { - if (this.#gatewaySurfaceForObserved(observed)?.key !== surface?.key) { + for (const observed of this.#workspaceObservedThreads()) { + if (this.#workspaceSurfaceForObserved(observed)?.key !== surface?.key) { continue; } const observedWorkspaceKey = observed.workspaceKey ?? @@ -2438,7 +2331,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } async #listWorkspaceGoalSummaries( - workspace: DiscordGatewayWorkspaceSurface, + workspace: DiscordWorkspaceWorkspaceSurface, ): Promise { const threads = (await this.#listWorkspaceThreads(workspace)).slice( 0, @@ -2491,11 +2384,11 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } async #listActiveCodexThreadSummaries( - surface: GatewaySurface | undefined = this.#primaryGatewaySurface(), + surface: WorkspaceSurface | undefined = this.#primaryWorkspaceSurface(), ): Promise { const byId = new Map(); const put = (summary: WorkspaceThreadSummary) => { - if (this.#gatewaySurfaceForCwd(summary.cwd)?.key !== surface?.key) { + if (this.#workspaceSurfaceForCwd(summary.cwd)?.key !== surface?.key) { return; } const existing = byId.get(summary.id); @@ -2536,7 +2429,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { }); } - for (const delegation of this.#gatewayDelegations()) { + for (const delegation of this.#workspaceDelegations()) { if (delegation.status !== "active" && delegation.lastStatus !== "in_progress") { continue; } @@ -2553,7 +2446,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { }); } - for (const observed of this.#gatewayObservedThreads()) { + for (const observed of this.#workspaceObservedThreads()) { if (!isObservedThreadActive(observed)) { continue; } @@ -2571,10 +2464,10 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } #listOpenWorkspaceThreads( - workspace: DiscordGatewayWorkspaceSurface, + workspace: DiscordWorkspaceWorkspaceSurface, ): WorkspaceThreadSummary[] { - const surface = this.#gatewaySurfaceForWorkspace(workspace); - const workbench = this.#gatewayWorkbenchConfig(surface); + const surface = this.#workspaceSurfaceForWorkspace(workspace); + const workbench = this.#workspaceWorkbenchConfig(surface); if (!workbench) { return []; } @@ -2582,7 +2475,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { session.parentChannelId === workbench.taskThreadsChannelId && workspaceKey(workspaceCwdForPath(session.cwd, this.config.cwd)) === workspace.key && - this.#gatewaySurfaceForSession(session)?.key === surface?.key + this.#workspaceSurfaceForSession(session)?.key === surface?.key ); return sessions.map((session) => ({ id: session.codexThreadId, @@ -2612,7 +2505,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { useStateDbOnly: false, }); } catch (error) { - this.#debug("gateway.workbench.threadList.failed", { + this.#debug("workspace.workbench.threadList.failed", { error: errorMessage(error), }); return summaries; @@ -2638,9 +2531,9 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { #workspaceDiscordThreadForCodexThread( codexThreadId: string, - surface?: GatewaySurface, + surface?: WorkspaceSurface, ): DiscordBridgeSession | undefined { - const workbench = this.#gatewayWorkbenchConfig(surface); + const workbench = this.#workspaceWorkbenchConfig(surface); return this.#requireState().sessions.find((session) => session.codexThreadId === codexThreadId && session.parentChannelId === workbench?.taskThreadsChannelId @@ -2649,26 +2542,26 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { #discordChannelForCodexThread( codexThreadId: string, - surface: GatewaySurface | undefined = this.#primaryGatewaySurface(), + surface: WorkspaceSurface | undefined = this.#primaryWorkspaceSurface(), ): string | undefined { - if (this.#isGatewayMainThread(codexThreadId)) { - return surface?.homeChannelId ?? this.config.gateway?.homeChannelId; + if (this.#isWorkspaceMainThread(codexThreadId)) { + return surface?.homeChannelId ?? this.config.workspace?.homeChannelId; } const session = this.#requireState().sessions.find((candidate) => candidate.codexThreadId === codexThreadId && - this.#gatewaySurfaceForSession(candidate)?.key === surface?.key + this.#workspaceSurfaceForSession(candidate)?.key === surface?.key ); const delegation = this.#delegationForThread(codexThreadId); const delegationChannel = delegation && - this.#gatewaySurfaceForDelegation(delegation)?.key === surface?.key + this.#workspaceSurfaceForDelegation(delegation)?.key === surface?.key ? delegation.discordTaskThreadId ?? delegation.discordDetailThreadId : undefined; return session?.discordThreadId ?? delegationChannel; } - #workspaceForChannel(channelId: string): DiscordGatewayWorkspaceSurface | undefined { - const workspaces = this.#requireState().gateway?.workspaces ?? []; + #workspaceForChannel(channelId: string): DiscordWorkspaceWorkspaceSurface | undefined { + const workspaces = this.#requireState().workspace?.workspaces ?? []; const direct = workspaces.find((workspace) => workspace.discordThreadId === channelId ); @@ -2682,10 +2575,10 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { return undefined; } const key = workspaceKey(workspaceCwdForPath(session.cwd, this.config.cwd)); - const surface = this.#gatewaySurfaceForSession(session); + const surface = this.#workspaceSurfaceForSession(session); return workspaces.find((workspace) => workspace.key === key && - this.#gatewaySurfaceForWorkspace(workspace)?.key === surface?.key + this.#workspaceSurfaceForWorkspace(workspace)?.key === surface?.key ); } @@ -2695,7 +2588,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { ); if ( !session || - session.mode === "gateway" || + session.mode === "operator" || session.discordThreadId === session.parentChannelId ) { return undefined; @@ -2705,13 +2598,13 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { #workspaceForGoalSession( session: DiscordBridgeSession, - ): DiscordGatewayWorkspaceSurface { + ): DiscordWorkspaceWorkspaceSurface { const existing = this.#workspaceForChannel(session.discordThreadId); if (existing) { return existing; } const cwd = workspaceCwdForPath(session.cwd, this.config.cwd); - const surface = this.#gatewaySurfaceForSession(session); + const surface = this.#workspaceSurfaceForSession(session); return { key: workspaceKey(cwd), surfaceKey: surface?.key, @@ -2726,14 +2619,14 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { #workspaceForumForChannel( channelId: string, - ): DiscordGatewayWorkspaceSurface | undefined { - return this.#requireState().gateway?.workspaces?.find((workspace) => + ): DiscordWorkspaceWorkspaceSurface | undefined { + return this.#requireState().workspace?.workspaces?.find((workspace) => workspace.discordThreadId === channelId ); } async #mirrorDelegationResultToTaskThread( - delegation: DiscordGatewayDelegation, + delegation: DiscordWorkspaceDelegation, ): Promise { if ( !delegation.discordTaskThreadId || @@ -2749,7 +2642,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { ); const deliveredAt = this.#now().toISOString(); this.#requireState().deliveries.push({ - discordMessageId: `gateway-workbench:${delegation.id}:${delegation.lastTurnId ?? "latest"}`, + discordMessageId: `workspace-workbench:${delegation.id}:${delegation.lastTurnId ?? "latest"}`, discordThreadId: delegation.discordTaskThreadId, codexThreadId: delegation.codexThreadId, turnId: delegation.lastTurnId, @@ -2761,7 +2654,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { delegation.updatedAt = deliveredAt; } - #hasDelegationTaskFinalDelivery(delegation: DiscordGatewayDelegation): boolean { + #hasDelegationTaskFinalDelivery(delegation: DiscordWorkspaceDelegation): boolean { if (!delegation.discordTaskThreadId) { return false; } @@ -2773,85 +2666,85 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { ); } - async #startGatewayStopHookSpool(): Promise { - if (!this.config.gateway || this.#gatewayStopHookWatcher) { + async #startWorkspaceStopHookSpool(): Promise { + if (!this.config.workspace || this.#workspaceStopHookWatcher) { return; } - const spoolDir = this.#gatewayStopHookSpoolDir(); + const spoolDir = this.#workspaceStopHookSpoolDir(); await ensureStopHookSpool(spoolDir); const pendingDir = stopHookSpoolPaths(spoolDir).pending; - this.#gatewayStopHookWatcher = watch(pendingDir, { persistent: false }, () => { - this.#scheduleGatewayStopHookDrain(); + this.#workspaceStopHookWatcher = watch(pendingDir, { persistent: false }, () => { + this.#scheduleWorkspaceStopHookDrain(); }); - this.#gatewayStopHookWatcher.on("error", (error) => { - this.#debug("gateway.stopHook.watch.failed", { + this.#workspaceStopHookWatcher.on("error", (error) => { + this.#debug("workspace.stopHook.watch.failed", { error: errorMessage(error), }); }); - await this.#drainGatewayStopHookSpool(); + await this.#drainWorkspaceStopHookSpool(); } - #scheduleGatewayStopHookDrain(delayMs = stopHookDrainDebounceMs): void { - if (!this.config.gateway) { + #scheduleWorkspaceStopHookDrain(delayMs = stopHookDrainDebounceMs): void { + if (!this.config.workspace) { return; } - if (this.#gatewayStopHookDrainTimer) { - clearTimeout(this.#gatewayStopHookDrainTimer); + if (this.#workspaceStopHookDrainTimer) { + clearTimeout(this.#workspaceStopHookDrainTimer); } - this.#gatewayStopHookDrainTimer = setTimeout(() => { - this.#gatewayStopHookDrainTimer = undefined; - void this.#drainGatewayStopHookSpool().catch((error) => { - this.#debug("gateway.stopHook.drain.failed", { + this.#workspaceStopHookDrainTimer = setTimeout(() => { + this.#workspaceStopHookDrainTimer = undefined; + void this.#drainWorkspaceStopHookSpool().catch((error) => { + this.#debug("workspace.stopHook.drain.failed", { error: errorMessage(error), }); }); }, delayMs); - this.#gatewayStopHookDrainTimer.unref?.(); + this.#workspaceStopHookDrainTimer.unref?.(); } - async #drainGatewayStopHookSpool(): Promise { - const drain = this.#gatewayStopHookDrainChain + async #drainWorkspaceStopHookSpool(): Promise { + const drain = this.#workspaceStopHookDrainChain .catch(() => undefined) - .then(() => this.#drainGatewayStopHookSpoolOnce()); - this.#gatewayStopHookDrainChain = drain.catch(() => undefined); + .then(() => this.#drainWorkspaceStopHookSpoolOnce()); + this.#workspaceStopHookDrainChain = drain.catch(() => undefined); await drain; } - async #drainGatewayStopHookSpoolOnce(): Promise { - if (!this.config.gateway) { + async #drainWorkspaceStopHookSpoolOnce(): Promise { + if (!this.config.workspace) { return; } - const spoolDir = this.#gatewayStopHookSpoolDir(); + const spoolDir = this.#workspaceStopHookSpoolDir(); const files = await readPendingStopHookSpoolFiles(spoolDir); let shouldRetry = false; for (const file of files) { if ("error" in file) { - this.#debug("gateway.stopHook.file.invalid", { + this.#debug("workspace.stopHook.file.invalid", { fileName: file.fileName, error: file.error.message, }); await archiveStopHookSpoolFile(file, spoolDir, "failed"); continue; } - const processedIds = this.#gatewayProcessedHookEventIds(); + const processedIds = this.#workspaceProcessedHookEventIds(); if (processedIds.includes(file.event.id)) { await archiveStopHookSpoolFile(file, spoolDir, "ignored"); continue; } - const result = await this.#handleGatewayHookEvent(file.event); + const result = await this.#handleWorkspaceHookEvent(file.event); if (result === "retry") { shouldRetry = true; continue; } processedIds.push(file.event.id); if (file.event.eventName === "Stop") { - const gateway = this.#requireState().gateway; - const stopIds = gateway?.processedStopHookEventIds ?? []; + const workspace = this.#requireState().workspace; + const stopIds = workspace?.processedStopHookEventIds ?? []; if (!stopIds.includes(file.event.id)) { stopIds.push(file.event.id); } - if (gateway) { - gateway.processedStopHookEventIds = stopIds; + if (workspace) { + workspace.processedStopHookEventIds = stopIds; } } await this.#persist(); @@ -2862,26 +2755,26 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { ); } if (shouldRetry) { - this.#scheduleGatewayStopHookDrain(stopHookRetryMs); + this.#scheduleWorkspaceStopHookDrain(stopHookRetryMs); } } - async #handleGatewayHookEvent( - event: DiscordGatewayHookEvent, + async #handleWorkspaceHookEvent( + event: DiscordWorkspaceHookEvent, ): Promise<"processed" | "ignored" | "retry"> { - const isGatewayMain = this.#isGatewayMainThread(event.sessionId); - if (!isGatewayMain) { + const isWorkspaceMain = this.#isWorkspaceMainThread(event.sessionId); + if (!isWorkspaceMain) { await this.#recordObservedThreadEvent(event); } if (event.eventName !== "Stop") { return "processed"; } - if (isGatewayMain) { + if (isWorkspaceMain) { const started = await this.#processPendingWakes({ completedThreadId: event.sessionId, completedTurnId: event.turnId, }); - return started || !this.#gatewayPendingWakes().some((wake) => !wake.startedAt) + return started || !this.#workspacePendingWakes().some((wake) => !wake.startedAt) ? "processed" : "retry"; } @@ -2903,9 +2796,9 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } async #recordObservedThreadEvent( - event: DiscordGatewayHookEvent, + event: DiscordWorkspaceHookEvent, ): Promise { - const observedThreads = this.#gatewayObservedThreads(); + const observedThreads = this.#workspaceObservedThreads(); const seenAt = event.createdAt || this.#now().toISOString(); let observed = observedThreads.find((thread) => thread.threadId === event.sessionId @@ -2923,7 +2816,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } const cwd = event.cwd ?? observed.cwd; - const surface = this.#gatewaySurfaceForCwd(cwd); + const surface = this.#workspaceSurfaceForCwd(cwd); observed.status = observedStatusForHookEvent(event); observed.cwd = cwd; observed.workspaceKey = cwd @@ -2950,7 +2843,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { observed.lastSeenAt = seenAt; observed.updatedAt = seenAt; - const config = this.#gatewayWorkbenchConfig(surface); + const config = this.#workspaceWorkbenchConfig(surface); if (config && cwd) { const workspace = await this.#ensureWorkspaceSurfaceForCwd( workspaceCwdForPath(cwd, this.config.cwd), @@ -2959,7 +2852,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { try { await this.#updateWorkspaceSurface(workspace); } catch (error) { - this.#debug("gateway.observed.workspaceUpdate.failed", { + this.#debug("workspace.observed.workspaceUpdate.failed", { workspaceKey: workspace.key, threadId: observed.threadId, error: errorMessage(error), @@ -2969,7 +2862,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } async #applyDelegationReturnPolicy( - delegation: DiscordGatewayDelegation, + delegation: DiscordWorkspaceDelegation, ): Promise { if (!isTerminalDelegation(delegation)) { return; @@ -2988,7 +2881,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { }); } if (mode === "wake_on_group" && delegation.groupId) { - const group = this.#gatewayDelegations().filter((candidate) => + const group = this.#workspaceDelegations().filter((candidate) => candidate.groupId === delegation.groupId ); if (group.length > 0 && group.every(isTerminalDelegation)) { @@ -3002,13 +2895,13 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } } - async #recordDelegationResult(delegation: DiscordGatewayDelegation): Promise { - const gatewaySession = this.#gatewaySession(); - if (!gatewaySession || delegation.injectedAt) { + async #recordDelegationResult(delegation: DiscordWorkspaceDelegation): Promise { + const workspaceSession = this.#workspaceSession(); + if (!workspaceSession || delegation.injectedAt) { return; } await this.client.injectThreadItems({ - threadId: gatewaySession.codexThreadId, + threadId: workspaceSession.codexThreadId, items: [ { type: "message", @@ -3026,10 +2919,10 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { delegation.updatedAt = delegation.injectedAt; } - async #mirrorDelegationResult(delegation: DiscordGatewayDelegation): Promise { - const surface = this.#gatewaySurfaceForDelegation(delegation); + async #mirrorDelegationResult(delegation: DiscordWorkspaceDelegation): Promise { + const surface = this.#workspaceSurfaceForDelegation(delegation); const homeChannelId = surface?.homeChannelId ?? - (this.config.gateway?.surfaces?.length ? undefined : this.config.gateway?.homeChannelId); + (this.config.workspace?.surfaces?.length ? undefined : this.config.workspace?.homeChannelId); if (!homeChannelId || delegation.mirroredAt) { return; } @@ -3039,7 +2932,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { ); await this.presenter.sendMessage( homeChannelId, - this.#gatewayWorkbenchConfig(surface) && hasWorkbenchLinks + this.#workspaceWorkbenchConfig(surface) && hasWorkbenchLinks ? compactDelegationResultText(delegation) : delegationResultText(delegation), ); @@ -3048,7 +2941,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } #enqueueWake(input: { - kind: DiscordGatewayPendingWake["kind"]; + kind: DiscordWorkspacePendingWake["kind"]; delegationIds: string[]; groupId?: string; reason: string; @@ -3057,7 +2950,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { if (delegationIds.length === 0) { return; } - const wakes = this.#gatewayPendingWakes(); + const wakes = this.#workspacePendingWakes(); if (wakes.some((wake) => wake.kind === input.kind && wake.groupId === input.groupId && @@ -3079,24 +2972,24 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { completedThreadId?: string; completedTurnId?: string; } = {}): Promise { - const gatewaySession = this.#gatewaySession(); + const workspaceSession = this.#workspaceSession(); if ( - !gatewaySession || - this.#isSessionRunning(gatewaySession, this.#requireState(), options) + !workspaceSession || + this.#isSessionRunning(workspaceSession, this.#requireState(), options) ) { return false; } - const wake = this.#gatewayPendingWakes().find((candidate) => !candidate.startedAt); + const wake = this.#workspacePendingWakes().find((candidate) => !candidate.startedAt); if (!wake) { return false; } - const prompt = wakePrompt(wake, this.#gatewayDelegations()); + const prompt = wakePrompt(wake, this.#workspaceDelegations()); let turn: v2.TurnStartResponse; try { turn = await this.client.startTurn({ - threadId: gatewaySession.codexThreadId, + threadId: workspaceSession.codexThreadId, input: [{ type: "text", text: prompt, text_elements: [] }], - cwd: gatewaySession.cwd ?? this.config.cwd ?? null, + cwd: workspaceSession.cwd ?? this.config.cwd ?? null, model: this.config.model ?? null, serviceTier: this.config.serviceTier ?? null, effort: this.config.effort ?? null, @@ -3107,7 +3000,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { }); } catch (error) { if (errorMessage(error).includes("already has an active turn")) { - this.#debug("gateway.wake.deferred.activeTurn", { + this.#debug("workspace.wake.deferred.activeTurn", { wakeId: wake.id, error: errorMessage(error), }); @@ -3116,13 +3009,13 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { throw error; } wake.startedAt = this.#now().toISOString(); - for (const delegation of this.#gatewayDelegations()) { + for (const delegation of this.#workspaceDelegations()) { if (wake.delegationIds.includes(delegation.id)) { delegation.reportedAt = wake.startedAt; delegation.updatedAt = wake.startedAt; } } - this.#debug("gateway.wake.started", { + this.#debug("workspace.wake.started", { wakeId: wake.id, turnId: turn.turn.id, kind: wake.kind, @@ -3165,43 +3058,16 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { return this.#flowBackendClient; } - #upsertDelegation(input: DiscordGatewayDelegation): DiscordGatewayDelegation { - const delegations = this.#gatewayDelegations(); - const index = delegations.findIndex((delegation) => - delegation.id === input.id || - delegation.codexThreadId === input.codexThreadId - ); - if (index >= 0) { - delegations[index] = { ...delegations[index], ...input }; - return delegations[index] as DiscordGatewayDelegation; - } - delegations.push(input); - return input; - } - - #requireDelegation(args: Record): DiscordGatewayDelegation { - const id = stringValue(args.delegationId) ?? stringValue(args.id); - const threadId = stringValue(args.threadId); - const delegation = this.#gatewayDelegations().find((candidate) => - (id && candidate.id === id) || - (threadId && candidate.codexThreadId === threadId) - ); - if (!delegation) { - throw new Error("Unknown gateway delegation."); - } - return delegation; - } - - #delegationForThread(threadId: string): DiscordGatewayDelegation | undefined { - return this.#gatewayDelegations().find((delegation) => + #delegationForThread(threadId: string): DiscordWorkspaceDelegation | undefined { + return this.#workspaceDelegations().find((delegation) => delegation.codexThreadId === threadId ); } #observedThreadForThread( threadId: string, - ): DiscordGatewayObservedThread | undefined { - return this.#gatewayObservedThreads().find((thread) => + ): DiscordWorkspaceObservedThread | undefined { + return this.#workspaceObservedThreads().find((thread) => thread.threadId === threadId ); } @@ -3214,10 +3080,10 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { completedTurnId?: string; } = {}, ): boolean { - const isGateway = session.mode === "gateway"; + const isWorkspace = session.mode === "operator"; const hasActiveTurn = state.activeTurns.some( (active) => - (isGateway || active.discordThreadId === session.discordThreadId) && + (isWorkspace || active.discordThreadId === session.discordThreadId) && active.codexThreadId === session.codexThreadId && !( active.codexThreadId === options.completedThreadId && @@ -3229,7 +3095,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { } return state.queue.some( (item) => - (isGateway || item.discordThreadId === session.discordThreadId) && + (isWorkspace || item.discordThreadId === session.discordThreadId) && item.codexThreadId === session.codexThreadId && item.status !== "failed" && !( @@ -3241,9 +3107,9 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { #isAllowedChannel(channelId: string): boolean { if ( - this.#gatewaySurfaceForHomeChannel(channelId) || - this.#gatewaySurfaceForWorkspaceForumChannel(channelId) || - this.#gatewaySurfaceForTaskThreadsChannel(channelId) + this.#workspaceSurfaceForHomeChannel(channelId) || + this.#workspaceSurfaceForWorkspaceForumChannel(channelId) || + this.#workspaceSurfaceForTaskThreadsChannel(channelId) ) { return true; } @@ -3254,7 +3120,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { return true; } if ( - this.#requireState().gateway?.workspaces?.some((workspace) => + this.#requireState().workspace?.workspaces?.some((workspace) => workspace.discordThreadId === channelId ) ) { @@ -3263,8 +3129,8 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { const session = this.#requireState().sessions.find( (candidate) => candidate.discordThreadId === channelId, ); - const workbench = this.#gatewayWorkbenchConfig( - this.#gatewaySurfaceForSession(session), + const workbench = this.#workspaceWorkbenchConfig( + this.#workspaceSurfaceForSession(session), ); return Boolean( session && @@ -3277,7 +3143,7 @@ export class LocalCodexGatewayBackend implements CodexGatewayBackend { #commandRegistrationChannelIds(): string[] { return uniqueStringList([ ...this.config.allowedChannelIds, - ...this.#gatewaySurfaces().flatMap((surface) => [ + ...this.#workspaceSurfaces().flatMap((surface) => [ surface.homeChannelId, surface.workspaceForumChannelId ?? "", surface.taskThreadsChannelId ?? "", @@ -3689,16 +3555,16 @@ function isDuplicate(state: DiscordBridgeState, messageId: string): boolean { ); } -function gatewayToolSpecs(): v2.DynamicToolSpec[] { +function workspaceToolSpecs(): v2.DynamicToolSpec[] { return [ { - namespace: "codex_gateway", + namespace: "codex_workspace", name: "list_delegations", - description: "List delegated Codex sessions tracked by the Discord gateway.", + description: "List delegated Codex sessions tracked by the Discord workspace.", inputSchema: objectSchema({}), }, { - namespace: "codex_gateway", + namespace: "codex_workspace", name: "start_delegation", description: "Start a delegated Codex session in a cwd and optionally start its first turn.", inputSchema: objectSchema({ @@ -3712,7 +3578,7 @@ function gatewayToolSpecs(): v2.DynamicToolSpec[] { }, ["cwd"]), }, { - namespace: "codex_gateway", + namespace: "codex_workspace", name: "resume_delegation", description: "Register an existing Codex thread as delegated work.", inputSchema: objectSchema({ @@ -3726,7 +3592,7 @@ function gatewayToolSpecs(): v2.DynamicToolSpec[] { }, ["threadId"]), }, { - namespace: "codex_gateway", + namespace: "codex_workspace", name: "send_delegation", description: "Send a prompt as a new turn to a tracked delegated Codex session.", inputSchema: objectSchema({ @@ -3738,7 +3604,7 @@ function gatewayToolSpecs(): v2.DynamicToolSpec[] { }, ["prompt"]), }, { - namespace: "codex_gateway", + namespace: "codex_workspace", name: "read_delegation", description: "Read and summarize a tracked delegated Codex session.", inputSchema: objectSchema({ @@ -3747,7 +3613,7 @@ function gatewayToolSpecs(): v2.DynamicToolSpec[] { }), }, { - namespace: "codex_gateway", + namespace: "codex_workspace", name: "set_delegation_policy", description: "Update return policy for one delegation or every delegation in a group.", inputSchema: objectSchema({ @@ -3758,7 +3624,7 @@ function gatewayToolSpecs(): v2.DynamicToolSpec[] { }, ["returnMode"]), }, { - namespace: "codex_gateway", + namespace: "codex_workspace", name: "flush_delegation_results", description: "Manually inject and mirror completed delegation results, optionally waking the main operator.", inputSchema: objectSchema({ @@ -3769,15 +3635,15 @@ function gatewayToolSpecs(): v2.DynamicToolSpec[] { }), }, { - namespace: "codex_gateway", + namespace: "codex_workspace", name: "list_delegation_groups", description: "List delegation groups and their terminal/active counts.", inputSchema: objectSchema({}), }, { - namespace: "codex_gateway", + namespace: "codex_workspace", name: "list_flow_runs", - description: "List runs from the configured codex-flow-systemd-local backend.", + description: "List runs from the configured workspace flow backend.", inputSchema: objectSchema({ eventId: optionalStringSchema("Optional event id filter."), status: optionalStringSchema("Optional run status filter."), @@ -3785,9 +3651,9 @@ function gatewayToolSpecs(): v2.DynamicToolSpec[] { }), }, { - namespace: "codex_gateway", + namespace: "codex_workspace", name: "list_flow_events", - description: "List events from the configured codex-flow-systemd-local backend.", + description: "List events from the configured workspace flow backend.", inputSchema: objectSchema({ type: optionalStringSchema("Optional event type filter."), limit: optionalStringSchema("Optional max result count."), @@ -3816,49 +3682,38 @@ function optionalStringSchema(description: string): JsonValue { return stringSchema(description); } -function requiredArg(args: Record, name: string): string { - const value = stringValue(args[name]); - if (!value) { - throw new Error(`Missing required argument: ${name}`); - } - return value; -} - -function returnModeFromArgs( +function discordDelegationMetadata( args: Record, - fallback: DiscordGatewayDelegationReturnMode | undefined, -): DiscordGatewayDelegationReturnMode | undefined { - const value = stringValue(args.returnMode) ?? stringValue(args.returnPolicy); - if (!value) { - return fallback; - } - if (value === "immediate") { - return "wake_on_done"; - } - if (value === "group_barrier") { - return "wake_on_group"; - } - if ( - value === "detached" || - value === "record_only" || - value === "wake_on_done" || - value === "wake_on_group" || - value === "manual" - ) { - return value; - } - throw new Error(`Invalid returnMode: ${value}`); +): Record | undefined { + const metadata = { + kind: "discord", + discordDetailThreadId: stringValue(args.discordDetailThreadId), + discordTaskThreadId: stringValue(args.discordTaskThreadId), + discordWorkspaceThreadId: stringValue(args.discordWorkspaceThreadId), + parentDiscordMessageId: stringValue(args.parentDiscordMessageId), + }; + return Object.values(metadata).some((value) => value !== undefined) + ? metadata + : undefined; } -function isTerminalDelegation(delegation: DiscordGatewayDelegation): boolean { +function discordMetadataString( + delegation: DiscordWorkspaceDelegation, + key: string, +): string | undefined { + const metadata = record((delegation as { metadata?: unknown }).metadata); + return metadata.kind === "discord" ? stringValue(metadata[key]) : undefined; +} + +function isTerminalDelegation(delegation: DiscordWorkspaceDelegation): boolean { return delegation.status === "complete" || delegation.status === "failed" || delegation.status === "reported"; } -function delegationResultText(delegation: DiscordGatewayDelegation): string { +function delegationResultText(delegation: DiscordWorkspaceDelegation): string { return [ - "[discord-gateway delegation result]", + "[discord-workspace delegation result]", `Delegation: ${delegation.title}`, `Delegation ID: ${delegation.id}`, `Thread: ${delegation.codexThreadId}`, @@ -3872,7 +3727,7 @@ function delegationResultText(delegation: DiscordGatewayDelegation): string { ].filter((line): line is string => line !== undefined).join("\n"); } -function delegationTaskResultText(delegation: DiscordGatewayDelegation): string { +function delegationTaskResultText(delegation: DiscordWorkspaceDelegation): string { return [ "**Delegation Result**", `Delegation: ${delegation.title}`, @@ -3885,7 +3740,7 @@ function delegationTaskResultText(delegation: DiscordGatewayDelegation): string ].filter((line): line is string => line !== undefined).join("\n"); } -function compactDelegationResultText(delegation: DiscordGatewayDelegation): string { +function compactDelegationResultText(delegation: DiscordWorkspaceDelegation): string { const links = [ delegation.discordWorkspaceThreadId ? `workspace <#${delegation.discordWorkspaceThreadId}>` @@ -3895,7 +3750,7 @@ function compactDelegationResultText(delegation: DiscordGatewayDelegation): stri : undefined, ].filter((link): link is string => link !== undefined).join(", "); return [ - "[discord-gateway delegation result]", + "[discord-workspace delegation result]", `${delegation.title}: ${delegation.lastStatus ?? delegation.status}`, delegation.groupId ? `Group: ${delegation.groupId}` : undefined, links ? `Links: ${links}` : undefined, @@ -3904,9 +3759,9 @@ function compactDelegationResultText(delegation: DiscordGatewayDelegation): stri } function workspaceDashboardText( - workspace: DiscordGatewayWorkspaceSurface, + workspace: DiscordWorkspaceWorkspaceSurface, options: { - delegations?: DiscordGatewayDelegation[]; + delegations?: DiscordWorkspaceDelegation[]; threads?: WorkspaceThreadSummary[]; } = {}, ): string { @@ -3959,7 +3814,7 @@ function activeThreadStatusLines( } function goalPickerText( - workspace: DiscordGatewayWorkspaceSurface, + workspace: DiscordWorkspaceWorkspaceSurface, entries: WorkspaceGoalSummary[], total: number, ): string { @@ -3979,7 +3834,7 @@ function goalPickerText( } function goalActionText( - workspace: DiscordGatewayWorkspaceSurface, + workspace: DiscordWorkspaceWorkspaceSurface, entry: WorkspaceGoalSummary, options: { prefix?: string } = {}, ): string { @@ -4053,7 +3908,7 @@ function goalSummaryText(entry: WorkspaceGoalSummary): string { } function threadPickerText( - workspace: DiscordGatewayWorkspaceSurface, + workspace: DiscordWorkspaceWorkspaceSurface, threads: WorkspaceThreadSummary[], total: number, options: { action?: string } = {}, @@ -4112,7 +3967,7 @@ function threadStatusText(status: v2.ThreadStatus): string { return status.type === "active" ? "active" : status.type; } -function observedThreadStatusText(thread: DiscordGatewayObservedThread): string { +function observedThreadStatusText(thread: DiscordWorkspaceObservedThread): string { if (thread.status === "waiting" && thread.permissionDescription) { return `waiting: ${thread.permissionDescription}`; } @@ -4122,7 +3977,7 @@ function observedThreadStatusText(thread: DiscordGatewayObservedThread): string return thread.status; } -function isObservedThreadActive(thread: DiscordGatewayObservedThread): boolean { +function isObservedThreadActive(thread: DiscordWorkspaceObservedThread): boolean { return thread.status === "starting" || thread.status === "active" || thread.status === "tool" || @@ -4130,8 +3985,8 @@ function isObservedThreadActive(thread: DiscordGatewayObservedThread): boolean { } function observedStatusForHookEvent( - event: DiscordGatewayHookEvent, -): DiscordGatewayObservedThread["status"] { + event: DiscordWorkspaceHookEvent, +): DiscordWorkspaceObservedThread["status"] { if (event.eventName === "SessionStart") { return "starting"; } @@ -4148,8 +4003,8 @@ function observedStatusForHookEvent( } function observedThreadTitle( - event: DiscordGatewayHookEvent, - existing?: DiscordGatewayObservedThread, + event: DiscordWorkspaceHookEvent, + existing?: DiscordWorkspaceObservedThread, ): string { return firstLine(event.promptPreview)?.trim() || firstLine(event.lastAssistantMessage)?.trim() || @@ -4201,8 +4056,8 @@ function isDiscoverableWorkspaceEntry(name: string): boolean { } function wakePrompt( - wake: DiscordGatewayPendingWake, - delegations: DiscordGatewayDelegation[], + wake: DiscordWorkspacePendingWake, + delegations: DiscordWorkspaceDelegation[], ): string { const matching = delegations.filter((delegation) => wake.delegationIds.includes(delegation.id) @@ -4211,7 +4066,7 @@ function wakePrompt( `- ${delegation.title} (${delegation.id}): ${delegation.lastStatus ?? delegation.status}` ).join("\n"); return [ - "[discord-gateway wake]", + "[discord-workspace wake]", wake.reason, wake.groupId ? `Group: ${wake.groupId}` : undefined, "", @@ -4230,7 +4085,7 @@ function sameStringSet(left: string[], right: string[]): boolean { } function wakeId( - kind: DiscordGatewayPendingWake["kind"], + kind: DiscordWorkspacePendingWake["kind"], groupId: string | undefined, delegationIds: string[], ): string { @@ -4264,10 +4119,6 @@ function compactId(value: string): string { return value.length > 14 ? `${value.slice(0, 6)}...${value.slice(-6)}` : value; } -function delegationId(threadId: string): string { - return `delegation-${createHash("sha256").update(threadId).digest("hex").slice(0, 12)}`; -} - function clearSummary(input: { deleted: number; running: number; diff --git a/apps/discord-bridge/src/runner.ts b/apps/discord-bridge/src/runner.ts index 35e1b0c..d820a0b 100644 --- a/apps/discord-bridge/src/runner.ts +++ b/apps/discord-bridge/src/runner.ts @@ -1,6 +1,6 @@ import type { JsonRpcNotification } from "@peezy.tech/codex-flows/rpc"; import type { v2 } from "@peezy.tech/codex-flows/generated"; -import type { CodexGatewayPresenter } from "./gateway-backend.ts"; +import type { CodexWorkspacePresenter } from "./workspace-backend.ts"; import type { DiscordConsoleMessageKind, @@ -25,7 +25,7 @@ const runningCommandStatusDelayMs = 5_000; export type ThreadRunnerContext = { client: CodexBridgeClient; - presenter: CodexGatewayPresenter; + presenter: CodexWorkspacePresenter; config: DiscordBridgeConfig; getState(): DiscordBridgeState; persist(): Promise; @@ -999,7 +999,7 @@ export class DiscordThreadRunner { this.#state().deliveries .filter( (delivery) => - (this.session.mode === "gateway" || + (this.session.mode === "operator" || delivery.discordThreadId === this.session.discordThreadId) && delivery.codexThreadId === this.session.codexThreadId && delivery.kind === "final" && @@ -1565,7 +1565,7 @@ export class DiscordThreadRunner { #hasDelivery(turnId: string, kind: DiscordBridgeDelivery["kind"]): boolean { return this.#state().deliveries.some( (delivery) => - (this.session.mode === "gateway" || + (this.session.mode === "operator" || delivery.discordThreadId === this.session.discordThreadId) && delivery.codexThreadId === this.session.codexThreadId && delivery.turnId === turnId && @@ -1603,7 +1603,7 @@ export class DiscordThreadRunner { #sessionActiveTurns(): DiscordBridgeActiveTurn[] { return this.#state().activeTurns.filter( (active) => - (this.session.mode === "gateway" || + (this.session.mode === "operator" || active.discordThreadId === this.session.discordThreadId) && active.codexThreadId === this.session.codexThreadId, ); @@ -1620,7 +1620,7 @@ export class DiscordThreadRunner { const observedAt = this.#context.now().toISOString(); state.activeTurns = state.activeTurns.filter( (active) => - (this.session.mode !== "gateway" && + (this.session.mode !== "operator" && active.discordThreadId !== this.session.discordThreadId) || active.codexThreadId !== this.session.codexThreadId || active.turnId === input.turnId, @@ -1651,7 +1651,7 @@ export class DiscordThreadRunner { const state = this.#state(); state.activeTurns = state.activeTurns.filter( (active) => - (this.session.mode !== "gateway" && + (this.session.mode !== "operator" && active.discordThreadId !== this.session.discordThreadId) || active.codexThreadId !== this.session.codexThreadId || active.turnId !== turnId, @@ -1677,7 +1677,7 @@ export class DiscordThreadRunner { #sessionQueueItems(): DiscordBridgeQueueItem[] { return this.#state().queue.filter( (item) => - (this.session.mode === "gateway" || + (this.session.mode === "operator" || item.discordThreadId === this.session.discordThreadId) && item.codexThreadId === this.session.codexThreadId, ); @@ -2079,20 +2079,20 @@ function formatDiscordPrompt( session: DiscordBridgeSession, config: DiscordBridgeConfig, ): string { - if (session.mode === "gateway") { - const surface = config.gateway?.surfaces?.find((candidate) => + if (session.mode === "operator") { + const surface = config.workspace?.surfaces?.find((candidate) => candidate.homeChannelId === item.discordThreadId ); return [ - "[discord-gateway]", - "Role: You are the main Codex operator thread for a configured Discord gateway surface.", - "Intent: Treat this as a gateway request. Answer directly when appropriate; otherwise reason about backend/runtime delegation without assuming Discord itself owns a workspace registry.", + "[discord-workspace]", + "Role: You are the main Codex operator thread for a configured Discord workspace surface.", + "Intent: Treat this as a workspace request. Answer directly when appropriate; otherwise reason about backend/runtime delegation without assuming Discord itself owns a workspace registry.", "Canonical memory: This main Codex thread is the operator memory. Delegated Codex threads remain canonical history for delegated work.", surface ? `Surface: ${surface.key}` : undefined, `Author: ${item.authorName} (${item.authorId})`, `Message: ${item.discordMessageId}`, `Home channel: ${item.discordThreadId}`, - `Gateway cwd: ${session.cwd ?? "default"}`, + `Workspace cwd: ${session.cwd ?? "default"}`, "", item.content, ].filter((line): line is string => line !== undefined).join("\n"); diff --git a/apps/discord-bridge/src/state.ts b/apps/discord-bridge/src/state.ts index e0e1308..7af1e52 100644 --- a/apps/discord-bridge/src/state.ts +++ b/apps/discord-bridge/src/state.ts @@ -9,11 +9,11 @@ import type { DiscordBridgeSession, DiscordBridgeState, DiscordBridgeStateStore, - DiscordGatewayDelegation, - DiscordGatewayHookEventName, - DiscordGatewayObservedThread, - DiscordGatewayWorkspaceSurface, - DiscordGatewayState, + DiscordWorkspaceDelegation, + DiscordWorkspaceHookEventName, + DiscordWorkspaceObservedThread, + DiscordWorkspaceWorkspaceSurface, + DiscordWorkspaceState, } from "./types.ts"; const maxProcessedMessageIds = 1000; @@ -66,7 +66,7 @@ export class MemoryStateStore implements DiscordBridgeStateStore { export function emptyState(): DiscordBridgeState { return { version: 1, - gateway: undefined, + workspace: undefined, sessions: [], queue: [], activeTurns: [], @@ -80,18 +80,18 @@ export function trimState(state: DiscordBridgeState): void { -maxProcessedMessageIds, ); state.deliveries = state.deliveries.slice(-maxDeliveries); - if (state.gateway?.processedStopHookEventIds) { - state.gateway.processedStopHookEventIds = - state.gateway.processedStopHookEventIds.slice( + if (state.workspace?.processedStopHookEventIds) { + state.workspace.processedStopHookEventIds = + state.workspace.processedStopHookEventIds.slice( -maxProcessedStopHookEventIds, ); } - if (state.gateway?.processedHookEventIds) { - state.gateway.processedHookEventIds = - state.gateway.processedHookEventIds.slice(-maxProcessedHookEventIds); + if (state.workspace?.processedHookEventIds) { + state.workspace.processedHookEventIds = + state.workspace.processedHookEventIds.slice(-maxProcessedHookEventIds); } - if (state.gateway?.observedThreads) { - state.gateway.observedThreads = [...state.gateway.observedThreads] + if (state.workspace?.observedThreads) { + state.workspace.observedThreads = [...state.workspace.observedThreads] .sort((left, right) => Date.parse(right.lastSeenAt) - Date.parse(left.lastSeenAt) ) @@ -105,7 +105,7 @@ function parseState(value: unknown): DiscordBridgeState { } return { version: 1, - gateway: parseGateway(value.gateway), + workspace: parseWorkspace(value.workspace), sessions: Array.isArray(value.sessions) ? value.sessions.map(parseSession) : [], @@ -124,30 +124,30 @@ function parseState(value: unknown): DiscordBridgeState { }; } -function parseGateway(value: unknown): DiscordGatewayState | undefined { +function parseWorkspace(value: unknown): DiscordWorkspaceState | undefined { if (value === undefined) { return undefined; } if (!isRecord(value)) { - throw new Error("Invalid Discord bridge gateway state"); + throw new Error("Invalid Discord bridge workspace state"); } return { - homeChannelId: requiredString(value.homeChannelId, "gateway.homeChannelId"), + homeChannelId: requiredString(value.homeChannelId, "workspace.homeChannelId"), mainThreadId: optionalString(value.mainThreadId), statusMessageId: optionalString(value.statusMessageId), createdAt: optionalString(value.createdAt), toolsVersion: optionalNumber(value.toolsVersion), delegations: Array.isArray(value.delegations) - ? value.delegations.map(parseGatewayDelegation) + ? value.delegations.map(parseWorkspaceDelegation) : [], workspaces: Array.isArray(value.workspaces) - ? value.workspaces.map(parseGatewayWorkspace) + ? value.workspaces.map(parseWorkspaceWorkspace) : [], observedThreads: Array.isArray(value.observedThreads) - ? value.observedThreads.map(parseGatewayObservedThread) + ? value.observedThreads.map(parseWorkspaceObservedThread) : [], pendingWakes: Array.isArray(value.pendingWakes) - ? value.pendingWakes.map(parseGatewayPendingWake) + ? value.pendingWakes.map(parseWorkspacePendingWake) : [], processedHookEventIds: uniqueStrings([ ...(Array.isArray(value.processedHookEventIds) @@ -163,9 +163,9 @@ function parseGateway(value: unknown): DiscordGatewayState | undefined { }; } -function parseGatewayDelegation(value: unknown): DiscordGatewayDelegation { +function parseWorkspaceDelegation(value: unknown): DiscordWorkspaceDelegation { if (!isRecord(value)) { - throw new Error("Invalid Discord bridge gateway delegation"); + throw new Error("Invalid Discord bridge workspace delegation"); } const status = value.status; if ( @@ -175,15 +175,15 @@ function parseGatewayDelegation(value: unknown): DiscordGatewayDelegation { status !== "complete" && status !== "reported" ) { - throw new Error("Invalid Discord bridge gateway delegation status"); + throw new Error("Invalid Discord bridge workspace delegation status"); } return { - id: requiredString(value.id, "gateway.delegations.id"), + id: requiredString(value.id, "workspace.delegations.id"), codexThreadId: requiredString( value.codexThreadId, - "gateway.delegations.codexThreadId", + "workspace.delegations.codexThreadId", ), - title: requiredString(value.title, "gateway.delegations.title"), + title: requiredString(value.title, "workspace.delegations.title"), status, cwd: optionalString(value.cwd), workspaceKey: optionalString(value.workspaceKey), @@ -202,64 +202,64 @@ function parseGatewayDelegation(value: unknown): DiscordGatewayDelegation { mirroredAt: optionalString(value.mirroredAt), taskMirroredAt: optionalString(value.taskMirroredAt), reportedAt: optionalString(value.reportedAt), - createdAt: requiredString(value.createdAt, "gateway.delegations.createdAt"), - updatedAt: requiredString(value.updatedAt, "gateway.delegations.updatedAt"), + createdAt: requiredString(value.createdAt, "workspace.delegations.createdAt"), + updatedAt: requiredString(value.updatedAt, "workspace.delegations.updatedAt"), }; } -function parseGatewayWorkspace(value: unknown): DiscordGatewayWorkspaceSurface { +function parseWorkspaceWorkspace(value: unknown): DiscordWorkspaceWorkspaceSurface { if (!isRecord(value)) { - throw new Error("Invalid Discord bridge gateway workspace"); + throw new Error("Invalid Discord bridge workspace workspace"); } return { - key: requiredString(value.key, "gateway.workspaces.key"), + key: requiredString(value.key, "workspace.workspaces.key"), surfaceKey: optionalString(value.surfaceKey), - cwd: requiredString(value.cwd, "gateway.workspaces.cwd"), - title: requiredString(value.title, "gateway.workspaces.title"), + cwd: requiredString(value.cwd, "workspace.workspaces.cwd"), + title: requiredString(value.title, "workspace.workspaces.title"), discordThreadId: requiredString( value.discordThreadId, - "gateway.workspaces.discordThreadId", + "workspace.workspaces.discordThreadId", ), statusMessageId: optionalString(value.statusMessageId), delegationIds: Array.isArray(value.delegationIds) ? uniqueStrings(value.delegationIds) : [], - createdAt: requiredString(value.createdAt, "gateway.workspaces.createdAt"), - updatedAt: requiredString(value.updatedAt, "gateway.workspaces.updatedAt"), + createdAt: requiredString(value.createdAt, "workspace.workspaces.createdAt"), + updatedAt: requiredString(value.updatedAt, "workspace.workspaces.updatedAt"), }; } -function parseGatewayPendingWake( +function parseWorkspacePendingWake( value: unknown, -): NonNullable[number] { +): NonNullable[number] { if (!isRecord(value)) { - throw new Error("Invalid Discord bridge gateway pending wake"); + throw new Error("Invalid Discord bridge workspace pending wake"); } const kind = value.kind === "delegation" || value.kind === "group" ? value.kind : undefined; if (!kind) { - throw new Error("Invalid Discord bridge gateway pending wake kind"); + throw new Error("Invalid Discord bridge workspace pending wake kind"); } return { - id: requiredString(value.id, "gateway.pendingWakes.id"), + id: requiredString(value.id, "workspace.pendingWakes.id"), kind, delegationIds: Array.isArray(value.delegationIds) ? uniqueStrings(value.delegationIds) : [], groupId: optionalString(value.groupId), - reason: requiredString(value.reason, "gateway.pendingWakes.reason"), - createdAt: requiredString(value.createdAt, "gateway.pendingWakes.createdAt"), + reason: requiredString(value.reason, "workspace.pendingWakes.reason"), + createdAt: requiredString(value.createdAt, "workspace.pendingWakes.createdAt"), startedAt: optionalString(value.startedAt), }; } -function parseGatewayObservedThread(value: unknown): DiscordGatewayObservedThread { +function parseWorkspaceObservedThread(value: unknown): DiscordWorkspaceObservedThread { if (!isRecord(value)) { - throw new Error("Invalid Discord bridge gateway observed thread"); + throw new Error("Invalid Discord bridge workspace observed thread"); } return { - threadId: requiredString(value.threadId, "gateway.observedThreads.threadId"), + threadId: requiredString(value.threadId, "workspace.observedThreads.threadId"), title: optionalString(value.title), status: parseObservedThreadStatus(value.status), cwd: optionalString(value.cwd), @@ -277,15 +277,15 @@ function parseGatewayObservedThread(value: unknown): DiscordGatewayObservedThrea toolInputPreview: optionalString(value.toolInputPreview), toolResponsePreview: optionalString(value.toolResponsePreview), permissionDescription: optionalString(value.permissionDescription), - firstSeenAt: requiredString(value.firstSeenAt, "gateway.observedThreads.firstSeenAt"), - lastSeenAt: requiredString(value.lastSeenAt, "gateway.observedThreads.lastSeenAt"), - updatedAt: requiredString(value.updatedAt, "gateway.observedThreads.updatedAt"), + firstSeenAt: requiredString(value.firstSeenAt, "workspace.observedThreads.firstSeenAt"), + lastSeenAt: requiredString(value.lastSeenAt, "workspace.observedThreads.lastSeenAt"), + updatedAt: requiredString(value.updatedAt, "workspace.observedThreads.updatedAt"), }; } function parseObservedThreadStatus( value: unknown, -): DiscordGatewayObservedThread["status"] { +): DiscordWorkspaceObservedThread["status"] { return value === "starting" || value === "active" || value === "tool" || @@ -295,7 +295,7 @@ function parseObservedThreadStatus( : "idle"; } -function parseHookEventName(value: unknown): DiscordGatewayHookEventName | undefined { +function parseHookEventName(value: unknown): DiscordWorkspaceHookEventName | undefined { return value === "SessionStart" || value === "UserPromptSubmit" || value === "PreToolUse" || @@ -306,7 +306,7 @@ function parseHookEventName(value: unknown): DiscordGatewayHookEventName | undef : undefined; } -function parseReturnMode(value: unknown): DiscordGatewayDelegation["returnMode"] { +function parseReturnMode(value: unknown): DiscordWorkspaceDelegation["returnMode"] { return value === "detached" || value === "record_only" || value === "wake_on_done" || @@ -430,7 +430,7 @@ function optionalNumber(value: unknown): number | undefined { function parseSessionMode(value: unknown): DiscordBridgeSession["mode"] { return value === "new" || value === "resumed" || - value === "gateway" || + value === "workspace" || value === "delegated" || value === "workspace" ? value diff --git a/apps/discord-bridge/src/stop-hook-spool.ts b/apps/discord-bridge/src/stop-hook-spool.ts index 779386b..707816e 100644 --- a/apps/discord-bridge/src/stop-hook-spool.ts +++ b/apps/discord-bridge/src/stop-hook-spool.ts @@ -11,8 +11,8 @@ import os from "node:os"; import path from "node:path"; import type { - DiscordGatewayHookEvent, - DiscordGatewayHookEventName, + DiscordWorkspaceHookEvent, + DiscordWorkspaceHookEventName, } from "./types.ts"; export type HookEventSpoolDisposition = "processed" | "ignored" | "failed"; @@ -22,7 +22,7 @@ export type PendingHookEventSpoolFile = | { filePath: string; fileName: string; - event: DiscordGatewayHookEvent; + event: DiscordWorkspaceHookEvent; } | { filePath: string; @@ -65,7 +65,7 @@ export async function writeStopHookSpoolEvent( spoolDir?: string; now?: () => Date; } = {}, -): Promise { +): Promise { return await writeHookSpoolEvent(input, options); } @@ -75,7 +75,7 @@ export async function writeHookSpoolEvent( spoolDir?: string; now?: () => Date; } = {}, -): Promise { +): Promise { const spoolDir = options.spoolDir ?? stopHookSpoolDirFromEnv(); const event = hookEventFromInput(input, options.now ?? (() => new Date())); const paths = stopHookSpoolPaths(spoolDir); @@ -151,7 +151,7 @@ export async function removeStopHookSpool(spoolDir: string): Promise { function hookEventFromInput( input: unknown, now: () => Date, -): DiscordGatewayHookEvent { +): DiscordWorkspaceHookEvent { const parsed = record(input); const eventName = stringValue(parsed.hook_event_name) ?? stringValue(parsed.eventName); if (!isHookEventName(eventName)) { @@ -212,7 +212,7 @@ function hookEventFromInput( }; } -function parseHookSpoolEvent(input: unknown): DiscordGatewayHookEvent { +function parseHookSpoolEvent(input: unknown): DiscordWorkspaceHookEvent { const parsed = record(input); if (parsed.version !== 1) { throw new Error("Invalid hook event version"); @@ -249,7 +249,7 @@ function parseHookSpoolEvent(input: unknown): DiscordGatewayHookEvent { } function hookEventId(input: { - eventName: DiscordGatewayHookEventName; + eventName: DiscordWorkspaceHookEventName; sessionId: string; turnId?: string; transcriptPath?: string; @@ -277,7 +277,7 @@ function hookEventId(input: { return `${prefix}-${createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 24)}`; } -function isHookEventName(value: unknown): value is DiscordGatewayHookEventName { +function isHookEventName(value: unknown): value is DiscordWorkspaceHookEventName { return value === "SessionStart" || value === "UserPromptSubmit" || value === "PreToolUse" || diff --git a/apps/discord-bridge/src/types.ts b/apps/discord-bridge/src/types.ts index e6d66a8..e888719 100644 --- a/apps/discord-bridge/src/types.ts +++ b/apps/discord-bridge/src/types.ts @@ -10,7 +10,7 @@ export type DiscordBridgeConfig = { allowedUserIds: Set; allowedChannelIds: Set; statePath: string; - gateway?: DiscordGatewayConfig; + workspace?: DiscordWorkspaceConfig; flowBackendUrl?: string; cwd?: string; model?: string; @@ -33,15 +33,15 @@ export type DiscordBridgeConfig = { export type DiscordProgressMode = "summary" | "commentary" | "none"; export type DiscordConsoleOutputMode = "messages" | "none"; -export type DiscordGatewayConfig = { +export type DiscordWorkspaceConfig = { homeChannelId: string; mainThreadId?: string; workspaceForumChannelId?: string; taskThreadsChannelId?: string; - surfaces?: DiscordGatewaySurfaceConfig[]; + surfaces?: DiscordWorkspaceSurfaceConfig[]; }; -export type DiscordGatewaySurfaceConfig = { +export type DiscordWorkspaceSurfaceConfig = { key: string; homeChannelId: string; workspaceForumChannelId?: string; @@ -238,7 +238,7 @@ export type CodexBridgeClient = { export type DiscordBridgeState = { version: 1; - gateway?: DiscordGatewayState; + workspace?: DiscordWorkspaceState; sessions: DiscordBridgeSession[]; queue: DiscordBridgeQueueItem[]; activeTurns: DiscordBridgeActiveTurn[]; @@ -246,28 +246,28 @@ export type DiscordBridgeState = { deliveries: DiscordBridgeDelivery[]; }; -export type DiscordGatewayState = { +export type DiscordWorkspaceState = { homeChannelId: string; mainThreadId?: string; statusMessageId?: string; createdAt?: string; toolsVersion?: number; - delegations: DiscordGatewayDelegation[]; - workspaces?: DiscordGatewayWorkspaceSurface[]; - observedThreads?: DiscordGatewayObservedThread[]; - pendingWakes?: DiscordGatewayPendingWake[]; + delegations: DiscordWorkspaceDelegation[]; + workspaces?: DiscordWorkspaceWorkspaceSurface[]; + observedThreads?: DiscordWorkspaceObservedThread[]; + pendingWakes?: DiscordWorkspacePendingWake[]; processedHookEventIds?: string[]; processedStopHookEventIds?: string[]; }; -export type DiscordGatewayDelegationReturnMode = +export type DiscordWorkspaceDelegationReturnMode = | "detached" | "record_only" | "wake_on_done" | "wake_on_group" | "manual"; -export type DiscordGatewayDelegation = { +export type DiscordWorkspaceDelegation = { id: string; codexThreadId: string; title: string; @@ -276,7 +276,7 @@ export type DiscordGatewayDelegation = { workspaceKey?: string; surfaceKey?: string; groupId?: string; - returnMode?: DiscordGatewayDelegationReturnMode; + returnMode?: DiscordWorkspaceDelegationReturnMode; discordDetailThreadId?: string; discordTaskThreadId?: string; discordWorkspaceThreadId?: string; @@ -293,7 +293,7 @@ export type DiscordGatewayDelegation = { updatedAt: string; }; -export type DiscordGatewayWorkspaceSurface = { +export type DiscordWorkspaceWorkspaceSurface = { key: string; surfaceKey?: string; cwd: string; @@ -305,7 +305,7 @@ export type DiscordGatewayWorkspaceSurface = { updatedAt: string; }; -export type DiscordGatewayPendingWake = { +export type DiscordWorkspacePendingWake = { id: string; kind: "delegation" | "group"; delegationIds: string[]; @@ -315,7 +315,7 @@ export type DiscordGatewayPendingWake = { startedAt?: string; }; -export type DiscordGatewayHookEventName = +export type DiscordWorkspaceHookEventName = | "SessionStart" | "UserPromptSubmit" | "PreToolUse" @@ -323,10 +323,10 @@ export type DiscordGatewayHookEventName = | "PostToolUse" | "Stop"; -export type DiscordGatewayHookEvent = { +export type DiscordWorkspaceHookEvent = { version: 1; id: string; - eventName: DiscordGatewayHookEventName; + eventName: DiscordWorkspaceHookEventName; sessionId: string; turnId?: string; cwd?: string; @@ -344,28 +344,28 @@ export type DiscordGatewayHookEvent = { createdAt: string; }; -export type DiscordGatewayStopHookEvent = DiscordGatewayHookEvent & { +export type DiscordWorkspaceStopHookEvent = DiscordWorkspaceHookEvent & { eventName: "Stop"; }; -export type DiscordGatewayObservedThreadStatus = +export type DiscordWorkspaceObservedThreadStatus = | "starting" | "active" | "tool" | "waiting" | "idle"; -export type DiscordGatewayObservedThread = { +export type DiscordWorkspaceObservedThread = { threadId: string; title?: string; - status: DiscordGatewayObservedThreadStatus; + status: DiscordWorkspaceObservedThreadStatus; cwd?: string; workspaceKey?: string; surfaceKey?: string; model?: string; transcriptPath?: string; lastTurnId?: string; - lastHookEventName?: DiscordGatewayHookEventName; + lastHookEventName?: DiscordWorkspaceHookEventName; source?: string; promptPreview?: string; assistantPreview?: string; @@ -391,7 +391,7 @@ export type DiscordBridgeSession = { ownerUserId?: string; participantUserIds?: string[]; cwd?: string; - mode?: "new" | "resumed" | "gateway" | "delegated" | "workspace"; + mode?: "new" | "resumed" | "operator" | "delegated" | "workspace"; statusMessageId?: string; }; diff --git a/apps/discord-bridge/src/gateway-backend.ts b/apps/discord-bridge/src/workspace-backend.ts similarity index 94% rename from apps/discord-bridge/src/gateway-backend.ts rename to apps/discord-bridge/src/workspace-backend.ts index a397c2a..0362b9f 100644 --- a/apps/discord-bridge/src/gateway-backend.ts +++ b/apps/discord-bridge/src/workspace-backend.ts @@ -4,7 +4,7 @@ import type { DiscordInbound, } from "./types.ts"; -export type CodexGatewayBackend = { +export type CodexWorkspaceBackend = { start(): Promise; startTransportDependentWork?(): Promise; startBackgroundWork?(): Promise; @@ -15,7 +15,7 @@ export type CodexGatewayBackend = { flushSummariesForTest?(): Promise; }; -export type CodexGatewayPresenter = { +export type CodexWorkspacePresenter = { createWorkspacePost?( locationId: string, title: string, diff --git a/apps/discord-bridge/test/bridge.test.ts b/apps/discord-bridge/test/bridge.test.ts index 64c6c4c..fb8a40f 100644 --- a/apps/discord-bridge/test/bridge.test.ts +++ b/apps/discord-bridge/test/bridge.test.ts @@ -9,7 +9,7 @@ import type { FlowBackendClient } from "@peezy.tech/flow-runtime/backend-client" import { DiscordCodexBridge, - LocalCodexGatewayBackend, + LocalCodexWorkspaceBackend, parseThreadStartIntent, } from "../src/bridge.ts"; import type { @@ -19,9 +19,9 @@ import type { import { MemoryStateStore, emptyState } from "../src/state.ts"; import { writeStopHookSpoolEvent } from "../src/stop-hook-spool.ts"; import type { - CodexGatewayBackend, - CodexGatewayPresenter, -} from "../src/gateway-backend.ts"; + CodexWorkspaceBackend, + CodexWorkspacePresenter, +} from "../src/workspace-backend.ts"; import type { CodexBridgeClient, DiscordBridgeConfig, @@ -50,11 +50,11 @@ describe("DiscordCodexBridge", () => { }); }); - test("can run Discord as a transport over a gateway backend", async () => { + test("can run Discord as a transport over a workspace backend", async () => { const transport = new FakeDiscordTransport(); const calls: string[] = []; const inboundEvents: DiscordInbound[] = []; - const backend: CodexGatewayBackend = { + const backend: CodexWorkspaceBackend = { async start() { calls.push("backend.start"); }, @@ -107,11 +107,11 @@ describe("DiscordCodexBridge", () => { expect(calls).toContain("backend.stop"); }); - test("local gateway backend runs against a presenter without Discord transport lifecycle", async () => { + test("local workspace backend runs against a presenter without Discord transport lifecycle", async () => { const client = new FakeCodexClient(); const sentMessages: Array<{ locationId: string; text: string }> = []; const typingLocations: string[] = []; - const presenter: CodexGatewayPresenter = { + const presenter: CodexWorkspacePresenter = { async createThread(locationId, title, sourceMessageId) { expect(locationId).toBe("parent-channel"); expect(title).toBe("Existing thread"); @@ -127,12 +127,12 @@ describe("DiscordCodexBridge", () => { typingLocations.push(locationId); }, }; - const backend = new LocalCodexGatewayBackend({ + const backend = new LocalCodexWorkspaceBackend({ client, presenter, store: new MemoryStateStore(), config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, allowedChannelIds: new Set(["parent-channel"]), }), }); @@ -162,7 +162,7 @@ describe("DiscordCodexBridge", () => { await backend.stop(); }); - test("starts a gateway main thread and routes home channel messages to it", async () => { + test("starts a workspace main thread and routes home channel messages to it", async () => { const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); const bridge = new DiscordCodexBridge({ @@ -170,7 +170,7 @@ describe("DiscordCodexBridge", () => { transport, store: new MemoryStateStore(), config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, allowedChannelIds: new Set(["parent-channel"]), }), }); @@ -184,20 +184,20 @@ describe("DiscordCodexBridge", () => { expect(client.startThreadCalls[0]?.dynamicTools).toEqual( expect.arrayContaining([ expect.objectContaining({ - namespace: "codex_gateway", + namespace: "codex_workspace", name: "start_delegation", }), expect.objectContaining({ - namespace: "codex_gateway", + namespace: "codex_workspace", name: "list_flow_runs", }), ]), ); expect(client.setThreadNameCalls[0]).toEqual({ threadId: "codex-thread-1", - name: "[discord-gateway] Codex Gateway", + name: "[discord-workspace] Codex Workspace", }); - expect(bridge.stateForTest().gateway).toEqual( + expect(bridge.stateForTest().workspace).toEqual( expect.objectContaining({ homeChannelId: "home-channel", mainThreadId: "codex-thread-1", @@ -209,9 +209,9 @@ describe("DiscordCodexBridge", () => { discordThreadId: "home-channel", parentChannelId: "home-channel", codexThreadId: "codex-thread-1", - title: "Codex Gateway", + title: "Codex Workspace", cwd: "/workspace", - mode: "gateway", + mode: "operator", }), ); @@ -229,7 +229,7 @@ describe("DiscordCodexBridge", () => { "status across the workspaces", ); expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( - "[discord-gateway]", + "[discord-workspace]", ); expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( "main Codex operator thread", @@ -240,7 +240,7 @@ describe("DiscordCodexBridge", () => { await bridge.stop(); }); - test("gateway tool starts and tracks delegated Codex sessions without privileged tools", async () => { + test("workspace tool starts and tracks delegated Codex sessions without privileged tools", async () => { const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); const store = new MemoryStateStore(); @@ -249,7 +249,7 @@ describe("DiscordCodexBridge", () => { transport, store, config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, }), now: () => new Date("2026-05-14T12:00:00.000Z"), }); @@ -263,12 +263,12 @@ describe("DiscordCodexBridge", () => { threadId: "codex-thread-1", turnId: "turn-main", callId: "call-1", - namespace: "codex_gateway", + namespace: "codex_workspace", tool: "start_delegation", arguments: { cwd: "/workspace/other", title: "Other workspace", - prompt: "Inspect the remaining gateway work.", + prompt: "Inspect the remaining workspace work.", discordDetailThreadId: "detail-thread", parentDiscordMessageId: "home-message", }, @@ -293,9 +293,9 @@ describe("DiscordCodexBridge", () => { }), ); expect(inputText(client.startTurnCalls[0]?.input[0])).toBe( - "Inspect the remaining gateway work.", + "Inspect the remaining workspace work.", ); - expect(bridge.stateForTest().gateway?.delegations).toEqual([ + expect(bridge.stateForTest().workspace?.delegations).toEqual([ expect.objectContaining({ codexThreadId: "codex-thread-2", title: "Other workspace", @@ -305,7 +305,7 @@ describe("DiscordCodexBridge", () => { parentDiscordMessageId: "home-message", }), ]); - expect(gatewayToolResult(client.responses[0]?.result)).toEqual( + expect(workspaceToolResult(client.responses[0]?.result)).toEqual( expect.objectContaining({ turnId: "turn-1", delegation: expect.objectContaining({ @@ -316,7 +316,7 @@ describe("DiscordCodexBridge", () => { await bridge.stop(); }); - test("gateway flow inspection uses backend client and preserves tool payload shape", async () => { + test("workspace flow inspection uses backend client and preserves tool payload shape", async () => { const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); const flowBackendClient = new FakeFlowBackendClient(); @@ -325,7 +325,7 @@ describe("DiscordCodexBridge", () => { transport, store: new MemoryStateStore(), config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, }), flowBackendClient, }); @@ -337,7 +337,7 @@ describe("DiscordCodexBridge", () => { method: "item/tool/call", params: { threadId: "codex-thread-1", - namespace: "codex_gateway", + namespace: "codex_workspace", tool: "list_flow_runs", arguments: { eventId: "event-1", @@ -351,7 +351,7 @@ describe("DiscordCodexBridge", () => { method: "item/tool/call", params: { threadId: "codex-thread-1", - namespace: "codex_gateway", + namespace: "codex_workspace", tool: "list_flow_events", arguments: { type: "upstream.release", @@ -367,7 +367,7 @@ describe("DiscordCodexBridge", () => { expect(flowBackendClient.listEventsCalls).toEqual([ { type: "upstream.release", limit: 3 }, ]); - expect(gatewayToolResult(client.responses[0]?.result)).toEqual({ + expect(workspaceToolResult(client.responses[0]?.result)).toEqual({ eventId: "event-1", runs: [ expect.objectContaining({ @@ -378,7 +378,7 @@ describe("DiscordCodexBridge", () => { }), ], }); - expect(gatewayToolResult(client.responses[1]?.result)).toEqual({ + expect(workspaceToolResult(client.responses[1]?.result)).toEqual({ events: [ expect.objectContaining({ id: "event-1", @@ -389,7 +389,7 @@ describe("DiscordCodexBridge", () => { await bridge.stop(); }); - test("gateway workbench opens delegation task threads lazily from workspace posts", async () => { + test("workspace workbench opens delegation task threads lazily from workspace posts", async () => { const hookSpoolDir = await testHookSpoolDir(); const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); @@ -398,7 +398,7 @@ describe("DiscordCodexBridge", () => { transport, store: new MemoryStateStore(), config: testConfig({ - gateway: { + workspace: { homeChannelId: "home-channel", workspaceForumChannelId: "workspace-forum", taskThreadsChannelId: "task-channel", @@ -417,7 +417,7 @@ describe("DiscordCodexBridge", () => { method: "item/tool/call", params: { threadId: "codex-thread-1", - namespace: "codex_gateway", + namespace: "codex_workspace", tool: "start_delegation", arguments: { cwd: "/workspace/codex-flows", @@ -438,23 +438,23 @@ describe("DiscordCodexBridge", () => { ]); expect(transport.createdThreads).toEqual([]); const state = bridge.stateForTest(); - expect(state.gateway?.workspaces).toEqual([ + expect(state.workspace?.workspaces).toEqual([ expect.objectContaining({ cwd: "/workspace/codex-flows", title: "codex-flows", discordThreadId: "forum-post-1", statusMessageId: "forum-post-1", - delegationIds: [state.gateway?.delegations[0]?.id], + delegationIds: [state.workspace?.delegations[0]?.id], }), ]); - expect(state.gateway?.delegations[0]).toEqual( + expect(state.workspace?.delegations[0]).toEqual( expect.objectContaining({ codexThreadId: "codex-thread-2", - workspaceKey: state.gateway?.workspaces?.[0]?.key, + workspaceKey: state.workspace?.workspaces?.[0]?.key, discordWorkspaceThreadId: "forum-post-1", }), ); - expect(state.gateway?.delegations[0]?.discordTaskThreadId).toBeUndefined(); + expect(state.workspace?.delegations[0]?.discordTaskThreadId).toBeUndefined(); const workspaceUpdate = transport.updatedMessages.find((message) => message.channelId === "forum-post-1" && message.messageId === "forum-post-1" @@ -529,7 +529,7 @@ describe("DiscordCodexBridge", () => { )).toBe(true); const homeResult = transport.messages.find((message) => message.channelId === "home-channel" && - message.text.includes("[discord-gateway delegation result]") && + message.text.includes("[discord-workspace delegation result]") && message.text.includes("Hook packaging") )?.text ?? ""; expect(homeResult).toContain("<#forum-post-1>"); @@ -564,7 +564,7 @@ describe("DiscordCodexBridge", () => { } }); - test("gateway workbench reuses one workspace post per normalized cwd", async () => { + test("workspace workbench reuses one workspace post per normalized cwd", async () => { const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); const bridge = new DiscordCodexBridge({ @@ -572,7 +572,7 @@ describe("DiscordCodexBridge", () => { transport, store: new MemoryStateStore(), config: testConfig({ - gateway: { + workspace: { homeChannelId: "home-channel", workspaceForumChannelId: "workspace-forum", taskThreadsChannelId: "task-channel", @@ -589,7 +589,7 @@ describe("DiscordCodexBridge", () => { method: "item/tool/call", params: { threadId: "codex-thread-1", - namespace: "codex_gateway", + namespace: "codex_workspace", tool: "start_delegation", arguments: { cwd: index === 0 @@ -604,8 +604,8 @@ describe("DiscordCodexBridge", () => { expect(transport.createdForumPosts).toHaveLength(1); expect(transport.createdThreads).toHaveLength(0); - const workspaces = bridge.stateForTest().gateway?.workspaces ?? []; - const delegations = bridge.stateForTest().gateway?.delegations ?? []; + const workspaces = bridge.stateForTest().workspace?.workspaces ?? []; + const delegations = bridge.stateForTest().workspace?.delegations ?? []; expect(workspaces).toEqual([ expect.objectContaining({ cwd: "/workspace/codex-flows", @@ -617,7 +617,7 @@ describe("DiscordCodexBridge", () => { await bridge.stop(); }); - test("gateway workbench discovers top-level folders under the main workspace", async () => { + test("workspace workbench discovers top-level folders under the main workspace", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "discord-workspaces-")); await mkdir(path.join(root, "alpha", "nested"), { recursive: true }); await mkdir(path.join(root, "beta"), { recursive: true }); @@ -644,7 +644,7 @@ describe("DiscordCodexBridge", () => { store: new MemoryStateStore(), config: testConfig({ cwd: root, - gateway: { + workspace: { homeChannelId: "home-channel", workspaceForumChannelId: "workspace-forum", taskThreadsChannelId: "task-channel", @@ -659,7 +659,7 @@ describe("DiscordCodexBridge", () => { "alpha", "beta", ]); - expect(bridge.stateForTest().gateway?.workspaces?.map((workspace) => + expect(bridge.stateForTest().workspace?.workspaces?.map((workspace) => workspace.cwd )).toEqual([ path.join(root, "alpha"), @@ -711,7 +711,7 @@ describe("DiscordCodexBridge", () => { method: "item/tool/call", params: { threadId: "codex-thread-1", - namespace: "codex_gateway", + namespace: "codex_workspace", tool: "start_delegation", arguments: { cwd: path.join(root, "alpha", "nested", "project"), @@ -723,10 +723,10 @@ describe("DiscordCodexBridge", () => { expect(transport.createdForumPosts).toHaveLength(2); expect(transport.createdThreads).toHaveLength(1); const state = bridge.stateForTest(); - const alpha = state.gateway?.workspaces?.find((workspace) => + const alpha = state.workspace?.workspaces?.find((workspace) => workspace.cwd === path.join(root, "alpha") ); - const delegation = state.gateway?.delegations[0]; + const delegation = state.workspace?.delegations[0]; expect(delegation).toBeDefined(); expect(alpha?.delegationIds).toEqual([delegation!.id]); expect(delegation).toEqual( @@ -741,7 +741,7 @@ describe("DiscordCodexBridge", () => { } }); - test("gateway workbench surfaces hook-observed non-gateway threads", async () => { + test("workspace workbench surfaces hook-observed non-workspace threads", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "discord-observed-")); const hookSpoolDir = await testHookSpoolDir(); await mkdir(path.join(root, "alpha", "project"), { recursive: true }); @@ -753,7 +753,7 @@ describe("DiscordCodexBridge", () => { store: new MemoryStateStore(), config: testConfig({ cwd: root, - gateway: { + workspace: { homeChannelId: "home-channel", workspaceForumChannelId: "workspace-forum", taskThreadsChannelId: "task-channel", @@ -776,7 +776,7 @@ describe("DiscordCodexBridge", () => { prompt: "Inspect observed runtime activity.", }); await waitFor(() => - bridge.stateForTest().gateway?.observedThreads?.[0]?.status === "active" + bridge.stateForTest().workspace?.observedThreads?.[0]?.status === "active" ); await emitHookEvent(hookSpoolDir, { eventName: "PermissionRequest", @@ -787,9 +787,9 @@ describe("DiscordCodexBridge", () => { toolInput: { description: "Needs network" }, }); await waitFor(() => - bridge.stateForTest().gateway?.observedThreads?.[0]?.status === "waiting" + bridge.stateForTest().workspace?.observedThreads?.[0]?.status === "waiting" ); - expect(bridge.stateForTest().gateway?.observedThreads?.[0]).toEqual( + expect(bridge.stateForTest().workspace?.observedThreads?.[0]).toEqual( expect.objectContaining({ threadId: "codex-observed", title: "Inspect observed runtime activity.", @@ -848,7 +848,7 @@ describe("DiscordCodexBridge", () => { } }); - test("gateway hook drain continues when workspace dashboard updates fail", async () => { + test("workspace hook drain continues when workspace dashboard updates fail", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "discord-observed-fail-")); const hookSpoolDir = await testHookSpoolDir(); await mkdir(path.join(root, "alpha", "project"), { recursive: true }); @@ -860,7 +860,7 @@ describe("DiscordCodexBridge", () => { store: new MemoryStateStore(), config: testConfig({ cwd: root, - gateway: { + workspace: { homeChannelId: "home-channel", workspaceForumChannelId: "workspace-forum", taskThreadsChannelId: "task-channel", @@ -881,7 +881,7 @@ describe("DiscordCodexBridge", () => { prompt: "Keep draining hooks.", }); await waitFor(() => - bridge.stateForTest().gateway?.observedThreads?.some((thread) => + bridge.stateForTest().workspace?.observedThreads?.some((thread) => thread.threadId === "codex-observed-fail" && thread.status === "active" ) ?? false @@ -896,13 +896,13 @@ describe("DiscordCodexBridge", () => { } }); - test("gateway workbench resumes persisted task thread sessions after restart", async () => { + test("workspace workbench resumes persisted task thread sessions after restart", async () => { const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); const existingWorkspaceKey = testWorkspaceKey("/workspace/codex-flows"); const store = new MemoryStateStore({ ...emptyState(), - gateway: { + workspace: { homeChannelId: "home-channel", mainThreadId: "codex-main", toolsVersion: 1, @@ -938,10 +938,10 @@ describe("DiscordCodexBridge", () => { discordThreadId: "home-channel", parentChannelId: "home-channel", codexThreadId: "codex-main", - title: "Codex Gateway", + title: "Codex Workspace", createdAt: "2026-05-14T11:00:00.000Z", cwd: "/workspace", - mode: "gateway", + mode: "workspace", }, { discordThreadId: "task-thread-existing", @@ -963,7 +963,7 @@ describe("DiscordCodexBridge", () => { transport, store, config: testConfig({ - gateway: { + workspace: { homeChannelId: "home-channel", workspaceForumChannelId: "workspace-forum", taskThreadsChannelId: "task-channel", @@ -995,7 +995,7 @@ describe("DiscordCodexBridge", () => { await bridge.stop(); }); - test("gateway rejects dynamic tool calls outside the main operator thread", async () => { + test("workspace rejects dynamic tool calls outside the main operator thread", async () => { const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); const bridge = new DiscordCodexBridge({ @@ -1003,7 +1003,7 @@ describe("DiscordCodexBridge", () => { transport, store: new MemoryStateStore(), config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, }), }); @@ -1014,7 +1014,7 @@ describe("DiscordCodexBridge", () => { method: "item/tool/call", params: { threadId: "codex-thread-elsewhere", - namespace: "codex_gateway", + namespace: "codex_workspace", tool: "list_delegations", arguments: {}, }, @@ -1032,7 +1032,7 @@ describe("DiscordCodexBridge", () => { await bridge.stop(); }); - test("gateway records group delegation results and wakes after the group finishes", async () => { + test("workspace records group delegation results and wakes after the group finishes", async () => { const hookSpoolDir = await testHookSpoolDir(); const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); @@ -1041,7 +1041,7 @@ describe("DiscordCodexBridge", () => { transport, store: new MemoryStateStore(), config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, hookSpoolDir, }), now: () => new Date("2026-05-14T12:00:00.000Z"), @@ -1056,7 +1056,7 @@ describe("DiscordCodexBridge", () => { method: "item/tool/call", params: { threadId: "codex-thread-1", - namespace: "codex_gateway", + namespace: "codex_workspace", tool: "start_delegation", arguments: { cwd: `/workspace/${index}`, @@ -1097,7 +1097,7 @@ describe("DiscordCodexBridge", () => { expect(inputText(client.startTurnCalls[2]?.input[0])).toContain( "Delegation group fanout completed.", ); - expect(bridge.stateForTest().gateway?.pendingWakes?.[0]).toEqual( + expect(bridge.stateForTest().workspace?.pendingWakes?.[0]).toEqual( expect.objectContaining({ kind: "group", groupId: "fanout", @@ -1106,14 +1106,14 @@ describe("DiscordCodexBridge", () => { ); await sleep(30); expect(client.startTurnCalls).toHaveLength(3); - expect(bridge.stateForTest().gateway?.pendingWakes).toHaveLength(1); + expect(bridge.stateForTest().workspace?.pendingWakes).toHaveLength(1); } finally { await bridge.stop(); await rm(hookSpoolDir, { recursive: true, force: true }); } }); - test("gateway detached delegations complete without injecting or waking the main thread", async () => { + test("workspace detached delegations complete without injecting or waking the main thread", async () => { const hookSpoolDir = await testHookSpoolDir(); const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); @@ -1122,7 +1122,7 @@ describe("DiscordCodexBridge", () => { transport, store: new MemoryStateStore(), config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, hookSpoolDir, }), now: () => new Date("2026-05-14T12:00:00.000Z"), @@ -1136,7 +1136,7 @@ describe("DiscordCodexBridge", () => { method: "item/tool/call", params: { threadId: "codex-thread-1", - namespace: "codex_gateway", + namespace: "codex_workspace", tool: "start_delegation", arguments: { cwd: "/workspace/detached", @@ -1154,7 +1154,7 @@ describe("DiscordCodexBridge", () => { lastAssistantMessage: "Detached result.", }); await waitFor(() => - bridge.stateForTest().gateway?.delegations[0]?.status === "complete" + bridge.stateForTest().workspace?.delegations[0]?.status === "complete" ); expect(client.injectThreadItemsCalls).toEqual([]); expect(client.startTurnCalls).toHaveLength(1); @@ -1168,7 +1168,7 @@ describe("DiscordCodexBridge", () => { } }); - test("gateway queues delegation wake while the main operator thread is busy", async () => { + test("workspace queues delegation wake while the main operator thread is busy", async () => { const hookSpoolDir = await testHookSpoolDir(); const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); @@ -1177,7 +1177,7 @@ describe("DiscordCodexBridge", () => { transport, store: new MemoryStateStore(), config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, hookSpoolDir, }), now: () => new Date("2026-05-14T12:00:00.000Z"), @@ -1201,7 +1201,7 @@ describe("DiscordCodexBridge", () => { method: "item/tool/call", params: { threadId: "codex-thread-1", - namespace: "codex_gateway", + namespace: "codex_workspace", tool: "start_delegation", arguments: { cwd: "/workspace/side", @@ -1219,12 +1219,12 @@ describe("DiscordCodexBridge", () => { }); await waitFor(() => client.injectThreadItemsCalls.length === 1); expect(client.startTurnCalls).toHaveLength(2); - expect(bridge.stateForTest().gateway?.pendingWakes?.[0]).toEqual( + expect(bridge.stateForTest().workspace?.pendingWakes?.[0]).toEqual( expect.objectContaining({ kind: "delegation", }), ); - expect(bridge.stateForTest().gateway?.pendingWakes?.[0]).not.toHaveProperty( + expect(bridge.stateForTest().workspace?.pendingWakes?.[0]).not.toHaveProperty( "startedAt", ); await emitStopHook(hookSpoolDir, { @@ -1236,7 +1236,7 @@ describe("DiscordCodexBridge", () => { expect(inputText(client.startTurnCalls[2]?.input[0])).toContain( "Delegation Side task completed.", ); - expect(bridge.stateForTest().gateway?.pendingWakes?.[0]).toEqual( + expect(bridge.stateForTest().workspace?.pendingWakes?.[0]).toEqual( expect.objectContaining({ startedAt: "2026-05-14T12:00:00.000Z", }), @@ -1247,7 +1247,7 @@ describe("DiscordCodexBridge", () => { } }); - test("gateway record-only delegations inject and mirror without waking", async () => { + test("workspace record-only delegations inject and mirror without waking", async () => { const hookSpoolDir = await testHookSpoolDir(); const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); @@ -1256,7 +1256,7 @@ describe("DiscordCodexBridge", () => { transport, store: new MemoryStateStore(), config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, hookSpoolDir, }), now: () => new Date("2026-05-14T12:00:00.000Z"), @@ -1270,7 +1270,7 @@ describe("DiscordCodexBridge", () => { method: "item/tool/call", params: { threadId: "codex-thread-1", - namespace: "codex_gateway", + namespace: "codex_workspace", tool: "start_delegation", arguments: { cwd: "/workspace/record", @@ -1289,7 +1289,7 @@ describe("DiscordCodexBridge", () => { }); await waitFor(() => client.injectThreadItemsCalls.length === 1); expect(client.startTurnCalls).toHaveLength(1); - expect(bridge.stateForTest().gateway?.pendingWakes ?? []).toEqual([]); + expect(bridge.stateForTest().workspace?.pendingWakes ?? []).toEqual([]); expect(transport.messages.some((message) => message.text.includes("Record-only result.") )).toBe(true); @@ -1299,13 +1299,13 @@ describe("DiscordCodexBridge", () => { } }); - test("gateway drains queued stop hook events on startup", async () => { + test("workspace drains queued stop hook events on startup", async () => { const hookSpoolDir = await testHookSpoolDir(); const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); const store = new MemoryStateStore({ ...emptyState(), - gateway: { + workspace: { homeChannelId: "home-channel", mainThreadId: "codex-thread-1", toolsVersion: 1, @@ -1329,10 +1329,10 @@ describe("DiscordCodexBridge", () => { discordThreadId: "home-channel", parentChannelId: "home-channel", codexThreadId: "codex-thread-1", - title: "Codex Gateway", + title: "Codex Workspace", createdAt: "2026-05-14T11:59:00.000Z", cwd: "/workspace", - mode: "gateway", + mode: "workspace", }, ], }); @@ -1347,7 +1347,7 @@ describe("DiscordCodexBridge", () => { transport, store, config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, hookSpoolDir, }), now: () => new Date("2026-05-14T12:00:00.000Z"), @@ -1357,7 +1357,7 @@ describe("DiscordCodexBridge", () => { await bridge.start(); await waitFor(() => client.injectThreadItemsCalls.length === 1); expect(client.readThreadCalls).toEqual([]); - expect(bridge.stateForTest().gateway?.delegations[0]).toEqual( + expect(bridge.stateForTest().workspace?.delegations[0]).toEqual( expect.objectContaining({ status: "complete", lastTurnId: "turn-queued", @@ -1374,7 +1374,7 @@ describe("DiscordCodexBridge", () => { } }); - test("gateway stop hook events are idempotent", async () => { + test("workspace stop hook events are idempotent", async () => { const hookSpoolDir = await testHookSpoolDir(); const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); @@ -1383,7 +1383,7 @@ describe("DiscordCodexBridge", () => { transport, store: new MemoryStateStore(), config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, hookSpoolDir, }), now: () => new Date("2026-05-14T12:00:00.000Z"), @@ -1397,7 +1397,7 @@ describe("DiscordCodexBridge", () => { method: "item/tool/call", params: { threadId: "codex-thread-1", - namespace: "codex_gateway", + namespace: "codex_workspace", tool: "start_delegation", arguments: { cwd: "/workspace/idempotent", @@ -1425,7 +1425,7 @@ describe("DiscordCodexBridge", () => { message.text.includes("Exactly once.") )).toHaveLength(1); expect( - bridge.stateForTest().gateway?.processedStopHookEventIds, + bridge.stateForTest().workspace?.processedStopHookEventIds, ).toHaveLength(1); } finally { await bridge.stop(); @@ -1433,7 +1433,7 @@ describe("DiscordCodexBridge", () => { } }); - test("gateway manually flushes completed manual delegation results", async () => { + test("workspace manually flushes completed manual delegation results", async () => { const hookSpoolDir = await testHookSpoolDir(); const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); @@ -1442,7 +1442,7 @@ describe("DiscordCodexBridge", () => { transport, store: new MemoryStateStore(), config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, hookSpoolDir, }), now: () => new Date("2026-05-14T12:00:00.000Z"), @@ -1456,7 +1456,7 @@ describe("DiscordCodexBridge", () => { method: "item/tool/call", params: { threadId: "codex-thread-1", - namespace: "codex_gateway", + namespace: "codex_workspace", tool: "start_delegation", arguments: { cwd: "/workspace/manual", @@ -1474,7 +1474,7 @@ describe("DiscordCodexBridge", () => { lastAssistantMessage: "Manual result.", }); await waitFor(() => - bridge.stateForTest().gateway?.delegations[0]?.status === "complete" + bridge.stateForTest().workspace?.delegations[0]?.status === "complete" ); expect(client.injectThreadItemsCalls).toEqual([]); client.emitRequest({ @@ -1482,10 +1482,10 @@ describe("DiscordCodexBridge", () => { method: "item/tool/call", params: { threadId: "codex-thread-1", - namespace: "codex_gateway", + namespace: "codex_workspace", tool: "flush_delegation_results", arguments: { - delegationId: bridge.stateForTest().gateway?.delegations[0]?.id, + delegationId: bridge.stateForTest().workspace?.delegations[0]?.id, wake: "false", }, }, @@ -1504,7 +1504,7 @@ describe("DiscordCodexBridge", () => { } }); - test("answers gateway status command without starting a turn", async () => { + test("answers workspace status command without starting a turn", async () => { const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); const replies: string[] = []; @@ -1513,7 +1513,7 @@ describe("DiscordCodexBridge", () => { transport, store: new MemoryStateStore(), config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, }), }); @@ -1530,7 +1530,7 @@ describe("DiscordCodexBridge", () => { }); await waitFor(() => replies.length === 1); - expect(replies[0]).toContain("**Codex Gateway**"); + expect(replies[0]).toContain("**Codex Workspace**"); expect(client.startTurnCalls).toHaveLength(0); await bridge.stop(); }); @@ -1583,7 +1583,7 @@ describe("DiscordCodexBridge", () => { }), config: testConfig({ cwd: root, - gateway: { + workspace: { homeChannelId: "home-channel", workspaceForumChannelId: "workspace-forum", taskThreadsChannelId: "task-channel", @@ -1637,7 +1637,7 @@ describe("DiscordCodexBridge", () => { } }); - test("multi-guild gateway surfaces scope workspaces, status, hooks, and home delivery", async () => { + test("multi-guild workspace surfaces scope workspaces, status, hooks, and home delivery", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "discord-surfaces-")); const hookSpoolDir = await testHookSpoolDir(); const alphaCwd = path.join(root, "alpha", "project"); @@ -1669,7 +1669,7 @@ describe("DiscordCodexBridge", () => { store: new MemoryStateStore(), config: testConfig({ cwd: root, - gateway: { + workspace: { homeChannelId: "home-default", workspaceForumChannelId: "forum-default", taskThreadsChannelId: "tasks-default", @@ -1708,7 +1708,7 @@ describe("DiscordCodexBridge", () => { threadId: "forum-post-2", }), ]); - expect(bridge.stateForTest().gateway?.workspaces).toEqual([ + expect(bridge.stateForTest().workspace?.workspaces).toEqual([ expect.objectContaining({ cwd: path.join(root, "alpha"), surfaceKey: "default", @@ -1775,7 +1775,7 @@ describe("DiscordCodexBridge", () => { prompt: "Watch the crypto workspace.", }); await waitFor(() => - bridge.stateForTest().gateway?.observedThreads?.some((thread) => + bridge.stateForTest().workspace?.observedThreads?.some((thread) => thread.threadId === "codex-crypto-observed" && thread.surfaceKey === "crypto" && thread.status === "active" @@ -1853,7 +1853,7 @@ describe("DiscordCodexBridge", () => { item: { id: "message-crypto-final", type: "agentMessage", - text: "Crypto gateway answer.", + text: "Crypto workspace answer.", phase: "final_answer", memoryCitation: null, }, @@ -1873,12 +1873,12 @@ describe("DiscordCodexBridge", () => { await waitFor(() => transport.messages.some((message) => message.channelId === "home-crypto" && - message.text === "Crypto gateway answer." + message.text === "Crypto workspace answer." ) ); expect(transport.messages.some((message) => message.channelId === "home-default" && - message.text === "Crypto gateway answer." + message.text === "Crypto workspace answer." )).toBe(false); } finally { await bridge.stop(); @@ -1916,7 +1916,7 @@ describe("DiscordCodexBridge", () => { store: new MemoryStateStore(), config: testConfig({ cwd: root, - gateway: { + workspace: { homeChannelId: "home-channel", workspaceForumChannelId: "workspace-forum", taskThreadsChannelId: "task-channel", @@ -2024,7 +2024,7 @@ describe("DiscordCodexBridge", () => { }), config: testConfig({ cwd: root, - gateway: { + workspace: { homeChannelId: "home-channel", workspaceForumChannelId: "workspace-forum", taskThreadsChannelId: "task-channel", @@ -2118,7 +2118,7 @@ describe("DiscordCodexBridge", () => { } }); - test("resumes a configured gateway main thread without creating Discord threads", async () => { + test("resumes a configured workspace main thread without creating Discord threads", async () => { const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); const bridge = new DiscordCodexBridge({ @@ -2126,7 +2126,7 @@ describe("DiscordCodexBridge", () => { transport, store: new MemoryStateStore(), config: testConfig({ - gateway: { + workspace: { homeChannelId: "home-channel", mainThreadId: "codex-main-thread", }, @@ -2146,18 +2146,18 @@ describe("DiscordCodexBridge", () => { discordThreadId: "home-channel", codexThreadId: "codex-main-thread", cwd: "/workspace", - mode: "gateway", + mode: "operator", }), ); await bridge.stop(); }); - test("replaces stale persisted gateway sessions when no main thread is configured", async () => { + test("replaces stale persisted workspace sessions when no main thread is configured", async () => { const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); const store = new MemoryStateStore({ ...emptyState(), - gateway: { + workspace: { homeChannelId: "home-channel", mainThreadId: "old-codex-thread", createdAt: "2026-05-13T00:00:00.000Z", @@ -2168,10 +2168,10 @@ describe("DiscordCodexBridge", () => { discordThreadId: "home-channel", parentChannelId: "home-channel", codexThreadId: "old-codex-thread", - title: "Codex Gateway", + title: "Codex Workspace", createdAt: "2026-05-13T00:00:00.000Z", cwd: "/workspace", - mode: "gateway", + mode: "workspace", }, ], }); @@ -2180,28 +2180,28 @@ describe("DiscordCodexBridge", () => { transport, store, config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, }), }); await bridge.start(); - await waitFor(() => bridge.stateForTest().gateway?.mainThreadId === "codex-thread-1"); + await waitFor(() => bridge.stateForTest().workspace?.mainThreadId === "codex-thread-1"); expect(client.resumeThreadCalls).toEqual([]); expect(client.startThreadCalls).toHaveLength(1); expect(client.startThreadCalls[0]?.dynamicTools).toEqual( expect.arrayContaining([ - expect.objectContaining({ namespace: "codex_gateway" }), + expect.objectContaining({ namespace: "codex_workspace" }), ]), ); expect(bridge.stateForTest().sessions.filter((session) => - session.mode === "gateway" + session.mode === "operator" )).toEqual([ expect.objectContaining({ codexThreadId: "codex-thread-1", }), ]); - expect(bridge.stateForTest().gateway).toEqual( + expect(bridge.stateForTest().workspace).toEqual( expect.objectContaining({ mainThreadId: "codex-thread-1", toolsVersion: 1, @@ -2210,13 +2210,13 @@ describe("DiscordCodexBridge", () => { await bridge.stop(); }); - test("recreates a tool-enabled gateway session when resume reports thread not found", async () => { + test("recreates a tool-enabled workspace session when resume reports thread not found", async () => { const client = new FakeCodexClient(); client.failedResumeThreadIds.add("missing-codex-thread"); const transport = new FakeDiscordTransport(); const store = new MemoryStateStore({ ...emptyState(), - gateway: { + workspace: { homeChannelId: "home-channel", mainThreadId: "missing-codex-thread", createdAt: "2026-05-13T00:00:00.000Z", @@ -2228,10 +2228,10 @@ describe("DiscordCodexBridge", () => { discordThreadId: "home-channel", parentChannelId: "home-channel", codexThreadId: "missing-codex-thread", - title: "Codex Gateway", + title: "Codex Workspace", createdAt: "2026-05-13T00:00:00.000Z", cwd: "/workspace", - mode: "gateway", + mode: "workspace", }, ], }); @@ -2240,12 +2240,12 @@ describe("DiscordCodexBridge", () => { transport, store, config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, }), }); await bridge.start(); - await waitFor(() => bridge.stateForTest().gateway?.mainThreadId === "codex-thread-1"); + await waitFor(() => bridge.stateForTest().workspace?.mainThreadId === "codex-thread-1"); expect(client.resumeThreadCalls[0]).toEqual( expect.objectContaining({ threadId: "missing-codex-thread" }), @@ -2253,17 +2253,17 @@ describe("DiscordCodexBridge", () => { expect(client.startThreadCalls).toHaveLength(1); expect(client.startThreadCalls[0]?.dynamicTools).toEqual( expect.arrayContaining([ - expect.objectContaining({ namespace: "codex_gateway" }), + expect.objectContaining({ namespace: "codex_workspace" }), ]), ); - expect(bridge.stateForTest().gateway).toEqual( + expect(bridge.stateForTest().workspace).toEqual( expect.objectContaining({ mainThreadId: "codex-thread-1", toolsVersion: 1, }), ); expect(bridge.stateForTest().sessions.filter((session) => - session.mode === "gateway" + session.mode === "operator" )).toEqual([ expect.objectContaining({ codexThreadId: "codex-thread-1", @@ -2272,7 +2272,7 @@ describe("DiscordCodexBridge", () => { await bridge.stop(); }); - test("routes bot mentions in the home channel to the gateway instead of creating threads", async () => { + test("routes bot mentions in the home channel to the workspace instead of creating threads", async () => { const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); const bridge = new DiscordCodexBridge({ @@ -2280,7 +2280,7 @@ describe("DiscordCodexBridge", () => { transport, store: new MemoryStateStore(), config: testConfig({ - gateway: { homeChannelId: "home-channel" }, + workspace: { homeChannelId: "home-channel" }, }), }); @@ -5128,7 +5128,7 @@ function inputText(value: unknown): string { return typeof text === "string" ? text : ""; } -function gatewayToolResult(value: unknown): unknown { +function workspaceToolResult(value: unknown): unknown { if (typeof value !== "object" || value === null || !("contentItems" in value)) { return undefined; } diff --git a/apps/discord-bridge/test/config.test.ts b/apps/discord-bridge/test/config.test.ts index 8ac2b4c..477277b 100644 --- a/apps/discord-bridge/test/config.test.ts +++ b/apps/discord-bridge/test/config.test.ts @@ -186,7 +186,7 @@ describe("parseConfig", () => { } }); - test("parses gateway home and main thread ids", () => { + test("parses workspace home and main thread ids", () => { const fromFlag = parseConfig( [ "--token", @@ -220,14 +220,14 @@ describe("parseConfig", () => { expect(fromFlag.type).toBe("run"); expect(fromEnv.type).toBe("run"); if (fromFlag.type === "run" && fromEnv.type === "run") { - expect(fromFlag.config.gateway).toEqual({ + expect(fromFlag.config.workspace).toEqual({ homeChannelId: "home-channel", mainThreadId: "main-thread", workspaceForumChannelId: "workspace-forum", taskThreadsChannelId: "task-channel", }); expect(fromFlag.config.flowBackendUrl).toBe("http://127.0.0.1:8089"); - expect(fromEnv.config.gateway).toEqual({ + expect(fromEnv.config.workspace).toEqual({ homeChannelId: "env-home", mainThreadId: "env-thread", workspaceForumChannelId: "env-workspace-forum", @@ -237,17 +237,17 @@ describe("parseConfig", () => { } }); - test("parses workspace-owned gateway surfaces and keeps env defaults as fallback", () => { + test("parses workspace-owned workspace surfaces and keeps env defaults as fallback", () => { const root = workspaceRoot(); writeWorkspaceToml(root, "crypto-workspace", ` -[[discord.gateway.surfaces]] +[[discord.workspace.surfaces]] key = "crypto" home_channel_id = "home-b" workspace_forum_channel_id = "forum-b" task_threads_channel_id = "tasks-b" `); writeWorkspaceToml(root, "research-workspace", ` -[[discord.gateway.surfaces]] +[[discord.workspace.surfaces]] key = "crypto" home_channel_id = "home-b" workspace_forum_channel_id = "forum-b" @@ -272,7 +272,7 @@ task_threads_channel_id = "tasks-b" expect(parsed.type).toBe("run"); if (parsed.type === "run") { - expect(parsed.config.gateway).toEqual({ + expect(parsed.config.workspace).toEqual({ homeChannelId: "home-a", workspaceForumChannelId: "forum-a", taskThreadsChannelId: "tasks-a", @@ -302,20 +302,20 @@ task_threads_channel_id = "tasks-b" } }); - test("rejects ambiguous workspace-owned gateway surfaces", () => { + test("rejects ambiguous workspace-owned workspace surfaces", () => { const multiple = workspaceRoot(); writeWorkspaceToml(multiple, "crypto-workspace", ` -[[discord.gateway.surfaces]] +[[discord.workspace.surfaces]] key = "default" home_channel_id = "home-a" -[[discord.gateway.surfaces]] +[[discord.workspace.surfaces]] key = "other" home_channel_id = "home-b" `); try { expect(() => parseConfig(baseArgsForRoot(multiple), {})).toThrow( - "workspace.toml discord.gateway.surfaces must contain one surface", + "workspace.toml discord.workspace.surfaces must contain one surface", ); } finally { rmSync(multiple, { recursive: true, force: true }); @@ -323,18 +323,18 @@ home_channel_id = "home-b" const duplicate = workspaceRoot(); writeWorkspaceToml(duplicate, "crypto-workspace", ` -[[discord.gateway.surfaces]] +[[discord.workspace.surfaces]] key = "default" home_channel_id = "home-a" `); writeWorkspaceToml(duplicate, "research-workspace", ` -[[discord.gateway.surfaces]] +[[discord.workspace.surfaces]] key = "default" home_channel_id = "home-b" `); try { expect(() => parseConfig(baseArgsForRoot(duplicate), {})).toThrow( - "Gateway surface key default is configured with different channels.", + "Workspace surface key default is configured with different channels.", ); } finally { rmSync(duplicate, { recursive: true, force: true }); @@ -342,25 +342,25 @@ home_channel_id = "home-b" const channelCollision = workspaceRoot(); writeWorkspaceToml(channelCollision, "crypto-workspace", ` -[[discord.gateway.surfaces]] +[[discord.workspace.surfaces]] key = "crypto" home_channel_id = "home-a" `); writeWorkspaceToml(channelCollision, "alpha-workspace", ` -[[discord.gateway.surfaces]] +[[discord.workspace.surfaces]] key = "alpha" home_channel_id = "home-a" `); try { expect(() => parseConfig(baseArgsForRoot(channelCollision), {})).toThrow( - "Gateway surface channel is configured more than once: home-a", + "Workspace surface channel is configured more than once: home-a", ); } finally { rmSync(channelCollision, { recursive: true, force: true }); } }); - test("ignores workspace.toml without gateway surfaces", () => { + test("ignores workspace.toml without workspace surfaces", () => { const root = workspaceRoot(); writeRootWorkspaceToml(root, ` name = "home" @@ -373,14 +373,14 @@ enabled = true expect(parsed.type).toBe("run"); if (parsed.type === "run") { - expect(parsed.config.gateway).toBeUndefined(); + expect(parsed.config.workspace).toBeUndefined(); } } finally { rmSync(root, { recursive: true, force: true }); } }); - test("rejects gateway main thread without home channel", () => { + test("rejects workspace main thread without home channel", () => { expect(() => parseConfig( [ @@ -393,10 +393,10 @@ enabled = true ], {}, ) - ).toThrow("Cannot set a gateway main thread without a gateway home channel."); + ).toThrow("Cannot set a workspace main thread without a workspace home channel."); }); - test("rejects partial gateway workbench channel configuration", () => { + test("rejects partial workspace workbench channel configuration", () => { expect(() => parseConfig( [ @@ -416,7 +416,7 @@ enabled = true ); }); - test("rejects gateway workbench channels that are not separate", () => { + test("rejects workspace workbench channels that are not separate", () => { expect(() => parseConfig( [ @@ -434,7 +434,7 @@ enabled = true {}, ) ).toThrow( - "Discord workbench channels must be separate from the gateway home channel and each other.", + "Discord workbench channels must be separate from the workspace home channel and each other.", ); }); diff --git a/apps/discord-bridge/test/hook-cli.test.ts b/apps/discord-bridge/test/hook-cli.test.ts index 6e6d075..be22757 100644 --- a/apps/discord-bridge/test/hook-cli.test.ts +++ b/apps/discord-bridge/test/hook-cli.test.ts @@ -9,7 +9,7 @@ import { upsertStopHookConfig, } from "../src/hook-cli.ts"; -describe("discord gateway hook CLI", () => { +describe("discord workspace hook CLI", () => { test("enables the current hooks feature in config.toml", () => { expect(enableHooksFeature("model = \"gpt-5\"\n")).toBe( "model = \"gpt-5\"\n\n[features]\nhooks = true\n", diff --git a/apps/discord-bridge/test/state.test.ts b/apps/discord-bridge/test/state.test.ts index 22faefc..fb50bf0 100644 --- a/apps/discord-bridge/test/state.test.ts +++ b/apps/discord-bridge/test/state.test.ts @@ -14,10 +14,10 @@ describe("JsonFileStateStore", () => { statePath, `${JSON.stringify({ version: 1, - gateway: { + workspace: { homeChannelId: "home-channel", - mainThreadId: "codex-gateway-thread", - statusMessageId: "message-gateway-status", + mainThreadId: "codex-workspace-thread", + statusMessageId: "message-workspace-status", createdAt: "2026-05-11T00:00:00.000Z", toolsVersion: 1, delegations: [ @@ -100,7 +100,7 @@ describe("JsonFileStateStore", () => { ownerUserId: "user-1", participantUserIds: ["user-2", "", "user-2", "user-3"], cwd: "/workspace/project", - mode: "gateway", + mode: "workspace", statusMessageId: "message-status-1", }, { @@ -137,10 +137,10 @@ describe("JsonFileStateStore", () => { const state = await new JsonFileStateStore(statePath).load(); - expect(state.gateway).toEqual({ + expect(state.workspace).toEqual({ homeChannelId: "home-channel", - mainThreadId: "codex-gateway-thread", - statusMessageId: "message-gateway-status", + mainThreadId: "codex-workspace-thread", + statusMessageId: "message-workspace-status", createdAt: "2026-05-11T00:00:00.000Z", toolsVersion: 1, delegations: [ @@ -221,7 +221,7 @@ describe("JsonFileStateStore", () => { "user-3", ]); expect(state.sessions[0]?.cwd).toBe("/workspace/project"); - expect(state.sessions[0]?.mode).toBe("gateway"); + expect(state.sessions[0]?.mode).toBe("workspace"); expect(state.sessions[0]?.statusMessageId).toBe("message-status-1"); expect(state.sessions[1]?.ownerUserId).toBeUndefined(); expect(state.sessions[1]?.sourceMessageId).toBeUndefined(); diff --git a/apps/flow-backend-systemd-local/src/index.ts b/apps/flow-backend-systemd-local/src/index.ts deleted file mode 100644 index 1a9ef96..0000000 --- a/apps/flow-backend-systemd-local/src/index.ts +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bun -import path from "node:path"; -import { dispatchFlowEvent, readFlowEvent, replayFlowEvent } from "./backend.ts"; -import { helpText, parseCli } from "./config.ts"; -import { serveFlowBackend } from "./server.ts"; -import { FlowBackendStore, type FlowRunStatus } from "./store.ts"; - -await main().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -}); - -async function main(): Promise { - const cli = parseCli(Bun.argv.slice(2)); - if (cli.kind === "help") { - process.stdout.write(helpText()); - return; - } - if (cli.kind === "serve") { - const server = serveFlowBackend(cli.config); - process.stdout.write(`codex-flow-systemd-local listening on http://${server.hostname}:${server.port}\n`); - return new Promise(() => undefined); - } - const store = new FlowBackendStore(path.join(cli.config.dataDir, "flow-backend.sqlite")); - try { - if (cli.kind === "dispatch") { - const event = await readFlowEvent(cli.eventPath); - const result = await dispatchFlowEvent({ - config: cli.config, - store, - event, - wait: cli.wait, - env: process.env, - }); - writeJson(result); - return; - } - if (cli.kind === "list-events") { - writeJson({ events: store.listEvents({ type: cli.type, limit: cli.limit }) }); - return; - } - if (cli.kind === "show-event") { - const event = store.getEvent(cli.eventId); - if (!event) { - throw new Error(`Unknown event: ${cli.eventId}`); - } - writeJson({ event, runs: store.listRunsByEvent(cli.eventId) }); - return; - } - if (cli.kind === "replay-event") { - writeJson(await replayFlowEvent({ - config: cli.config, - store, - eventId: cli.eventId, - wait: cli.wait, - env: process.env, - })); - return; - } - if (cli.kind === "list-runs") { - writeJson({ - runs: store.listRuns({ - eventId: cli.eventId, - status: cli.status ? requireRunStatus(cli.status) : undefined, - limit: cli.limit, - }), - }); - return; - } - if (cli.kind === "show-run") { - const run = store.getRun(cli.runId); - if (!run) { - throw new Error(`Unknown run: ${cli.runId}`); - } - writeJson({ run }); - return; - } - } finally { - store.close(); - } -} - -function writeJson(value: unknown): void { - process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); -} - -function requireRunStatus(value: string): FlowRunStatus { - if (value === "queued" || value === "running" || value === "completed" || value === "failed") { - return value; - } - throw new Error("run status must be queued, running, completed, or failed"); -} diff --git a/apps/flow-backend-systemd-local/src/server.ts b/apps/flow-backend-systemd-local/src/server.ts deleted file mode 100644 index 1981d35..0000000 --- a/apps/flow-backend-systemd-local/src/server.ts +++ /dev/null @@ -1,119 +0,0 @@ -import path from "node:path"; -import type { FlowBackendConfig } from "./config.ts"; -import { dispatchFlowEvent, normalizeFlowEvent, replayFlowEvent } from "./backend.ts"; -import { requestSignature, verifyBodySignature } from "./signature.ts"; -import { FlowBackendStore, type FlowRunStatus } from "./store.ts"; - -export function serveFlowBackend(config: FlowBackendConfig): ReturnType { - const store = new FlowBackendStore(path.join(config.dataDir, "flow-backend.sqlite")); - return Bun.serve({ - hostname: config.host, - port: config.port, - async fetch(request) { - const url = new URL(request.url); - if (request.method === "GET" && url.pathname === "/healthz") { - return json({ ok: true }); - } - if (request.method === "POST" && (url.pathname === "/events" || url.pathname === "/flow-events")) { - const body = await request.text(); - if (!validSignature(config, body, request.headers)) { - return json({ error: "invalid signature" }, 401); - } - const event = normalizeFlowEvent(JSON.parse(body) as unknown); - const result = await dispatchFlowEvent({ config, store, event }); - return json(result, 202); - } - if (request.method === "GET" && url.pathname === "/events") { - return json({ - events: store.listEvents({ - type: url.searchParams.get("type") ?? undefined, - limit: numberParam(url.searchParams.get("limit")), - }), - }); - } - const eventMatch = url.pathname.match(/^\/events\/([^/]+)(?:\/(replay))?$/); - if (eventMatch?.[1] && request.method === "GET" && !eventMatch[2]) { - const eventId = decodeURIComponent(eventMatch[1]); - const event = store.getEvent(eventId); - if (!event) { - return json({ error: "event not found" }, 404); - } - return json({ event, runs: store.listRunsByEvent(eventId) }); - } - if (eventMatch?.[1] && eventMatch[2] === "replay" && request.method === "POST") { - const body = await request.text(); - if (!validSignature(config, body, request.headers)) { - return json({ error: "invalid signature" }, 401); - } - const params = parseBody(body); - const result = await replayFlowEvent({ - config, - store, - eventId: decodeURIComponent(eventMatch[1]), - wait: Boolean(params.wait), - env: process.env, - }); - return json(result, 202); - } - if (request.method === "GET" && url.pathname === "/runs") { - const eventId = url.searchParams.get("eventId"); - const status = url.searchParams.get("status"); - return json({ - ...(eventId ? { eventId } : {}), - runs: store.listRuns({ - eventId: eventId ?? undefined, - status: status ? requireRunStatus(status) : undefined, - limit: numberParam(url.searchParams.get("limit")), - }), - }); - } - const runMatch = url.pathname.match(/^\/runs\/([^/]+)$/); - if (runMatch?.[1] && request.method === "GET") { - const run = store.getRun(decodeURIComponent(runMatch[1])); - if (!run) { - return json({ error: "run not found" }, 404); - } - return json({ run }); - } - return json({ error: "not found" }, 404); - }, - }); -} - -function validSignature(config: FlowBackendConfig, body: string, headers: Headers): boolean { - return !config.secret || verifyBodySignature(config.secret, body, requestSignature(headers)); -} - -function numberParam(value: string | null): number | undefined { - if (!value) { - return undefined; - } - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - -function parseBody(body: string): Record { - if (!body.trim()) { - return {}; - } - const parsed = JSON.parse(body) as unknown; - return isRecord(parsed) ? parsed : {}; -} - -function requireRunStatus(value: string): FlowRunStatus { - if (value === "queued" || value === "running" || value === "completed" || value === "failed") { - return value; - } - throw new Error("run status must be queued, running, completed, or failed"); -} - -function json(value: unknown, status = 200): Response { - return new Response(JSON.stringify(value, null, 2), { - status, - headers: { "content-type": "application/json" }, - }); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/apps/flow-backend-systemd-local/tsconfig.json b/apps/flow-backend-systemd-local/tsconfig.json deleted file mode 100644 index 1a52ffe..0000000 --- a/apps/flow-backend-systemd-local/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "module": "ESNext", - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "target": "ES2022", - "lib": ["ESNext"], - "strict": true, - "skipLibCheck": true, - "noUncheckedIndexedAccess": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "forceConsistentCasingInFileNames": true, - "noEmit": true, - "types": ["node", "bun"], - "baseUrl": ".", - "paths": { - "@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"], - "@peezy.tech/codex-flows/*": ["../../packages/codex-client/src/*"], - "@peezy.tech/flow-runtime": ["../../packages/flow-runtime/src/index.ts"], - "@peezy.tech/flow-runtime/*": ["../../packages/flow-runtime/src/*"] - } - }, - "include": ["src/**/*.ts", "test/**/*.ts"] -} diff --git a/apps/gateway/package.json b/apps/gateway/package.json deleted file mode 100644 index 06a14ab..0000000 --- a/apps/gateway/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "codex-gateway-local", - "version": "0.1.0", - "description": "Local Codex gateway server for browser and transport clients.", - "type": "module", - "private": true, - "license": "Apache-2.0", - "bin": { - "codex-gateway-local": "./src/index.ts" - }, - "scripts": { - "build": "tsc --noEmit", - "check:types": "tsc --noEmit", - "start": "bun ./src/index.ts serve", - "test": "bun test test/*.test.ts" - }, - "dependencies": { - "@peezy.tech/codex-flows": "workspace:*" - }, - "devDependencies": { - "@types/bun": "catalog:", - "@types/node": "catalog:", - "typescript": "catalog:" - } -} diff --git a/apps/gateway/src/index.ts b/apps/gateway/src/index.ts deleted file mode 100644 index d746b36..0000000 --- a/apps/gateway/src/index.ts +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env bun -import { - CodexAppServerClient, - CodexStdioTransport, -} from "@peezy.tech/codex-flows"; -import { - CodexGatewayProtocolServer, - type CodexGatewayPeer, -} from "@peezy.tech/codex-flows/gateway"; - -import { parseArgs, type GatewayCliArgs } from "./args.ts"; - -const defaultAppServerUrl = "ws://127.0.0.1:3585"; - -async function main(): Promise { - const parsed = parseArgs(Bun.argv.slice(2), process.env); - if (parsed.type === "help") { - process.stdout.write(parsed.text); - return; - } - - const client = createAppServerClient(parsed); - client.on("stderr", (line) => process.stderr.write(`${line}\n`)); - await client.connect(); - - const gateway = new CodexGatewayProtocolServer({ - appServer: client, - serverName: "codex-gateway-local", - serverVersion: "0.1.0", - }); - const peers = new WeakMap, CodexGatewayPeer>(); - const server = Bun.serve({ - hostname: parsed.hostname, - port: parsed.port, - fetch(request, bunServer) { - if (bunServer.upgrade(request)) { - return undefined; - } - return new Response("Codex gateway WebSocket server\n", { - status: 426, - headers: { "content-type": "text/plain; charset=utf-8" }, - }); - }, - websocket: { - open(socket) { - const peer: CodexGatewayPeer = { - send: (message) => socket.send(message), - }; - peers.set(socket, peer); - gateway.addPeer(peer); - }, - message(socket, message) { - const peer = peers.get(socket); - if (!peer) { - return; - } - void gateway.handleMessage(peer, websocketMessageToString(message)) - .catch((error: unknown) => { - gateway.sendGatewayEvent(peer, { - type: "appServer.error", - at: new Date().toISOString(), - message: errorMessage(error), - }); - }); - }, - close(socket) { - const peer = peers.get(socket); - if (peer) { - gateway.removePeer(peer); - peers.delete(socket); - } - }, - }, - }); - - process.stdout.write( - `codex-gateway-local listening on ws://${server.hostname}:${server.port}\n`, - ); - process.stdout.write( - `codex-gateway-local app-server ${ - parsed.localAppServer - ? "local stdio" - : parsed.appServerUrl ?? - process.env.CODEX_WORKSPACE_APP_SERVER_WS_URL ?? - defaultAppServerUrl - }\n`, - ); - - await waitForShutdown(server, client); -} - -function createAppServerClient( - args: Extract, -): CodexAppServerClient { - const appServerUrl = - args.appServerUrl ?? - process.env.CODEX_WORKSPACE_APP_SERVER_WS_URL ?? - defaultAppServerUrl; - return new CodexAppServerClient({ - transport: args.localAppServer - ? new CodexStdioTransport({ - args: localAppServerArgs(), - requestTimeoutMs: 90_000, - }) - : undefined, - webSocketTransportOptions: args.localAppServer - ? undefined - : { url: appServerUrl, requestTimeoutMs: 90_000 }, - clientName: "codex-gateway-local", - clientTitle: "Codex Gateway Local", - clientVersion: "0.1.0", - }); -} - -function localAppServerArgs(): string[] { - return [ - "app-server", - "--listen", - "stdio://", - "--enable", - "apps", - "--enable", - "hooks", - ]; -} - -function websocketMessageToString(message: string | Buffer): string { - return typeof message === "string" ? message : message.toString("utf8"); -} - -function waitForShutdown( - server: Bun.Server, - client: CodexAppServerClient, -): Promise { - return new Promise((resolve) => { - const shutdown = () => { - process.off("SIGINT", shutdown); - process.off("SIGTERM", shutdown); - server.stop(true); - client.close(); - resolve(); - }; - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); - }); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -await main(); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6d06a9d..569cbae 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -33,12 +33,12 @@ import { type v2, } from "@peezy.tech/codex-flows/browser"; import { - CodexGatewayClient, - type GatewayEvent, -} from "@peezy.tech/codex-flows/gateway"; + CodexWorkspaceBackendClient, + type WorkspaceBackendEvent, +} from "@peezy.tech/codex-flows/workspace-backend"; import { ThemeProvider } from "./components/theme-provider.tsx"; -import { gatewayStorageKey, initialGatewayWsUrl } from "./gateway-url.ts"; +import { workspaceBackendStorageKey, initialWorkspaceBackendWsUrl } from "./workspace-backend-url.ts"; type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; @@ -59,11 +59,11 @@ export function App() { } function BareCodexApp() { - const clientRef = useRef(null); + const clientRef = useRef(null); const authRef = useRef(null); const [wsUrl, setWsUrl] = useState(() => - initialGatewayWsUrl({ - envUrl: import.meta.env.VITE_CODEX_GATEWAY_WS_URL, + initialWorkspaceBackendWsUrl({ + envUrl: import.meta.env.VITE_CODEX_WORKSPACE_BACKEND_WS_URL, location: window.location, storage: window.localStorage, }) @@ -206,7 +206,7 @@ function BareCodexApp() { } clientRef.current?.close(); - const client = new CodexGatewayClient({ + const client = new CodexWorkspaceBackendClient({ webSocketTransportOptions: { url, requestTimeoutMs: 90_000 }, clientName: "bare-web", clientTitle: "Codex Bare Web", @@ -223,17 +223,17 @@ function BareCodexApp() { body: previewJson(message.params, 900), }); }); - client.on("gatewayEvent", (event: GatewayEvent) => { + client.on("workspaceBackendEvent", (event: WorkspaceBackendEvent) => { appendEvent({ kind: "control", - title: `gateway ${event.type}`, + title: `workspace backend ${event.type}`, body: previewJson(event, 900), }); }); client.on("error", (eventError: unknown) => { appendEvent({ kind: "error", - title: "gateway transport error", + title: "workspace backend transport error", body: errorMessage(eventError), }); setError(errorMessage(eventError)); @@ -255,7 +255,7 @@ function BareCodexApp() { setError(undefined); try { await client.connect(); - window.localStorage.setItem(gatewayStorageKey, url); + window.localStorage.setItem(workspaceBackendStorageKey, url); setConnectedUrl(url); setStatus("connected"); appendEvent({ kind: "control", title: "connected", body: url }); @@ -506,7 +506,7 @@ function BareCodexApp() {

Codex Bare

- {connectedUrl ?? "No gateway connection"} + {connectedUrl ?? "No workspace backend connection"}

; - storage?: Pick; -}; - -export function initialGatewayWsUrl(options: GatewayUrlOptions): string { - return options.storage?.getItem(gatewayStorageKey) ?? - options.envUrl ?? - proxiedGatewayWsUrl(options.location); -} - -export function proxiedGatewayWsUrl( - location: Pick, -): string { - const protocol = location.protocol === "https:" ? "wss:" : "ws:"; - return `${protocol}//${location.host}/__codex-gateway`; -} diff --git a/apps/web/src/workspace-backend-url.ts b/apps/web/src/workspace-backend-url.ts new file mode 100644 index 0000000..eb7bb8b --- /dev/null +++ b/apps/web/src/workspace-backend-url.ts @@ -0,0 +1,20 @@ +export const workspaceBackendStorageKey = "codex-bare.workspace-backend-url"; + +export type WorkspaceBackendUrlOptions = { + envUrl?: string; + location: Pick; + storage?: Pick; +}; + +export function initialWorkspaceBackendWsUrl(options: WorkspaceBackendUrlOptions): string { + return options.storage?.getItem(workspaceBackendStorageKey) ?? + options.envUrl ?? + proxiedWorkspaceBackendWsUrl(options.location); +} + +export function proxiedWorkspaceBackendWsUrl( + location: Pick, +): string { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${location.host}/__codex-workspace-backend`; +} diff --git a/apps/web/test/gateway-url.test.ts b/apps/web/test/gateway-url.test.ts deleted file mode 100644 index 8054ec2..0000000 --- a/apps/web/test/gateway-url.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - gatewayStorageKey, - initialGatewayWsUrl, - proxiedGatewayWsUrl, -} from "../src/gateway-url.ts"; - -describe("gateway URLs", () => { - test("uses the proxied gateway path on http origins", () => { - expect(proxiedGatewayWsUrl({ protocol: "http:", host: "localhost:5173" })) - .toBe("ws://localhost:5173/__codex-gateway"); - }); - - test("uses wss for https origins", () => { - expect(proxiedGatewayWsUrl({ protocol: "https:", host: "flows.peezy.tech" })) - .toBe("wss://flows.peezy.tech/__codex-gateway"); - }); - - test("prefers stored gateway URLs over env defaults", () => { - const values = new Map([ - [gatewayStorageKey, "ws://127.0.0.1:4599"], - ]); - expect( - initialGatewayWsUrl({ - envUrl: "ws://127.0.0.1:3586", - location: { protocol: "http:", host: "localhost:5173" }, - storage: { getItem: (key) => values.get(key) ?? null }, - }), - ).toBe("ws://127.0.0.1:4599"); - }); - - test("uses env defaults before deriving the proxied URL", () => { - expect( - initialGatewayWsUrl({ - envUrl: "ws://127.0.0.1:3586", - location: { protocol: "http:", host: "localhost:5173" }, - }), - ).toBe("ws://127.0.0.1:3586"); - }); -}); diff --git a/apps/web/test/workspace-backend-url.test.ts b/apps/web/test/workspace-backend-url.test.ts new file mode 100644 index 0000000..30157c6 --- /dev/null +++ b/apps/web/test/workspace-backend-url.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; + +import { + workspaceBackendStorageKey, + initialWorkspaceBackendWsUrl, + proxiedWorkspaceBackendWsUrl, +} from "../src/workspace-backend-url.ts"; + +describe("workspace backend URLs", () => { + test("uses the proxied workspace backend path on http origins", () => { + expect(proxiedWorkspaceBackendWsUrl({ protocol: "http:", host: "localhost:5173" })) + .toBe("ws://localhost:5173/__codex-workspace-backend"); + }); + + test("uses wss for https origins", () => { + expect(proxiedWorkspaceBackendWsUrl({ protocol: "https:", host: "flows.peezy.tech" })) + .toBe("wss://flows.peezy.tech/__codex-workspace-backend"); + }); + + test("prefers stored workspace backend URLs over env defaults", () => { + const values = new Map([ + [workspaceBackendStorageKey, "ws://127.0.0.1:4599"], + ]); + expect( + initialWorkspaceBackendWsUrl({ + envUrl: "ws://127.0.0.1:3586", + location: { protocol: "http:", host: "localhost:5173" }, + storage: { getItem: (key) => values.get(key) ?? null }, + }), + ).toBe("ws://127.0.0.1:4599"); + }); + + test("uses env defaults before deriving the proxied URL", () => { + expect( + initialWorkspaceBackendWsUrl({ + envUrl: "ws://127.0.0.1:3586", + location: { protocol: "http:", host: "localhost:5173" }, + }), + ).toBe("ws://127.0.0.1:3586"); + }); +}); diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index 30147f6..5bbaaa5 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -25,7 +25,7 @@ "@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"], "@peezy.tech/codex-flows/browser": ["../../packages/codex-client/src/browser.ts"], "@peezy.tech/codex-flows/flows": ["../../packages/codex-client/src/app-server/flows.ts"], - "@peezy.tech/codex-flows/gateway": ["../../packages/codex-client/src/gateway/index.ts"], + "@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"], "@peezy.tech/codex-flows/generated": ["../../packages/codex-client/src/app-server/generated/index.ts"], "@peezy.tech/codex-flows/generated/*": ["../../packages/codex-client/src/app-server/generated/*"], "@peezy.tech/codex-flows/rpc": ["../../packages/codex-client/src/app-server/rpc.ts"], diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 417c0d5..f70605d 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -11,7 +11,7 @@ "@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"], "@peezy.tech/codex-flows/browser": ["../../packages/codex-client/src/browser.ts"], "@peezy.tech/codex-flows/flows": ["../../packages/codex-client/src/app-server/flows.ts"], - "@peezy.tech/codex-flows/gateway": ["../../packages/codex-client/src/gateway/index.ts"], + "@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"], "@peezy.tech/codex-flows/generated": ["../../packages/codex-client/src/app-server/generated/index.ts"], "@peezy.tech/codex-flows/generated/*": ["../../packages/codex-client/src/app-server/generated/*"], "@peezy.tech/codex-flows/rpc": ["../../packages/codex-client/src/app-server/rpc.ts"], diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index aa73983..d9ef8db 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,8 +7,8 @@ const allowedHosts = (process.env.VITE_ALLOWED_HOSTS ?? "") .split(",") .map((host) => host.trim()) .filter(Boolean); -const codexGatewayTarget = - process.env.VITE_CODEX_GATEWAY_PROXY_TARGET ?? "ws://127.0.0.1:3586"; +const codexWorkspaceBackendTarget = + process.env.VITE_CODEX_WORKSPACE_BACKEND_PROXY_TARGET ?? "ws://127.0.0.1:3586"; export default defineConfig({ base: process.env.VITE_BASE_PATH ?? "/", @@ -20,9 +20,9 @@ export default defineConfig({ __dirname, "../../packages/codex-client/src/browser.ts", ), - "@peezy.tech/codex-flows/gateway": path.resolve( + "@peezy.tech/codex-flows/workspace-backend": path.resolve( __dirname, - "../../packages/codex-client/src/gateway/index.ts", + "../../packages/codex-client/src/workspace-backend/index.ts", ), "@peezy.tech/codex-flows": path.resolve( __dirname, @@ -46,8 +46,8 @@ export default defineConfig({ server: { allowedHosts: allowedHosts.length > 0 ? allowedHosts : undefined, proxy: { - "/__codex-gateway": { - target: codexGatewayTarget, + "/__codex-workspace-backend": { + target: codexWorkspaceBackendTarget, ws: true, rewrite: () => "/", configure: (proxy) => { diff --git a/apps/flow-backend-systemd-local/package.json b/apps/workspace-backend/package.json similarity index 64% rename from apps/flow-backend-systemd-local/package.json rename to apps/workspace-backend/package.json index d62a228..a6fcc40 100644 --- a/apps/flow-backend-systemd-local/package.json +++ b/apps/workspace-backend/package.json @@ -1,12 +1,12 @@ { - "name": "codex-flow-systemd-local", + "name": "codex-workspace-backend-local", "version": "0.1.0", - "description": "Local flow execution backend suitable for a systemd-managed service.", + "description": "Local Codex workspace backend with app-server control and flow capabilities.", "type": "module", "private": true, "license": "Apache-2.0", "bin": { - "codex-flow-systemd-local": "./src/index.ts" + "codex-workspace-backend-local": "./src/index.ts" }, "scripts": { "build": "tsc --noEmit", @@ -15,6 +15,7 @@ "test": "bun test test/*.test.ts" }, "dependencies": { + "@peezy.tech/codex-flows": "workspace:*", "@peezy.tech/flow-runtime": "workspace:*" }, "devDependencies": { diff --git a/apps/gateway/src/args.ts b/apps/workspace-backend/src/args.ts similarity index 62% rename from apps/gateway/src/args.ts rename to apps/workspace-backend/src/args.ts index 2a611fc..ea1da92 100644 --- a/apps/gateway/src/args.ts +++ b/apps/workspace-backend/src/args.ts @@ -1,10 +1,16 @@ -export type GatewayCliArgs = +export type WorkspaceBackendCliArgs = | { type: "serve"; port: number; hostname: string; appServerUrl?: string; localAppServer: boolean; + cwd?: string; + dataDir?: string; + secret?: string; + executor?: string; + bunCommand?: string; + flowRunnerPath?: string; } | { type: "help"; @@ -14,7 +20,7 @@ export type GatewayCliArgs = export function parseArgs( argv: string[], env: Record = process.env, -): GatewayCliArgs { +): WorkspaceBackendCliArgs { if (argv.includes("--help") || argv.includes("-h")) { return { type: "help", text: helpText() }; } @@ -23,20 +29,26 @@ export function parseArgs( throw new Error(`Unknown command: ${command}`); } const appServerUrl = - stringFlag(argv, "app-server-url") ?? env.CODEX_GATEWAY_APP_SERVER_URL; + stringFlag(argv, "app-server-url") ?? env.CODEX_WORKSPACE_BACKEND_APP_SERVER_URL; const localAppServer = booleanFlag(argv, "local-app-server") || - booleanEnv(env.CODEX_GATEWAY_LOCAL_APP_SERVER); + booleanEnv(env.CODEX_WORKSPACE_BACKEND_LOCAL_APP_SERVER); if (appServerUrl && localAppServer) { throw new Error("Cannot set both --local-app-server and --app-server-url."); } return { type: "serve", port: integerFlag(argv, "port") ?? - integerEnv(env.CODEX_GATEWAY_PORT) ?? + integerEnv(env.CODEX_WORKSPACE_BACKEND_PORT) ?? 3586, - hostname: stringFlag(argv, "host") ?? env.CODEX_GATEWAY_HOST ?? "127.0.0.1", + hostname: stringFlag(argv, "host") ?? env.CODEX_WORKSPACE_BACKEND_HOST ?? "127.0.0.1", appServerUrl, localAppServer, + cwd: stringFlag(argv, "cwd"), + dataDir: stringFlag(argv, "data-dir"), + secret: stringFlag(argv, "secret"), + executor: stringFlag(argv, "executor"), + bunCommand: stringFlag(argv, "bun"), + flowRunnerPath: stringFlag(argv, "flow-runner"), }; } @@ -78,22 +90,28 @@ function booleanEnv(value: string | undefined): boolean { } function helpText(): string { - return `codex-gateway-local serves the local Codex gateway protocol. + return `codex-workspace-backend-local serves the local Codex workspace backend protocol. Usage: - codex-gateway-local serve [options] + codex-workspace-backend-local serve [options] Options: --host Host to bind. Defaults to 127.0.0.1. --port Port to bind. Defaults to 3586. --app-server-url Existing app-server WebSocket URL. --local-app-server Start a local app-server over stdio. + --cwd Workspace root for flow discovery. + --data-dir Durable flow backend state directory. + --secret Optional HMAC secret for HTTP flow dispatch. + --executor Flow executor: direct or systemd-run. + --bun Bun command for flow execution. + --flow-runner Flow runner script path. --help, -h Show this help. Environment: - CODEX_GATEWAY_HOST - CODEX_GATEWAY_PORT - CODEX_GATEWAY_APP_SERVER_URL - CODEX_GATEWAY_LOCAL_APP_SERVER + CODEX_WORKSPACE_BACKEND_HOST + CODEX_WORKSPACE_BACKEND_PORT + CODEX_WORKSPACE_BACKEND_APP_SERVER_URL + CODEX_WORKSPACE_BACKEND_LOCAL_APP_SERVER `; } diff --git a/apps/flow-backend-systemd-local/src/backend.ts b/apps/workspace-backend/src/flow/backend.ts similarity index 99% rename from apps/flow-backend-systemd-local/src/backend.ts rename to apps/workspace-backend/src/flow/backend.ts index 28e4338..ad83407 100644 --- a/apps/flow-backend-systemd-local/src/backend.ts +++ b/apps/workspace-backend/src/flow/backend.ts @@ -157,7 +157,7 @@ function createRunRecord( flowName: flow.manifest.name, stepName: step.name, status: "queued", - backend: "systemd-local", + backend: "workspace-local", executor: config.executor, eventPath, createdAt: new Date().toISOString(), diff --git a/apps/flow-backend-systemd-local/src/config.ts b/apps/workspace-backend/src/flow/config.ts similarity index 92% rename from apps/flow-backend-systemd-local/src/config.ts rename to apps/workspace-backend/src/flow/config.ts index b03f92c..504d0ea 100644 --- a/apps/flow-backend-systemd-local/src/config.ts +++ b/apps/workspace-backend/src/flow/config.ts @@ -203,19 +203,19 @@ export function parseCli(argv: string[], env: Record } export function defaultFlowRunnerPath(): string { - return path.resolve(import.meta.dir, "..", "..", "flow-runner", "src", "index.ts"); + return path.resolve(import.meta.dir, "..", "..", "..", "flow-runner", "src", "index.ts"); } export function helpText(): string { return [ "Usage:", - " codex-flow-systemd-local serve [--cwd ] [--data-dir ] [--host ] [--port ]", - " codex-flow-systemd-local dispatch --event [--cwd ] [--data-dir ] [--wait]", - " codex-flow-systemd-local list-events [--type ] [--limit ]", - " codex-flow-systemd-local show-event ", - " codex-flow-systemd-local replay-event [--wait]", - " codex-flow-systemd-local list-runs [--event-id ] [--status ] [--limit ]", - " codex-flow-systemd-local show-run ", + " codex-workspace-backend-local serve [--cwd ] [--data-dir ] [--host ] [--port ]", + " codex-workspace-backend-local dispatch --event [--cwd ] [--data-dir ] [--wait]", + " codex-workspace-backend-local list-events [--type ] [--limit ]", + " codex-workspace-backend-local show-event ", + " codex-workspace-backend-local replay-event [--wait]", + " codex-workspace-backend-local list-runs [--event-id ] [--status ] [--limit ]", + " codex-workspace-backend-local show-run ", "", "Environment:", " CODEX_FLOW_BACKEND_SECRET Optional HMAC secret for HTTP dispatches", diff --git a/apps/flow-backend-systemd-local/src/executor.ts b/apps/workspace-backend/src/flow/executor.ts similarity index 100% rename from apps/flow-backend-systemd-local/src/executor.ts rename to apps/workspace-backend/src/flow/executor.ts diff --git a/apps/workspace-backend/src/flow/server.ts b/apps/workspace-backend/src/flow/server.ts new file mode 100644 index 0000000..dbdb37d --- /dev/null +++ b/apps/workspace-backend/src/flow/server.ts @@ -0,0 +1,195 @@ +import path from "node:path"; +import type { FlowBackendConfig } from "./config.ts"; +import { dispatchFlowEvent, normalizeFlowEvent, replayFlowEvent } from "./backend.ts"; +import { requestSignature, verifyBodySignature } from "./signature.ts"; +import { FlowBackendStore, type FlowRunStatus } from "./store.ts"; + +export type WorkspaceFlowCapabilityOptions = { + config: FlowBackendConfig; + store?: FlowBackendStore; + env?: Record; +}; + +export class WorkspaceFlowCapability { + readonly config: FlowBackendConfig; + readonly store: FlowBackendStore; + #env: Record; + #ownsStore: boolean; + + constructor(options: WorkspaceFlowCapabilityOptions) { + this.config = options.config; + this.store = options.store ?? + new FlowBackendStore(path.join(options.config.dataDir, "flow-backend.sqlite")); + this.#env = options.env ?? process.env; + this.#ownsStore = !options.store; + } + + close(): void { + if (this.#ownsStore) { + this.store.close(); + } + } + + async dispatch(event: unknown): Promise { + return await dispatchFlowEvent({ + config: this.config, + store: this.store, + event: normalizeFlowEvent(event), + env: this.#env, + }); + } + + async replay(eventId: string, options: { wait?: boolean } = {}): Promise { + return await replayFlowEvent({ + config: this.config, + store: this.store, + eventId, + wait: Boolean(options.wait), + env: this.#env, + }); + } + + listEvents(options: { type?: string; limit?: number } = {}): unknown { + return { + events: this.store.listEvents({ + type: options.type, + limit: options.limit, + }), + }; + } + + getEvent(eventId: string): unknown { + const event = this.store.getEvent(eventId); + if (!event) { + throw new Error(`Unknown event: ${eventId}`); + } + return { event, runs: this.store.listRunsByEvent(eventId) }; + } + + listRuns(options: { + eventId?: string; + status?: string; + limit?: number; + } = {}): unknown { + return { + ...(options.eventId ? { eventId: options.eventId } : {}), + runs: this.store.listRuns({ + eventId: options.eventId, + status: options.status ? requireRunStatus(options.status) : undefined, + limit: options.limit, + }), + }; + } + + getRun(runId: string): unknown { + const run = this.store.getRun(runId); + if (!run) { + throw new Error(`Unknown run: ${runId}`); + } + return { run }; + } + + async handleHttp(request: Request): Promise { + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/healthz") { + return json({ ok: true }); + } + if (request.method === "POST" && (url.pathname === "/events" || url.pathname === "/flow-events")) { + const body = await request.text(); + if (!validSignature(this.config, body, request.headers)) { + return json({ error: "invalid signature" }, 401); + } + return json(await this.dispatch(JSON.parse(body) as unknown), 202); + } + if (request.method === "GET" && url.pathname === "/events") { + return json(this.listEvents({ + type: url.searchParams.get("type") ?? undefined, + limit: numberParam(url.searchParams.get("limit")), + })); + } + const eventMatch = url.pathname.match(/^\/events\/([^/]+)(?:\/(replay))?$/); + if (eventMatch?.[1] && request.method === "GET" && !eventMatch[2]) { + try { + return json(this.getEvent(decodeURIComponent(eventMatch[1]))); + } catch { + return json({ error: "event not found" }, 404); + } + } + if (eventMatch?.[1] && eventMatch[2] === "replay" && request.method === "POST") { + const body = await request.text(); + if (!validSignature(this.config, body, request.headers)) { + return json({ error: "invalid signature" }, 401); + } + const params = parseBody(body); + const result = await this.replay(decodeURIComponent(eventMatch[1]), { + wait: Boolean(params.wait), + }); + return json(result, 202); + } + if (request.method === "GET" && url.pathname === "/runs") { + return json(this.listRuns({ + eventId: url.searchParams.get("eventId") ?? undefined, + status: url.searchParams.get("status") ?? undefined, + limit: numberParam(url.searchParams.get("limit")), + })); + } + const runMatch = url.pathname.match(/^\/runs\/([^/]+)$/); + if (runMatch?.[1] && request.method === "GET") { + try { + return json(this.getRun(decodeURIComponent(runMatch[1]))); + } catch { + return json({ error: "run not found" }, 404); + } + } + return undefined; + } +} + +export function serveFlowBackend(config: FlowBackendConfig): ReturnType { + const flow = new WorkspaceFlowCapability({ config }); + return Bun.serve({ + hostname: config.host, + port: config.port, + async fetch(request) { + return await flow.handleHttp(request) ?? json({ error: "not found" }, 404); + }, + }); +} + +function validSignature(config: FlowBackendConfig, body: string, headers: Headers): boolean { + return !config.secret || verifyBodySignature(config.secret, body, requestSignature(headers)); +} + +function numberParam(value: string | null): number | undefined { + if (!value) { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function parseBody(body: string): Record { + if (!body.trim()) { + return {}; + } + const parsed = JSON.parse(body) as unknown; + return isRecord(parsed) ? parsed : {}; +} + +function requireRunStatus(value: string): FlowRunStatus { + if (value === "queued" || value === "running" || value === "completed" || value === "failed") { + return value; + } + throw new Error("run status must be queued, running, completed, or failed"); +} + +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value, null, 2), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/flow-backend-systemd-local/src/signature.ts b/apps/workspace-backend/src/flow/signature.ts similarity index 100% rename from apps/flow-backend-systemd-local/src/signature.ts rename to apps/workspace-backend/src/flow/signature.ts diff --git a/apps/flow-backend-systemd-local/src/store.ts b/apps/workspace-backend/src/flow/store.ts similarity index 99% rename from apps/flow-backend-systemd-local/src/store.ts rename to apps/workspace-backend/src/flow/store.ts index a2e2dc0..566ec45 100644 --- a/apps/flow-backend-systemd-local/src/store.ts +++ b/apps/workspace-backend/src/flow/store.ts @@ -11,7 +11,7 @@ export type FlowRunRecord = { flowName: string; stepName: string; status: FlowRunStatus; - backend: "systemd-local"; + backend: "workspace-local"; executor: string; unit?: string; eventPath: string; @@ -252,7 +252,7 @@ function rowToRunRecord(row: unknown): FlowRunRecord { flowName: String(row.flow_name), stepName: String(row.step_name), status: String(row.status) as FlowRunStatus, - backend: "systemd-local", + backend: "workspace-local", executor: String(row.executor), ...(typeof row.unit === "string" ? { unit: row.unit } : {}), eventPath: String(row.event_path), diff --git a/apps/workspace-backend/src/index.ts b/apps/workspace-backend/src/index.ts new file mode 100644 index 0000000..e15f734 --- /dev/null +++ b/apps/workspace-backend/src/index.ts @@ -0,0 +1,355 @@ +#!/usr/bin/env bun +import { + CodexAppServerClient, + CodexStdioTransport, +} from "@peezy.tech/codex-flows"; +import { + CodexWorkspaceBackendProtocolServer, + type WorkspaceBackendMethodHandler, + type CodexWorkspaceBackendPeer, +} from "@peezy.tech/codex-flows/workspace-backend"; + +import path from "node:path"; +import { parseArgs, type WorkspaceBackendCliArgs } from "./args.ts"; +import { dispatchFlowEvent, readFlowEvent, replayFlowEvent } from "./flow/backend.ts"; +import { + helpText as flowHelpText, + parseCli as parseFlowCli, + readConfig as readFlowConfig, + type FlowBackendCli, + type FlowBackendConfig, + type FlowBackendExecutor, +} from "./flow/config.ts"; +import { WorkspaceFlowCapability } from "./flow/server.ts"; +import { FlowBackendStore, type FlowRunStatus } from "./flow/store.ts"; + +const defaultAppServerUrl = "ws://127.0.0.1:3585"; + +async function main(): Promise { + const argv = Bun.argv.slice(2); + if (isFlowCommand(argv[0])) { + await runFlowCli(parseFlowCli(argv, process.env)); + return; + } + if (argv[0] === "flow") { + await runFlowCli(parseFlowCli(argv.slice(1), process.env)); + return; + } + + const parsed = parseArgs(argv, process.env); + if (parsed.type === "help") { + process.stdout.write(parsed.text); + return; + } + + const client = createAppServerClient(parsed); + client.on("stderr", (line) => process.stderr.write(`${line}\n`)); + await client.connect(); + + const flow = new WorkspaceFlowCapability({ + config: workspaceFlowConfig(parsed), + env: process.env, + }); + const workspaceBackend = new CodexWorkspaceBackendProtocolServer({ + appServer: client, + serverName: "codex-workspace-backend-local", + serverVersion: "0.1.0", + flowInspection: true, + methods: flowMethodHandlers(flow), + }); + const peers = new WeakMap, CodexWorkspaceBackendPeer>(); + const server = Bun.serve({ + hostname: parsed.hostname, + port: parsed.port, + async fetch(request, bunServer) { + if (bunServer.upgrade(request)) { + return undefined; + } + const flowResponse = await flow.handleHttp(request); + if (flowResponse) { + return flowResponse; + } + return new Response("Codex workspace backend WebSocket server\n", { + status: 426, + headers: { "content-type": "text/plain; charset=utf-8" }, + }); + }, + websocket: { + open(socket) { + const peer: CodexWorkspaceBackendPeer = { + send: (message) => socket.send(message), + }; + peers.set(socket, peer); + workspaceBackend.addPeer(peer); + }, + message(socket, message) { + const peer = peers.get(socket); + if (!peer) { + return; + } + void workspaceBackend.handleMessage(peer, websocketMessageToString(message)) + .catch((error: unknown) => { + workspaceBackend.sendWorkspaceBackendEvent(peer, { + type: "appServer.error", + at: new Date().toISOString(), + message: errorMessage(error), + }); + }); + }, + close(socket) { + const peer = peers.get(socket); + if (peer) { + workspaceBackend.removePeer(peer); + peers.delete(socket); + } + }, + }, + }); + + process.stdout.write( + `codex-workspace-backend-local listening on ws://${server.hostname}:${server.port}\n`, + ); + process.stdout.write( + `codex-workspace-backend-local app-server ${ + parsed.localAppServer + ? "local stdio" + : parsed.appServerUrl ?? + process.env.CODEX_WORKSPACE_APP_SERVER_WS_URL ?? + defaultAppServerUrl + }\n`, + ); + + await waitForShutdown(server, client, flow); +} + +function createAppServerClient( + args: Extract, +): CodexAppServerClient { + const appServerUrl = + args.appServerUrl ?? + process.env.CODEX_WORKSPACE_APP_SERVER_WS_URL ?? + defaultAppServerUrl; + return new CodexAppServerClient({ + transport: args.localAppServer + ? new CodexStdioTransport({ + args: localAppServerArgs(), + requestTimeoutMs: 90_000, + }) + : undefined, + webSocketTransportOptions: args.localAppServer + ? undefined + : { url: appServerUrl, requestTimeoutMs: 90_000 }, + clientName: "codex-workspace-backend-local", + clientTitle: "Codex Workspace Backend Local", + clientVersion: "0.1.0", + }); +} + +function workspaceFlowConfig( + args: Extract, +): FlowBackendConfig { + return readFlowConfig(process.env, { + host: args.hostname, + port: args.port, + ...(args.cwd ? { cwd: args.cwd } : {}), + ...(args.dataDir ? { dataDir: args.dataDir } : {}), + ...(args.secret ? { secret: args.secret } : {}), + ...(args.executor ? { executor: requireFlowExecutor(args.executor) } : {}), + ...(args.bunCommand ? { bunCommand: args.bunCommand } : {}), + ...(args.flowRunnerPath ? { flowRunnerPath: args.flowRunnerPath } : {}), + }); +} + +function flowMethodHandlers( + flow: WorkspaceFlowCapability, +): Record { + return { + "flow.dispatch": async (params) => await flow.dispatch(record(params).event ?? params), + "flow.replay": async (params) => { + const input = record(params); + return await flow.replay(requiredString(input.eventId, "eventId"), { + wait: Boolean(input.wait), + }); + }, + "flow.listEvents": (params) => flow.listEvents({ + type: stringValue(record(params).type), + limit: positiveIntegerValue(record(params).limit), + }), + "flow.getEvent": (params) => + flow.getEvent(requiredString(record(params).eventId, "eventId")), + "flow.listRuns": (params) => flow.listRuns({ + eventId: stringValue(record(params).eventId), + status: stringValue(record(params).status), + limit: positiveIntegerValue(record(params).limit), + }), + "flow.getRun": (params) => + flow.getRun(requiredString(record(params).runId, "runId")), + }; +} + +function localAppServerArgs(): string[] { + return [ + "app-server", + "--listen", + "stdio://", + "--enable", + "apps", + "--enable", + "hooks", + ]; +} + +function websocketMessageToString(message: string | Buffer): string { + return typeof message === "string" ? message : message.toString("utf8"); +} + +function waitForShutdown( + server: Bun.Server, + client: CodexAppServerClient, + flow: WorkspaceFlowCapability, +): Promise { + return new Promise((resolve) => { + const shutdown = () => { + process.off("SIGINT", shutdown); + process.off("SIGTERM", shutdown); + server.stop(true); + client.close(); + flow.close(); + resolve(); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + }); +} + +async function runFlowCli(cli: FlowBackendCli): Promise { + if (cli.kind === "help") { + process.stdout.write(flowHelpText()); + return; + } + const store = new FlowBackendStore(path.join(cli.config.dataDir, "flow-backend.sqlite")); + try { + if (cli.kind === "serve") { + throw new Error("Use `codex-workspace-backend-local serve` for the networked workspace backend."); + } + if (cli.kind === "dispatch") { + const event = await readFlowEvent(cli.eventPath); + writeJson(await dispatchFlowEvent({ + config: cli.config, + store, + event, + wait: cli.wait, + env: process.env, + })); + return; + } + if (cli.kind === "list-events") { + writeJson({ events: store.listEvents({ type: cli.type, limit: cli.limit }) }); + return; + } + if (cli.kind === "show-event") { + const event = store.getEvent(cli.eventId); + if (!event) { + throw new Error(`Unknown event: ${cli.eventId}`); + } + writeJson({ event, runs: store.listRunsByEvent(cli.eventId) }); + return; + } + if (cli.kind === "replay-event") { + writeJson(await replayFlowEvent({ + config: cli.config, + store, + eventId: cli.eventId, + wait: cli.wait, + env: process.env, + })); + return; + } + if (cli.kind === "list-runs") { + writeJson({ + runs: store.listRuns({ + eventId: cli.eventId, + status: cli.status ? requireRunStatus(cli.status) : undefined, + limit: cli.limit, + }), + }); + return; + } + if (cli.kind === "show-run") { + const run = store.getRun(cli.runId); + if (!run) { + throw new Error(`Unknown run: ${cli.runId}`); + } + writeJson({ run }); + } + } finally { + store.close(); + } +} + +function isFlowCommand(command: string | undefined): boolean { + return command === "dispatch" || + command === "list-events" || + command === "events" || + command === "show-event" || + command === "event" || + command === "replay-event" || + command === "replay" || + command === "list-runs" || + command === "runs" || + command === "show-run" || + command === "run"; +} + +function writeJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function requireRunStatus(value: string): FlowRunStatus { + if (value === "queued" || value === "running" || value === "completed" || value === "failed") { + return value; + } + throw new Error("run status must be queued, running, completed, or failed"); +} + +function requireFlowExecutor(value: string): FlowBackendExecutor { + if (value === "direct" || value === "systemd-run") { + return value; + } + throw new Error("executor must be direct or systemd-run"); +} + +function requiredString(value: unknown, name: string): string { + const result = stringValue(value); + if (!result) { + throw new Error(`Missing required argument: ${name}`); + } + return result; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function positiveIntegerValue(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.trunc(value); + } + if (typeof value !== "string" || !value.trim()) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +await main(); diff --git a/apps/gateway/test/args.test.ts b/apps/workspace-backend/test/args.test.ts similarity index 78% rename from apps/gateway/test/args.test.ts rename to apps/workspace-backend/test/args.test.ts index 36dbf2d..b9f4d6b 100644 --- a/apps/gateway/test/args.test.ts +++ b/apps/workspace-backend/test/args.test.ts @@ -3,8 +3,8 @@ import { describe, expect, test } from "bun:test"; import { parseArgs } from "../src/args.ts"; describe("parseArgs", () => { - test("defaults to serving the local gateway port", () => { - expect(parseArgs([], {})).toEqual({ + test("defaults to serving the local workspace backend port", () => { + expect(parseArgs([], {})).toMatchObject({ type: "serve", hostname: "127.0.0.1", port: 3586, @@ -23,7 +23,7 @@ describe("parseArgs", () => { "--app-server-url", "ws://127.0.0.1:3585", ], {}), - ).toEqual({ + ).toMatchObject({ type: "serve", hostname: "0.0.0.0", port: 4599, @@ -46,11 +46,11 @@ describe("parseArgs", () => { test("reads environment overrides", () => { expect( parseArgs([], { - CODEX_GATEWAY_HOST: "0.0.0.0", - CODEX_GATEWAY_PORT: "4599", - CODEX_GATEWAY_LOCAL_APP_SERVER: "yes", + CODEX_WORKSPACE_BACKEND_HOST: "0.0.0.0", + CODEX_WORKSPACE_BACKEND_PORT: "4599", + CODEX_WORKSPACE_BACKEND_LOCAL_APP_SERVER: "yes", }), - ).toEqual({ + ).toMatchObject({ type: "serve", hostname: "0.0.0.0", port: 4599, diff --git a/apps/flow-backend-systemd-local/test/backend.test.ts b/apps/workspace-backend/test/flow-backend.test.ts similarity index 95% rename from apps/flow-backend-systemd-local/test/backend.test.ts rename to apps/workspace-backend/test/flow-backend.test.ts index fb85d63..63689ae 100644 --- a/apps/flow-backend-systemd-local/test/backend.test.ts +++ b/apps/workspace-backend/test/flow-backend.test.ts @@ -2,11 +2,11 @@ import { expect, test } from "bun:test"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { dispatchFlowEvent, replayFlowEvent } from "../src/backend.ts"; -import { parseCli, readConfig } from "../src/config.ts"; -import { flowCommand } from "../src/executor.ts"; -import { requestSignature, signBody, verifyBodySignature } from "../src/signature.ts"; -import { FlowBackendStore } from "../src/store.ts"; +import { dispatchFlowEvent, replayFlowEvent } from "../src/flow/backend.ts"; +import { parseCli, readConfig } from "../src/flow/config.ts"; +import { flowCommand } from "../src/flow/executor.ts"; +import { requestSignature, signBody, verifyBodySignature } from "../src/flow/signature.ts"; +import { FlowBackendStore } from "../src/flow/store.ts"; test("signs and verifies dispatch bodies", () => { const body = JSON.stringify({ id: "event-1" }); diff --git a/apps/workspace-backend/test/integration.test.ts b/apps/workspace-backend/test/integration.test.ts new file mode 100644 index 0000000..6d00604 --- /dev/null +++ b/apps/workspace-backend/test/integration.test.ts @@ -0,0 +1,191 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + CodexWorkspaceBackendProtocolServer, + type CodexWorkspaceBackendAppServer, + type CodexWorkspaceBackendPeer, +} from "@peezy.tech/codex-flows/workspace-backend"; +import { CodexEventEmitter } from "../../../packages/codex-client/src/app-server/events.ts"; +import { readConfig } from "../src/flow/config.ts"; +import { WorkspaceFlowCapability } from "../src/flow/server.ts"; + +test("networked local workspace backend serves control WebSocket and flow HTTP routes", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "workspace-backend-")); + const appServer = new FakeAppServer(); + const flow = new WorkspaceFlowCapability({ + config: readConfig({}, { + cwd: directory, + dataDir: path.join(directory, ".codex", "flow-backend"), + host: "127.0.0.1", + port: 0, + executor: "direct", + bunCommand: process.execPath, + }), + env: {}, + }); + const workspaceBackend = new CodexWorkspaceBackendProtocolServer({ + appServer, + flowInspection: true, + methods: { + "flow.listEvents": () => flow.listEvents(), + }, + }); + const peers = new WeakMap, CodexWorkspaceBackendPeer>(); + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request, bunServer) { + if (bunServer.upgrade(request)) { + return undefined; + } + return await flow.handleHttp(request) ?? + new Response("workspace backend", { status: 426 }); + }, + websocket: { + open(socket) { + const peer: CodexWorkspaceBackendPeer = { + send: (message) => socket.send(message), + }; + peers.set(socket, peer); + workspaceBackend.addPeer(peer); + }, + message(socket, message) { + const peer = peers.get(socket); + if (!peer) { + return; + } + void workspaceBackend.handleMessage(peer, websocketMessageToString(message)); + }, + close(socket) { + const peer = peers.get(socket); + if (peer) { + workspaceBackend.removePeer(peer); + peers.delete(socket); + } + }, + }, + }); + + try { + const health = await fetch(`http://127.0.0.1:${server.port}/healthz`); + expect(health.status).toBe(200); + expect(await health.json()).toEqual({ ok: true }); + + const responses = await websocketRequests( + `ws://127.0.0.1:${server.port}/__codex-workspace-backend`, + [ + { + jsonrpc: "2.0", + id: "initialize", + method: "workspace.initialize", + params: { + clientInfo: { name: "test", title: "Test", version: "0.1.0" }, + capabilities: { appServerPassThrough: true }, + }, + }, + { + jsonrpc: "2.0", + id: "threads", + method: "appServer.call", + params: { method: "thread/list", params: { limit: 1 } }, + }, + { + jsonrpc: "2.0", + id: "events", + method: "flow.listEvents", + params: {}, + }, + ], + ); + + expect(responses.get("initialize")?.result).toMatchObject({ + ok: true, + capabilities: { + appServerPassThrough: true, + flowInspection: true, + workspaceMethods: ["flow.listEvents"], + }, + }); + expect(responses.get("threads")?.result).toEqual({ threads: [] }); + expect(appServer.requests).toEqual([ + { method: "thread/list", params: { limit: 1 } }, + ]); + expect(responses.get("events")?.result).toEqual({ events: [] }); + } finally { + server.stop(true); + flow.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +class FakeAppServer extends CodexEventEmitter implements CodexWorkspaceBackendAppServer { + requests: Array<{ method: string; params?: unknown }> = []; + + async request(method: string, params?: unknown): Promise { + this.requests.push({ method, params }); + return { threads: [] } as T; + } + + notify(): void {} + + respond(): void {} + + respondError(): void {} +} + +type RpcResponse = { + id: string | number; + result?: unknown; + error?: { code: number; message: string }; +}; + +function websocketRequests( + url: string, + requests: Array>, +): Promise> { + return new Promise((resolve, reject) => { + const responses = new Map(); + const socket = new WebSocket(url); + const timeout = setTimeout(() => { + socket.close(); + reject(new Error("Timed out waiting for WebSocket responses.")); + }, 2000); + socket.addEventListener("open", () => { + for (const request of requests) { + socket.send(JSON.stringify(request)); + } + }); + socket.addEventListener("message", (event) => { + const parsed = JSON.parse(String(event.data)) as unknown; + if (!isRpcResponse(parsed)) { + return; + } + responses.set(parsed.id, parsed); + if (responses.size === requests.length) { + clearTimeout(timeout); + socket.close(); + resolve(responses); + } + }); + socket.addEventListener("error", () => { + clearTimeout(timeout); + reject(new Error("WebSocket request failed.")); + }); + }); +} + +function websocketMessageToString(message: string | Buffer): string { + return typeof message === "string" ? message : message.toString("utf8"); +} + +function isRpcResponse(value: unknown): value is RpcResponse { + return Boolean( + value && + typeof value === "object" && + "id" in value && + (typeof (value as { id: unknown }).id === "string" || + typeof (value as { id: unknown }).id === "number"), + ); +} diff --git a/apps/gateway/tsconfig.json b/apps/workspace-backend/tsconfig.json similarity index 69% rename from apps/gateway/tsconfig.json rename to apps/workspace-backend/tsconfig.json index fd371bf..203d077 100644 --- a/apps/gateway/tsconfig.json +++ b/apps/workspace-backend/tsconfig.json @@ -18,7 +18,9 @@ "baseUrl": ".", "paths": { "@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"], - "@peezy.tech/codex-flows/gateway": ["../../packages/codex-client/src/gateway/index.ts"] + "@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"], + "@peezy.tech/flow-runtime": ["../../packages/flow-runtime/src/index.ts"], + "@peezy.tech/flow-runtime/*": ["../../packages/flow-runtime/src/*"] } }, "include": ["src", "test"] diff --git a/bun.lock b/bun.lock index 5f2a10c..952637d 100644 --- a/bun.lock +++ b/bun.lock @@ -37,21 +37,6 @@ "typescript": "catalog:", }, }, - "apps/flow-backend-systemd-local": { - "name": "codex-flow-systemd-local", - "version": "0.1.0", - "bin": { - "codex-flow-systemd-local": "./src/index.ts", - }, - "dependencies": { - "@peezy.tech/flow-runtime": "workspace:*", - }, - "devDependencies": { - "@types/bun": "catalog:", - "@types/node": "catalog:", - "typescript": "catalog:", - }, - }, "apps/flow-runner": { "name": "codex-flow-runner", "version": "0.1.0", @@ -67,21 +52,6 @@ "typescript": "catalog:", }, }, - "apps/gateway": { - "name": "codex-gateway-local", - "version": "0.1.0", - "bin": { - "codex-gateway-local": "./src/index.ts", - }, - "dependencies": { - "@peezy.tech/codex-flows": "workspace:*", - }, - "devDependencies": { - "@types/bun": "catalog:", - "@types/node": "catalog:", - "typescript": "catalog:", - }, - }, "apps/web": { "name": "web", "version": "0.0.1", @@ -102,6 +72,22 @@ "vite": "catalog:", }, }, + "apps/workspace-backend": { + "name": "codex-workspace-backend-local", + "version": "0.1.0", + "bin": { + "codex-workspace-backend-local": "./src/index.ts", + }, + "dependencies": { + "@peezy.tech/codex-flows": "workspace:*", + "@peezy.tech/flow-runtime": "workspace:*", + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:", + }, + }, "docs": { "name": "@peezy.tech/codex-flow-docs", "version": "0.1.0", @@ -735,9 +721,7 @@ "codex-flow-runner": ["codex-flow-runner@workspace:apps/flow-runner"], - "codex-flow-systemd-local": ["codex-flow-systemd-local@workspace:apps/flow-backend-systemd-local"], - - "codex-gateway-local": ["codex-gateway-local@workspace:apps/gateway"], + "codex-workspace-backend-local": ["codex-workspace-backend-local@workspace:apps/workspace-backend"], "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], diff --git a/docs/pages/concepts/architecture.md b/docs/pages/concepts/architecture.md index 1fe445a..2ff203d 100644 --- a/docs/pages/concepts/architecture.md +++ b/docs/pages/concepts/architecture.md @@ -27,7 +27,7 @@ The same flow package can run: - directly through `codex-flow-runner` - synchronously through `@peezy.tech/flow-runtime/local-client` -- through `codex-flow-systemd-local` +- through the workspace backend's local flow capability - through a Convex control plane plus an external worker - through any app-owned backend adapter that preserves the event/result ABI diff --git a/docs/pages/concepts/backends.md b/docs/pages/concepts/backends.md index 7404e3d..e528b59 100644 --- a/docs/pages/concepts/backends.md +++ b/docs/pages/concepts/backends.md @@ -1,6 +1,6 @@ --- title: Backends -description: Compare local memory, file state, systemd-local, HTTP adapters, and Convex. +description: Compare local memory, file state, workspace flow backends, HTTP adapters, and Convex. --- # Backends @@ -15,11 +15,12 @@ event ids, list/get, and replay across client instances. Use it for product CLIs, tests, and local development. -## systemd-local +## Workspace flow capability -The systemd-local backend accepts HTTP dispatches, stores events and runs in +The workspace flow capability accepts dispatches, stores events and runs in SQLite, writes event JSON files, and starts local steps directly or through -`systemd-run`. +`systemd-run`. Embedded workspace backends can call it directly; the networked +local workspace backend also mounts compatible HTTP routes. Use it for a small host-level service where local system tools and Codex are available. diff --git a/docs/pages/concepts/domain-boundaries.md b/docs/pages/concepts/domain-boundaries.md index b83e5ac..31d7305 100644 --- a/docs/pages/concepts/domain-boundaries.md +++ b/docs/pages/concepts/domain-boundaries.md @@ -24,7 +24,7 @@ It does not own product-specific completion: - payment state - minting - Discord write tools -- gateway backends and arbitrary app-server thread wrappers +- workspace backend presenters and arbitrary app-server thread wrappers Keep domain completion in the consuming app. For example, a pet-game worker can generate an asset through a flow step, upload the asset, update payment state, @@ -33,7 +33,7 @@ mint if needed, and only then complete the generic run. This boundary keeps flow packages portable and prevents generic backends from depending on app-specific Convex schemas, credentials, or release policy. -The Discord gateway backend follows the same rule. It may inspect generic flow +The Discord workspace backend follows the same rule. It may inspect generic flow runs and events for an operator, but its app-server thread orchestration, delegation policy, Discord workbench state, and hook-spool wake behavior are not part of the generic codex-flow backend contract. diff --git a/docs/pages/concepts/gateway-backend-process.md b/docs/pages/concepts/gateway-backend-process.md deleted file mode 100644 index 89f3485..0000000 --- a/docs/pages/concepts/gateway-backend-process.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: Gateway backend process -description: Process boundaries for local and future remote Codex gateway backends. ---- - -# Gateway backend process - -The current Discord gateway backend runs in-process with the Discord transport. -That keeps deployment simple while preserving the backend boundary in code: - -- `DiscordCodexBridge` owns Discord startup, shutdown, command registration, and - inbound dispatch. -- `LocalCodexGatewayBackend` owns app-server connection, Codex thread lifecycle, - turn routing, goals, delegations, workbench state, hook-spool draining, and - persisted gateway state. -- `CodexGatewayPresenter` is the only outbound UI surface the local backend - receives. It can create posts or threads, send and update messages, pin status, - type, and delete presentation artifacts. - -## In-process local backend - -Local mode is the first implementation. The Discord process constructs a local -backend with: - -- a Codex app-server client -- a state store -- bridge configuration -- a presenter adapter backed by the Discord transport -- an optional flow backend client for read-only run and event inspection - -The backend may connect to an existing app-server WebSocket or to a local stdio -app-server started by the CLI. - -## Browser gateway process - -The browser UI talks to the standalone local gateway server instead of talking -directly to the Codex app-server. In development, Vite proxies -`/__codex-gateway` to `codex-gateway-local` on port `3586`. - -The browser gateway protocol has two lanes: - -| Lane | Methods | Owner | -|------|---------|-------| -| app-server pass-through | `appServer.call`, `appServer.notify`, `appServer.respond`, `appServer.respondError` | Codex app-server | -| gateway-owned | `gateway.*` methods and `gateway.event` notifications | Codex gateway backend | - -Native app-server methods stay native. For example, `thread/list`, -`thread/read`, `thread/start`, `turn/start`, `turn/interrupt`, -`account/read`, and app-server-native goal APIs are wrapped in -`appServer.call` and forwarded to the app-server. The gateway may observe, -route, and correlate those calls, but it should not duplicate their semantics. - -Gateway-owned methods are for orchestration that the app-server does not own: -delegations, return modes, group wakes, workbench/workspace routing, -hook-spool observed-thread wake behavior, persisted gateway/session state, and -optional read-only flow backend inspection. - -## Remote backend - -A remote backend can implement the same `CodexGatewayBackend` shape behind HTTP -or WebSocket. The transport-facing protocol should stay small: - -| Direction | Shape | Purpose | -|-----------|-------|---------| -| transport to backend | transport-specific inbound events or browser gateway JSON-RPC | lifecycle, commands, and event delivery | -| backend to transport | `CodexGatewayPresenter` operations or `gateway.event` notifications | UI output and presentation updates | -| backend to app-server | Codex app-server client calls | app-server-native thread, turn, auth, goal, and tool behavior | -| backend to flow backend | `@peezy.tech/flow-runtime` backend client calls | optional read-only inspection | - -Discord inbound events are still transport-shaped. The browser gateway protocol -is the first transport-neutral client lane; future surfaces should share that -shape where practical and add a presenter adapter only for UI output. - -## Flow backend boundary - -This gateway backend is not a codex-flow backend. It may inspect flow runs and -events, but it must not own `FlowEvent`, `flow.toml`, `FLOW_RESULT`, matching, -or step execution. diff --git a/docs/pages/concepts/gateway-backends.md b/docs/pages/concepts/gateway-backends.md deleted file mode 100644 index 10fc235..0000000 --- a/docs/pages/concepts/gateway-backends.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: Gateway backends -description: How Codex gateway surfaces differ from generic flow backends. ---- - -# Gateway backends - -A Codex gateway backend is the runtime behind an operator surface such as -Discord or the browser UI. It owns Codex app-server orchestration and exposes a -small UI-facing contract to the transport. - -The Discord bridge is the first transport using this split: - -- Discord owns bot login, commands, interactions, Discord channels, and message - delivery. -- The gateway backend owns app-server connection, delegations, workbench state, - hook-spool draining, persisted bridge/session state, and optional flow-run - inspection. -- The local backend is the first implementation. It can connect to an existing - app-server WebSocket or start a local stdio app-server. - -The browser UI uses the same split. Its gateway client sends native app-server -methods through `appServer.call`, `appServer.notify`, `appServer.respond`, and -`appServer.respondError`. The gateway forwards those calls instead of -reimplementing app-server behavior for thread list/read, thread start/resume, -turn start/steer/interrupt, auth, account state, and app-server-native goal -APIs. - -Gateway-owned commands are reserved for behavior that combines app-server state -with gateway policy or gateway state: delegation, return modes, group wakes, -workbench/workspace routing, hook-spool observed-thread wake behavior, persisted -gateway sessions, and read-only flow backend inspection. - -This is separate from codex-flow backends. A flow backend accepts `FlowEvent`, -matches `flow.toml`, executes steps, records `FLOW_RESULT`, and exposes run and -event views. A gateway backend may read those run and event views, but it does -not redefine the flow ABI and should not become the generic flow executor. - -Use a gateway backend when the product needs a long-lived Codex control surface. -Use a flow backend when the product needs portable event-driven automation. diff --git a/docs/pages/concepts/workspace-backend-deployments.md b/docs/pages/concepts/workspace-backend-deployments.md new file mode 100644 index 0000000..3da8ed8 --- /dev/null +++ b/docs/pages/concepts/workspace-backend-deployments.md @@ -0,0 +1,63 @@ +--- +title: Workspace backend deployments +description: Embedded, networked local, and future remote workspace backend shapes. +--- + +# Workspace backend deployments + +The workspace backend is a capability model first. It can run embedded with a +presenter, as a local networked process, or behind a future remote transport. + +## Embedded local + +Embedded local mode has no browser-facing HTTP surface and no public socket. A +presenter or transport such as Discord constructs the workspace backend +in-process with: + +- an app-server adapter, often a locally spawned `codex app-server` over stdio +- a state store +- workspace configuration +- presenter callbacks for output and UI artifacts +- direct access to delegation, workbench, and flow capabilities + +Discord uses this shape today. The Discord wrapper owns bot startup, shutdown, +command registration, and inbound Discord events. The local workspace backend +owns Codex app-server lifecycle, thread routing, goals, delegation, workbench +state, hook-spool draining, flow capability access, and persisted workspace +state. + +## Networked local + +Networked local mode runs `codex-workspace-backend-local` as one process. It can +connect to an existing app-server or spawn a local stdio app-server, and it can +mount browser/control WebSocket plus flow HTTP surfaces. + +In development, the browser can connect through Vite's +`/__codex-workspace-backend` proxy to the local backend on port `3586`. + +The browser protocol has two lanes: + +| Lane | Methods | Owner | +|------|---------|-------| +| app-server pass-through | `appServer.call`, `appServer.notify`, `appServer.respond`, `appServer.respondError` | Codex app-server adapter | +| workspace-owned | `workspace.*`, `delegation.*`, `flow.*`, and `workspace.event` | Codex workspace backend | + +The networked local process also mounts the stable flow HTTP routes such as +`/events`, `/events/:id/replay`, `/runs`, and `/healthz`. Those routes are an +optional transport surface over the same built-in flow capability. + +## Future remote + +A remote workspace backend should expose the same logical capabilities behind a +remote transport. The transport-facing contract should stay small: + +| Direction | Shape | Purpose | +|-----------|-------|---------| +| presenter to backend | transport-specific inbound events or workspace JSON-RPC | lifecycle, commands, and event delivery | +| backend to presenter | presenter operations or `workspace.event` notifications | UI output and presentation updates | +| backend to app-server | app-server adapter calls | app-server-native thread, turn, auth, goal, and tool behavior | +| backend to flow capability | direct calls or mounted HTTP routes | flow dispatch, inspection, and replay | + +The backend boundary should not redefine app-server or flow semantics. It owns +workspace orchestration and policy; app-server and flow capabilities keep their +native contracts. diff --git a/docs/pages/concepts/workspace-backends.md b/docs/pages/concepts/workspace-backends.md new file mode 100644 index 0000000..f91fe0e --- /dev/null +++ b/docs/pages/concepts/workspace-backends.md @@ -0,0 +1,40 @@ +--- +title: Workspace backends +description: Logical Codex workspace capabilities across embedded, local, and remote deployments. +--- + +# Workspace backends + +A Codex workspace backend is the logical runtime behind an operator surface such +as Discord, the browser UI, or a CLI. It is not necessarily a network server. +The backend owns shared workspace capabilities and exposes them through the +transport shape that fits the deployment. + +The built-in capability families are: + +- app-server pass-through for native Codex app-server JSON-RPC methods +- delegation lifecycle, return modes, result flushing, and group wakes +- flow execution and inspection over the generic `FlowEvent`/`FLOW_RESULT` ABI +- workbench state, observed threads, hook-spool returns, and presentation routing + +Native app-server methods stay app-server-native. Calls such as `thread/list`, +`thread/read`, `thread/start`, `turn/start`, `turn/interrupt`, `account/read`, +and app-server-native goal APIs are forwarded through the app-server adapter the +workspace backend owns. That adapter might be stdio, a Unix socket, a local +WebSocket, or a future remote transport. + +Workspace-owned methods are reserved for behavior that combines app-server +state with workspace policy or workspace state: delegations, return modes, group +wakes, workbench routing, hook-spool observed-thread wake behavior, persisted +workspace sessions, and flow inspection or dispatch. + +Flow execution is now a workspace backend capability. In an embedded backend, +tools and presenters can call the flow capability directly. In a networked local +backend, the same capability is also mounted as the existing HTTP routes for +dispatch, inspection, and replay. The generic flow ABI remains unchanged: +products dispatch `FlowEvent`, flow packages match `flow.toml`, steps emit +`FLOW_RESULT`, and app-specific completion stays in the consuming app. + +Use a workspace backend when the product needs a long-lived Codex control +surface. Use flow packages when the product needs portable event-driven +automation. diff --git a/docs/pages/guides/operate-systemd-local.md b/docs/pages/guides/operate-workspace-flow-backend.md similarity index 58% rename from docs/pages/guides/operate-systemd-local.md rename to docs/pages/guides/operate-workspace-flow-backend.md index 0d9a4e6..6343c24 100644 --- a/docs/pages/guides/operate-systemd-local.md +++ b/docs/pages/guides/operate-workspace-flow-backend.md @@ -1,26 +1,30 @@ --- -title: Operate systemd-local -description: Run the local HTTP backend, inspect stored events, and replay failed runs. +title: Operate the workspace flow backend +description: Run the local workspace backend flow capability and inspect stored events. --- -# Operate systemd-local +# Operate the workspace flow backend -`codex-flow-systemd-local` is the local durable HTTP backend. Patch and other -services can post generic `FlowEvent` JSON to it while operators inspect and -replay runs later. +The local workspace backend includes the durable flow capability. It can accept +generic `FlowEvent` JSON over the networked HTTP surface, persist events and +runs, start matching steps locally, and replay stored events. + +In embedded local mode, tools can call the same flow capability directly without +HTTP. The routes below are the optional networked surface mounted by +`codex-workspace-backend-local`. ## Start the backend ```bash -bun run flow:backend serve --cwd /home/peezy/codex-flows-public +bun run workspace:backend --cwd /home/peezy/codex-flows-public ``` Useful environment: ```bash -CODEX_FLOW_BACKEND_HOST=127.0.0.1 -CODEX_FLOW_BACKEND_PORT=7345 -CODEX_FLOW_BACKEND_DATA_DIR=/var/lib/codex-flow-systemd-local +CODEX_WORKSPACE_BACKEND_HOST=127.0.0.1 +CODEX_WORKSPACE_BACKEND_PORT=3586 +CODEX_FLOW_BACKEND_DATA_DIR=/var/lib/codex-workspace-flow CODEX_FLOW_BACKEND_SECRET=shared-hmac-secret CODEX_FLOW_BACKEND_EXECUTOR=direct ``` @@ -32,7 +36,7 @@ suitable when the backend service itself is already managed by systemd. ## Dispatch ```bash -curl -X POST http://127.0.0.1:7345/events \ +curl -X POST http://127.0.0.1:3586/events \ -H 'content-type: application/json' \ --data @event.json ``` @@ -55,7 +59,7 @@ run attempt. ## Back up state -The backend stores SQLite state under `CODEX_FLOW_BACKEND_DATA_DIR` and per +The capability stores SQLite state under `CODEX_FLOW_BACKEND_DATA_DIR` and per event JSON files under `CODEX_FLOW_BACKEND_DATA_DIR/events`. Back up the whole data directory while the service is stopped, or use SQLite online backup plus a copy of the `events/` directory. diff --git a/docs/pages/guides/run-discord-local-backend.md b/docs/pages/guides/run-discord-local-backend.md index 88ef5ae..d4ee4bb 100644 --- a/docs/pages/guides/run-discord-local-backend.md +++ b/docs/pages/guides/run-discord-local-backend.md @@ -1,12 +1,13 @@ --- title: Run Discord over a local backend -description: Start the Discord bridge with the in-process local Codex gateway backend. +description: Start the Discord bridge with the embedded local Codex workspace backend. --- # Run Discord over a local backend -Use the local gateway backend when Discord should be the operator surface for a -Codex workspace on the same host. +Use the embedded local workspace backend when Discord should be the operator +surface for a Codex workspace on the same host. This mode does not require a +browser-facing HTTP surface; the bridge constructs the backend in-process. ## 1. Configure Discord access @@ -18,7 +19,7 @@ export CODEX_DISCORD_ALLOWED_USER_IDS="123456789" export CODEX_DISCORD_ALLOWED_CHANNEL_IDS="987654321" ``` -To enable gateway mode, set a home channel: +To enable workspace mode, set a home channel: ```bash export CODEX_DISCORD_HOME_CHANNEL_ID="987654321" @@ -41,11 +42,12 @@ bun ./apps/discord-bridge/src/index.ts \ --sandbox danger-full-access ``` -Both modes use the same in-process `LocalCodexGatewayBackend`. +Both modes use the same in-process `LocalCodexWorkspaceBackend`. ## 3. Add optional workbench channels -Workbench channels let the gateway keep workspace dashboards and task threads: +Workbench channels let the workspace backend keep workspace dashboards and task +threads: ```bash export CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID="111111111" @@ -56,7 +58,7 @@ Set both values together. A partial workbench configuration is invalid. ## 4. Enable hook-spool returns -Install hooks for the runtime backing the gateway: +Install hooks for the runtime backing the workspace backend: ```bash codex-discord-bridge hook install @@ -68,12 +70,12 @@ results, and wakes the main operator thread when policy says to. ## 5. Add optional flow inspection -If a codex-flow backend is running, point the gateway at it: +If a workspace flow HTTP surface is running, point the bridge at it: ```bash -export CODEX_FLOW_BACKEND_URL="http://127.0.0.1:8787" +export CODEX_FLOW_BACKEND_URL="http://127.0.0.1:3586" ``` -This enables read-only `codex_gateway.list_flow_runs` and -`codex_gateway.list_flow_events`. It does not make the gateway backend a flow -executor. +This enables read-only `codex_workspace.list_flow_runs` and +`codex_workspace.list_flow_events`. Embedded workspace backends can also call a +local flow capability directly without HTTP. diff --git a/docs/pages/guides/run-web-over-local-gateway.md b/docs/pages/guides/run-web-over-local-gateway.md deleted file mode 100644 index 05d49f4..0000000 --- a/docs/pages/guides/run-web-over-local-gateway.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -title: Run web over the local gateway -description: Start the browser UI through codex-gateway-local instead of a direct app-server WebSocket. ---- - -# Run web over the local gateway - -Use the local gateway when the browser UI should share the same backend boundary -as Discord: the UI is a presenter/client, the gateway owns orchestration, and -the Codex app-server remains the source of truth for native app-server methods. - -## Start the gateway - -Connect the gateway to an existing app-server WebSocket: - -```sh -bun apps/gateway/src/index.ts serve --app-server-url ws://127.0.0.1:3585 -``` - -Or let the gateway start a local stdio app-server: - -```sh -bun apps/gateway/src/index.ts serve --local-app-server -``` - -The gateway listens on `ws://127.0.0.1:3586` by default. Override it with -`--host`, `--port`, `CODEX_GATEWAY_HOST`, or `CODEX_GATEWAY_PORT`. - -## Start the browser UI - -```sh -bun run dev:web -``` - -The Vite dev server proxies `ws:///__codex-gateway` to -`ws://127.0.0.1:3586`. Set `VITE_CODEX_GATEWAY_PROXY_TARGET` if the gateway is -on another host or port. - -For a browser that should connect directly to a gateway WebSocket instead of -using the dev proxy, set `VITE_CODEX_GATEWAY_WS_URL`. - -## Boundary - -The web client uses `CodexGatewayClient`. Native app-server operations such as -thread listing, thread reads, thread starts, turn starts, turn interrupts, auth, -and account reads are sent through `appServer.call` and forwarded by the -gateway. - -Do not reimplement app-server behavior in the gateway just to serve the web UI. -Add gateway-owned methods only for behavior that combines app-server state with -gateway state or policy, such as delegations, workbench routing, hook-spool -wakes, persisted gateway sessions, or read-only flow backend inspection. diff --git a/docs/pages/guides/run-web-over-local-workspace-backend.md b/docs/pages/guides/run-web-over-local-workspace-backend.md new file mode 100644 index 0000000..8a89e46 --- /dev/null +++ b/docs/pages/guides/run-web-over-local-workspace-backend.md @@ -0,0 +1,54 @@ +--- +title: Run web over the local workspace backend +description: Start the browser UI through codex-workspace-backend-local. +--- + +# Run web over the local workspace backend + +Use the networked local workspace backend when the browser UI should share the +same backend boundary as other presenters: the UI is a client, the workspace +backend owns orchestration, and the Codex app-server remains the source of truth +for native app-server methods. + +## Start the backend + +Connect the workspace backend to an existing app-server WebSocket: + +```sh +bun apps/workspace-backend/src/index.ts serve --app-server-url ws://127.0.0.1:3585 +``` + +Or let it start a local stdio app-server: + +```sh +bun apps/workspace-backend/src/index.ts serve --local-app-server +``` + +The backend listens on `ws://127.0.0.1:3586` by default. Override it with +`--host`, `--port`, `CODEX_WORKSPACE_BACKEND_HOST`, or +`CODEX_WORKSPACE_BACKEND_PORT`. + +## Start the browser UI + +```sh +bun run dev:web +``` + +The Vite dev server proxies `ws:///__codex-workspace-backend` to +`ws://127.0.0.1:3586`. Set `VITE_CODEX_WORKSPACE_BACKEND_PROXY_TARGET` if the +backend is on another host or port. + +For a browser that should connect directly to a workspace backend WebSocket +instead of using the dev proxy, set `VITE_CODEX_WORKSPACE_BACKEND_WS_URL`. + +## Boundary + +The web client uses `CodexWorkspaceBackendClient`. Native app-server operations +such as thread listing, thread reads, thread starts, turn starts, turn +interrupts, auth, and account reads are sent through `appServer.call` and +forwarded by the workspace backend's app-server adapter. + +Do not reimplement app-server behavior in the workspace backend just to serve +the web UI. Add workspace-owned methods only for behavior that combines +app-server state with workspace state or policy, such as delegations, workbench +routing, hook-spool wakes, persisted workspace sessions, or flow inspection. diff --git a/docs/pages/index.md b/docs/pages/index.md index 38f219e..8bed116 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -36,7 +36,7 @@ flowchart LR - New to flows: [Build your first flow](tutorials/first-flow). - Integrating a product: [Dispatch a release event](tutorials/dispatch-release-event). - Need exact shapes: [FlowEvent and FLOW_RESULT](reference/flow-event). -- Operating runs: [Operate systemd-local](guides/operate-systemd-local). +- Operating runs: [Operate the workspace flow backend](guides/operate-workspace-flow-backend). ## What is in this repo @@ -47,7 +47,7 @@ flowchart LR - `@peezy.tech/flow-backend-convex`: reusable Convex control-plane component for generic flow events and runs. - `codex-flow-runner`: CLI for discovering and firing local flow packages. -- `codex-flow-systemd-local`: local HTTP backend for durable flow dispatch, - inspection, replay, and optional `systemd-run` execution. +- `codex-workspace-backend-local`: local workspace backend process with durable + flow dispatch, inspection, replay, and optional `systemd-run` execution. - `codex-discord-bridge`: Discord sidecar for routing Discord threads to Codex - app-server threads, gateway delegation, and read-only flow inspection. + app-server threads, workspace delegation, and flow inspection. diff --git a/docs/pages/reference/backend-http.md b/docs/pages/reference/backend-http.md index 6a7c7df..a14e0e5 100644 --- a/docs/pages/reference/backend-http.md +++ b/docs/pages/reference/backend-http.md @@ -1,12 +1,13 @@ --- title: Backend HTTP API -description: Endpoints used by systemd-local and compatible flow backends. +description: Endpoints used by the workspace flow HTTP surface and compatible flow backends. --- # Backend HTTP API HTTP backends accept generic `FlowEvent` objects and expose stored event and run -state. +state. In the local workspace backend, these routes are an optional networked +surface over the built-in flow capability. | Method | Path | Purpose | |--------|------|---------| @@ -29,8 +30,8 @@ SHA-256 and send: x-flow-signature-256: sha256= ``` -`x-patch-flow-signature-256` remains accepted by systemd-local for older Patch -dispatchers. +`x-patch-flow-signature-256` remains accepted by the local workspace backend for +older Patch dispatchers. ## Compatibility diff --git a/docs/pages/reference/cli.md b/docs/pages/reference/cli.md index 377a62f..0faf71b 100644 --- a/docs/pages/reference/cli.md +++ b/docs/pages/reference/cli.md @@ -16,7 +16,7 @@ bun run flow run --event event.json `flow fire` dispatches through the local client and runs every step whose trigger type and schema match the event. -## systemd-local backend +## Workspace flow backend ```bash bun run flow:backend serve --cwd @@ -34,6 +34,6 @@ bun run flow:backend replay-event --wait | `CODEX_FLOWS_MODE=code-mode` | Enables Code Mode flow steps and Peezy Codex defaults. | | `CODEX_APP_SERVER_CODEX_COMMAND` | Overrides the Codex command for stdio app-server launches. | | `CODEX_FLOW_BACKEND_URL` | HTTP backend URL for consumers such as Discord bridge inspection. | -| `CODEX_FLOW_BACKEND_SECRET` | Shared HMAC secret for systemd-local dispatch. | +| `CODEX_FLOW_BACKEND_SECRET` | Shared HMAC secret for HTTP flow dispatch. | | `CODEX_FLOW_BACKEND_EXECUTOR` | `direct` or `systemd-run`. | | `CODEX_FLOW_BACKEND_DATA_DIR` | Durable backend state directory. | diff --git a/docs/pages/reference/discord-bridge.md b/docs/pages/reference/discord-bridge.md index c492091..ea95ea1 100644 --- a/docs/pages/reference/discord-bridge.md +++ b/docs/pages/reference/discord-bridge.md @@ -1,21 +1,21 @@ --- title: Discord bridge -description: Long-lived Discord sidecar for Codex app-server threads, gateway mode, and flow inspection. +description: Long-lived Discord sidecar for Codex app-server threads, workspace mode, and flow inspection. --- # Discord bridge `codex-discord-bridge` is a private workspace app that exposes Discord as a -transport over a Codex gateway backend. It is a user interface and operator +transport over a Codex workspace backend. It is a user interface and operator sidecar, not part of the generic flow runtime. Use it when a team wants to: - start or resume Codex work from Discord -- keep one gateway home channel as the operator surface +- keep one workspace home channel as the operator surface - delegate work into separate Codex threads - expose selected Codex thread state through Discord commands -- inspect codex-flow backend events and runs from the gateway +- inspect workspace flow backend events and runs ## Run it @@ -47,24 +47,23 @@ Common optional configuration: |----------|---------| | `CODEX_DISCORD_ALLOWED_CHANNEL_IDS` | Parent channels where the bridge may respond. | | `CODEX_DISCORD_DIR` | Root directory for Codex thread workspaces. | -| `CODEX_DISCORD_HOME_CHANNEL_ID` | Enables gateway mode for a Discord home channel. | -| `CODEX_DISCORD_MAIN_THREAD_ID` | Existing Codex operator thread to resume for gateway mode. | +| `CODEX_DISCORD_HOME_CHANNEL_ID` | Enables workspace mode for a Discord home channel. | +| `CODEX_DISCORD_MAIN_THREAD_ID` | Existing Codex operator thread to resume for workspace mode. | | `CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID` | Optional workbench forum channel. | | `CODEX_DISCORD_TASK_THREADS_CHANNEL_ID` | Optional workbench task-thread channel. | -| `CODEX_FLOW_BACKEND_URL` | Optional HTTP flow backend for read-only run/event inspection. | +| `CODEX_FLOW_BACKEND_URL` | Optional workspace flow HTTP surface for run/event inspection. | | `CODEX_DISCORD_HOOK_SPOOL_DIR` | Directory for Codex hook lifecycle events. | -## Gateway mode +## Workspace Mode -Gateway mode keeps one Discord home channel as the compact operator surface and +Workspace mode keeps one Discord home channel as the compact operator surface and one Codex main thread as the model-visible operator memory. Normal messages in the home channel go to that main thread. The main thread receives privileged -`codex_gateway` tools that can start, resume, read, and message delegated Codex +`codex_workspace` tools that can start, resume, read, and message delegated Codex sessions. -The gateway tools can also list flow events and runs when -`CODEX_FLOW_BACKEND_URL` points at a compatible backend such as -`codex-flow-systemd-local`. +The workspace tools can also list flow events and runs when +`CODEX_FLOW_BACKEND_URL` points at a compatible workspace flow HTTP surface. ## Backend contract @@ -73,12 +72,12 @@ The Discord process has two sides: - the Discord transport starts the bot, receives commands and messages, maps Discord channels and threads, registers slash commands, and sends Discord output -- the gateway backend handles inbound events, owns Codex app-server lifecycle, +- the workspace backend handles inbound events, owns Codex app-server lifecycle, starts and resumes Codex threads, manages goals, delegations, workbench state, persisted bridge state, and hook-spool wake behavior The built-in backend is local. It preserves the current behavior while giving -the bridge an explicit `CodexGatewayBackend` contract that another backend can +the bridge an explicit `CodexWorkspaceBackend` contract that another backend can implement later. The local backend only receives the outbound Discord presentation surface; transport startup, shutdown, inbound dispatch, and command registration stay in the Discord wrapper. @@ -102,7 +101,7 @@ Single-surface `.env` configuration is the default. For multi-guild routing, put one surface entry in a workspace-owned `.codex/workspace.toml`: ```toml -[[discord.gateway.surfaces]] +[[discord.workspace.surfaces]] key = "crypto" home_channel_id = "1503107617512919220" workspace_forum_channel_id = "1503107617512919221" @@ -115,7 +114,7 @@ workspace is the route. ## Codex hooks -Install Codex hooks once for the runtime backing the gateway: +Install Codex hooks once for the runtime backing the workspace backend: ```bash codex-discord-bridge hook install @@ -136,8 +135,8 @@ operator thread when configured. ## Boundary The Discord bridge may present flow backend events and runs, but it does not -own the generic flow ABI. The gateway backend can read from -`@peezy.tech/flow-runtime` backend clients for inspection, but flow packages -still communicate through `FlowEvent`, `flow.toml`, and `FLOW_RESULT`; -app-specific completion still belongs in the app that dispatched or consumed the -event. +own the generic flow ABI. The workspace backend can read from +`@peezy.tech/flow-runtime` backend clients or the built-in workspace flow +capability for inspection, but flow packages still communicate through +`FlowEvent`, `flow.toml`, and `FLOW_RESULT`; app-specific completion still +belongs in the app that dispatched or consumed the event. diff --git a/docs/pages/reference/packages.md b/docs/pages/reference/packages.md index a30c37c..6077b42 100644 --- a/docs/pages/reference/packages.md +++ b/docs/pages/reference/packages.md @@ -10,7 +10,7 @@ description: Public and workspace packages in the codex-flow stack. Low-level Codex app-server client package. It exports: - app-server JSON-RPC client and stdio/WebSocket transports -- browser-safe gateway client and gateway protocol server primitives +- browser-safe workspace backend client and protocol server primitives - browser-safe WebSocket transport - framework-agnostic app-server flow helpers - auth helpers for account login/status/usage @@ -36,10 +36,9 @@ attempts, leases, output chunks, and final result payloads. ## Workspace apps - `codex-flow-runner`: local CLI for listing, firing, and running steps. -- `codex-flow-systemd-local`: local durable HTTP backend and CLI. +- `codex-workspace-backend-local`: local workspace backend process with browser + control WebSocket and optional flow HTTP routes. - [`codex-discord-bridge`](discord-bridge): Discord-to-Codex bridge with - gateway delegation and read-only flow inspection tools. -- `codex-gateway-local`: local WebSocket gateway that forwards native - app-server calls and exposes gateway-owned events/commands. -- `web`: browser UI for Codex threads through the local gateway. + workspace delegation and flow inspection tools. +- `web`: browser UI for Codex threads through the local workspace backend. - `codex-app-cli`: JSON-RPC CLI for app-server actions. diff --git a/docs/tome.config.js b/docs/tome.config.js index 67606be..0d802a7 100644 --- a/docs/tome.config.js +++ b/docs/tome.config.js @@ -22,12 +22,12 @@ export default { "guides/author-flow-package", "guides/run-flows-locally", "guides/dispatch-and-replay-events", - "guides/operate-systemd-local", + "guides/operate-workspace-flow-backend", "guides/use-convex-backend", "guides/enable-code-mode", "guides/operate-codex-release-flows", "guides/run-discord-local-backend", - "guides/run-web-over-local-gateway", + "guides/run-web-over-local-workspace-backend", ], }, { @@ -48,8 +48,8 @@ export default { "concepts/architecture", "concepts/backends", "concepts/domain-boundaries", - "concepts/gateway-backends", - "concepts/gateway-backend-process", + "concepts/workspace-backends", + "concepts/workspace-backend-deployments", "concepts/code-mode", ], }, diff --git a/package.json b/package.json index ecceb4c..0a33fd1 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ } }, "scripts": { - "build": "bun run --filter @peezy.tech/codex-flows build && bun run --filter @peezy.tech/flow-runtime build && bun run --filter @peezy.tech/flow-backend-convex build && bun run --filter @peezy.tech/codex-opencode-go-router build && bun run --filter @workspace/ui build && bun run --filter codex-app-cli build && bun run --filter codex-discord-bridge build && bun run --filter codex-gateway-local build && bun run --filter codex-flow-systemd-local build && bun run --filter codex-flow-runner build && bun run --filter web build && bun run --filter @peezy.tech/codex-flow-docs build", + "build": "bun run --filter @peezy.tech/codex-flows build && bun run --filter @peezy.tech/flow-runtime build && bun run --filter @peezy.tech/flow-backend-convex build && bun run --filter @peezy.tech/codex-opencode-go-router build && bun run --filter @workspace/ui build && bun run --filter codex-app-cli build && bun run --filter codex-discord-bridge build && bun run --filter codex-workspace-backend-local build && bun run --filter codex-flow-runner build && bun run --filter web build && bun run --filter @peezy.tech/codex-flow-docs build", "check:types": "bun run --workspaces check:types", "codex:update": "bun scripts/run-codex-release-update-thread.ts", "dev": "bun run --filter web dev", @@ -46,12 +46,12 @@ "docs:dev": "bun run --filter @peezy.tech/codex-flow-docs dev", "dev:web": "bun run --filter web dev", "flow": "bun apps/flow-runner/src/index.ts", - "flow:backend": "bun apps/flow-backend-systemd-local/src/index.ts", - "gateway": "bun apps/gateway/src/index.ts serve", + "flow:backend": "bun apps/workspace-backend/src/index.ts", + "workspace:backend": "bun apps/workspace-backend/src/index.ts serve", "replay:thread": "bun scripts/run-code-mode-in-new-thread.ts", "start": "bun run --filter web preview", "start:discord:debug:commentary": "bun run --filter codex-discord-bridge start:debug:commentary", - "test": "bun run --filter @peezy.tech/codex-flows test && bun run --filter @peezy.tech/flow-runtime test && bun run --filter @peezy.tech/flow-backend-convex test && bun run --filter @peezy.tech/codex-opencode-go-router test && bun run --filter codex-flow-systemd-local test && bun run --filter codex-flow-runner test && bun run --filter codex-app-cli test && bun run --filter codex-discord-bridge test && bun run --filter codex-gateway-local test && bun run --filter web test", + "test": "bun run --filter @peezy.tech/codex-flows test && bun run --filter @peezy.tech/flow-runtime test && bun run --filter @peezy.tech/flow-backend-convex test && bun run --filter @peezy.tech/codex-opencode-go-router test && bun run --filter codex-workspace-backend-local test && bun run --filter codex-flow-runner test && bun run --filter codex-app-cli test && bun run --filter codex-discord-bridge test && bun run --filter web test", "release:check": "bun run --filter @peezy.tech/codex-flows release:check && bun run --filter @peezy.tech/flow-runtime release:check && bun run --filter @peezy.tech/flow-backend-convex release:check" } } diff --git a/packages/codex-client/package.json b/packages/codex-client/package.json index bad5454..820757c 100644 --- a/packages/codex-client/package.json +++ b/packages/codex-client/package.json @@ -45,9 +45,9 @@ "types": "./dist/workbench.d.ts", "import": "./dist/workbench.js" }, - "./gateway": { - "types": "./dist/gateway/index.d.ts", - "import": "./dist/gateway/index.js" + "./workspace-backend": { + "types": "./dist/workspace-backend/index.d.ts", + "import": "./dist/workspace-backend/index.js" }, "./rpc": { "types": "./dist/app-server/rpc.d.ts", diff --git a/packages/codex-client/src/browser.ts b/packages/codex-client/src/browser.ts index d9a88f9..083ff6f 100644 --- a/packages/codex-client/src/browser.ts +++ b/packages/codex-client/src/browser.ts @@ -4,11 +4,11 @@ export { type CodexBrowserAppServerTransport as CodexAppServerTransport, } from "./app-server/browser-client.ts"; export { - CodexGatewayClient, - type CodexGatewayClientOptions, - type CodexGatewayTransport, - type GatewayEvent, -} from "./gateway/client.ts"; + CodexWorkspaceBackendClient, + type CodexWorkspaceBackendClientOptions, + type CodexWorkspaceBackendTransport, + type WorkspaceBackendEvent, +} from "./workspace-backend/client.ts"; export { CodexWebSocketTransport, type CodexWebSocketTransportOptions, diff --git a/packages/codex-client/src/gateway/index.ts b/packages/codex-client/src/gateway/index.ts deleted file mode 100644 index e8c8767..0000000 --- a/packages/codex-client/src/gateway/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -export { - CodexGatewayClient, - type CodexGatewayClientOptions, - type CodexGatewayTransport, - type GatewayEvent, -} from "./client.ts"; -export { - CodexGatewayProtocolServer, - type CodexGatewayAppServer, - type CodexGatewayPeer, - type CodexGatewayProtocolServerOptions, -} from "./server.ts"; -export { - APP_SERVER_CALL_METHOD, - APP_SERVER_NOTIFICATION_METHOD, - APP_SERVER_NOTIFY_METHOD, - APP_SERVER_REQUEST_METHOD, - APP_SERVER_RESPOND_ERROR_METHOD, - APP_SERVER_RESPOND_METHOD, - GATEWAY_EVENT_METHOD, - GATEWAY_INITIALIZE_METHOD, - appServerCallParams, - appServerNotificationParams, - appServerNotifyParams, - appServerRequestParams, - appServerRespondErrorParams, - appServerRespondParams, - gatewayEventParams, - gatewayOwnedMethodPrefixes, - isGatewayOwnedMethod, - type AppServerCallParams, - type AppServerNotificationParams, - type AppServerNotifyParams, - type AppServerRequestParams, - type AppServerRespondErrorParams, - type AppServerRespondParams, - type GatewayEventParams, - type GatewayInitializeParams, - type GatewayInitializeResponse, -} from "./protocol.ts"; diff --git a/packages/codex-client/src/index.ts b/packages/codex-client/src/index.ts index 31bcc5f..49a16b3 100644 --- a/packages/codex-client/src/index.ts +++ b/packages/codex-client/src/index.ts @@ -4,15 +4,15 @@ export { type CodexAppServerTransport, } from "./app-server/client.ts"; export { - CodexGatewayClient, - CodexGatewayProtocolServer, - type CodexGatewayAppServer, - type CodexGatewayClientOptions, - type CodexGatewayPeer, - type CodexGatewayProtocolServerOptions, - type CodexGatewayTransport, - type GatewayEvent, -} from "./gateway/index.ts"; + CodexWorkspaceBackendClient, + CodexWorkspaceBackendProtocolServer, + type CodexWorkspaceBackendAppServer, + type CodexWorkspaceBackendClientOptions, + type CodexWorkspaceBackendPeer, + type CodexWorkspaceBackendProtocolServerOptions, + type CodexWorkspaceBackendTransport, + type WorkspaceBackendEvent, +} from "./workspace-backend/index.ts"; export { CodexStdioTransport, DEFAULT_CODEX_COMMAND, diff --git a/packages/codex-client/src/gateway/client.ts b/packages/codex-client/src/workspace-backend/client.ts similarity index 80% rename from packages/codex-client/src/gateway/client.ts rename to packages/codex-client/src/workspace-backend/client.ts index fd2ea70..3afee5c 100644 --- a/packages/codex-client/src/gateway/client.ts +++ b/packages/codex-client/src/workspace-backend/client.ts @@ -12,16 +12,16 @@ import { APP_SERVER_REQUEST_METHOD, APP_SERVER_RESPOND_ERROR_METHOD, APP_SERVER_RESPOND_METHOD, - GATEWAY_EVENT_METHOD, - GATEWAY_INITIALIZE_METHOD, + WORKSPACE_BACKEND_EVENT_METHOD, + WORKSPACE_BACKEND_INITIALIZE_METHOD, appServerNotificationParams, appServerRequestParams, - gatewayEventParams, - type GatewayEvent, - type GatewayInitializeResponse, + workspaceBackendEventParams, + type WorkspaceBackendEvent, + type WorkspaceBackendInitializeResponse, } from "./protocol.ts"; -export type CodexGatewayTransport = CodexEventEmitter & { +export type CodexWorkspaceBackendTransport = CodexEventEmitter & { readonly requestTimeoutMs: number; start(): void; close(): void; @@ -29,26 +29,26 @@ export type CodexGatewayTransport = CodexEventEmitter & { notify(method: string, params?: unknown): void; }; -export type CodexGatewayClientOptions = { - transport?: CodexGatewayTransport; +export type CodexWorkspaceBackendClientOptions = { + transport?: CodexWorkspaceBackendTransport; webSocketTransportOptions?: CodexWebSocketTransportOptions; clientName?: string; clientTitle?: string; clientVersion?: string; }; -export class CodexGatewayClient extends CodexEventEmitter { - readonly transport: CodexGatewayTransport; +export class CodexWorkspaceBackendClient extends CodexEventEmitter { + readonly transport: CodexWorkspaceBackendTransport; #clientName: string; #clientTitle: string | null; #clientVersion: string; #connected = false; - constructor(options: CodexGatewayClientOptions = {}) { + constructor(options: CodexWorkspaceBackendClientOptions = {}) { super(); const url = options.webSocketTransportOptions?.url; if (!options.transport && !url) { - throw new Error("A Codex gateway WebSocket URL is required"); + throw new Error("A Codex workspace backend WebSocket URL is required"); } this.transport = options.transport ?? @@ -56,8 +56,8 @@ export class CodexGatewayClient extends CodexEventEmitter { url: url!, requestTimeoutMs: options.webSocketTransportOptions?.requestTimeoutMs, }); - this.#clientName = options.clientName ?? "codex-gateway-client"; - this.#clientTitle = options.clientTitle ?? "Codex Gateway Client"; + this.#clientName = options.clientName ?? "codex-workspace-backend-client"; + this.#clientTitle = options.clientTitle ?? "Codex Workspace Backend Client"; this.#clientVersion = options.clientVersion ?? "0.1.0"; this.transport.on("notification", (message) => { @@ -75,10 +75,10 @@ export class CodexGatewayClient extends CodexEventEmitter { } return; } - if (message.method === GATEWAY_EVENT_METHOD) { - const params = gatewayEventParams(message.params); + if (message.method === WORKSPACE_BACKEND_EVENT_METHOD) { + const params = workspaceBackendEventParams(message.params); if (params) { - this.emit("gatewayEvent", params.event); + this.emit("workspaceBackendEvent", params.event); } return; } @@ -93,8 +93,8 @@ export class CodexGatewayClient extends CodexEventEmitter { return; } this.transport.start(); - await this.transport.request( - GATEWAY_INITIALIZE_METHOD, + await this.transport.request( + WORKSPACE_BACKEND_INITIALIZE_METHOD, { clientInfo: { name: this.#clientName, @@ -136,7 +136,7 @@ export class CodexGatewayClient extends CodexEventEmitter { }).catch((error: unknown) => this.emit("error", error)); } - gatewayRequest(method: string, params?: unknown): Promise { + workspaceRequest(method: string, params?: unknown): Promise { return this.transport.request(method, params); } @@ -187,4 +187,4 @@ export class CodexGatewayClient extends CodexEventEmitter { } } -export type { GatewayEvent }; +export type { WorkspaceBackendEvent }; diff --git a/packages/codex-client/src/workspace-backend/delegation.ts b/packages/codex-client/src/workspace-backend/delegation.ts new file mode 100644 index 0000000..2d1deb1 --- /dev/null +++ b/packages/codex-client/src/workspace-backend/delegation.ts @@ -0,0 +1,491 @@ +import type { v2 } from "../app-server/generated/index.ts"; + +export type WorkspaceDelegationReturnMode = + | "detached" + | "record_only" + | "wake_on_done" + | "wake_on_group" + | "manual"; + +export type WorkspaceDelegationStatus = + | "active" + | "idle" + | "failed" + | "complete" + | "reported"; + +export type WorkspaceDelegation = { + id: string; + codexThreadId: string; + title: string; + status: WorkspaceDelegationStatus; + cwd?: string; + workspaceKey?: string; + groupId?: string; + returnMode?: WorkspaceDelegationReturnMode; + metadata?: Record; + lastTurnId?: string; + lastStatus?: string; + lastFinal?: string; + completedAt?: string; + injectedAt?: string; + mirroredAt?: string; + taskMirroredAt?: string; + reportedAt?: string; + createdAt: string; + updatedAt: string; +}; + +export type WorkspacePendingWake = { + id: string; + kind: "delegation" | "group"; + delegationIds: string[]; + groupId?: string; + reason: string; + createdAt: string; + startedAt?: string; +}; + +export type WorkspaceDelegationAppServer = { + startThread(params: v2.ThreadStartParams): Promise; + resumeThread(params: v2.ThreadResumeParams): Promise; + setThreadName(params: v2.ThreadSetNameParams): Promise; + startTurn(params: v2.TurnStartParams): Promise; + readThread(params: v2.ThreadReadParams): Promise; +}; + +export type WorkspaceDelegationState = { + delegations: WorkspaceDelegation[]; + pendingWakes?: WorkspacePendingWake[]; +}; + +export type WorkspaceDelegationCapabilityOptions = { + client: WorkspaceDelegationAppServer; + state: WorkspaceDelegationState; + now?: () => Date; + threadStartParams(cwd: string): v2.ThreadStartParams; + threadResumeParams(threadId: string, cwd?: string): v2.ThreadResumeParams; + turnStartParams(input: { + threadId: string; + prompt: string; + cwd?: string | null; + }): v2.TurnStartParams; + metadataFromArgs?: (args: Record) => Record | undefined; + surfaceKeyForCwd?: (cwd?: string) => string | undefined; + recordResult?: (delegation: WorkspaceDelegation) => Promise; + mirrorResult?: (delegation: WorkspaceDelegation) => Promise; + enqueueWake?: (input: { + kind: WorkspacePendingWake["kind"]; + delegationIds: string[]; + groupId?: string; + reason: string; + }) => void; + processPendingWakes?: () => Promise; +}; + +export class WorkspaceDelegationCapability { + #client: WorkspaceDelegationAppServer; + #state: WorkspaceDelegationState; + #now: () => Date; + #options: WorkspaceDelegationCapabilityOptions; + + constructor(options: WorkspaceDelegationCapabilityOptions) { + this.#client = options.client; + this.#state = options.state; + this.#now = options.now ?? (() => new Date()); + this.#options = options; + } + + list(): { delegations: WorkspaceDelegation[] } { + return { delegations: this.#delegations() }; + } + + async start(args: Record): Promise<{ + delegation: WorkspaceDelegation; + turnId?: string; + }> { + const cwd = requiredArg(args, "cwd"); + const title = stringValue(args.title) ?? firstLine(stringValue(args.prompt)) ?? + `Delegated ${compactId(cwd)}`; + const prompt = stringValue(args.prompt); + const groupId = stringValue(args.groupId); + const returnMode = returnModeFromArgs( + args, + groupId ? "wake_on_group" : "wake_on_done", + ); + const started = await this.#client.startThread(this.#options.threadStartParams(cwd)); + const codexThreadId = started.thread.id; + await this.#client.setThreadName({ + threadId: codexThreadId, + name: `[delegated] ${title}`, + }); + const now = this.#now().toISOString(); + const delegation = this.upsert({ + id: delegationId(codexThreadId), + codexThreadId, + title, + status: prompt ? "active" : "idle", + cwd, + groupId, + returnMode, + metadata: this.#options.metadataFromArgs?.(args), + createdAt: now, + updatedAt: now, + }); + let turnId: string | undefined; + if (prompt) { + const turn = await this.#client.startTurn(this.#options.turnStartParams({ + threadId: codexThreadId, + prompt, + cwd, + })); + turnId = turn.turn.id; + delegation.lastTurnId = turnId; + } + return { delegation, turnId }; + } + + async resume(args: Record): Promise<{ delegation: WorkspaceDelegation }> { + const codexThreadId = requiredArg(args, "threadId"); + const cwd = stringValue(args.cwd); + const groupId = stringValue(args.groupId); + const resumed = await this.#client.resumeThread( + this.#options.threadResumeParams(codexThreadId, cwd), + ); + const now = this.#now().toISOString(); + const resolvedCwd = cwd ?? resumeResponseCwd(resumed); + const existing = this.delegationForThread(codexThreadId); + const delegation = this.upsert({ + id: stringValue(args.id) ?? delegationId(codexThreadId), + codexThreadId, + title: stringValue(args.title) ?? `Delegated ${compactId(codexThreadId)}`, + status: "idle", + cwd: resolvedCwd, + groupId, + returnMode: returnModeFromArgs(args, "manual"), + metadata: this.#options.metadataFromArgs?.(args), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }); + return { delegation }; + } + + async send(args: Record): Promise<{ + delegation: WorkspaceDelegation; + turnId: string; + }> { + const delegation = this.requireDelegation(args); + const prompt = requiredArg(args, "prompt"); + const groupId = stringValue(args.groupId); + if (groupId) { + delegation.groupId = groupId; + } + delegation.returnMode = returnModeFromArgs( + args, + delegation.returnMode ?? (delegation.groupId ? "wake_on_group" : "wake_on_done"), + ); + const turn = await this.#client.startTurn(this.#options.turnStartParams({ + threadId: delegation.codexThreadId, + prompt, + cwd: delegation.cwd ?? null, + })); + delegation.status = "active"; + delegation.lastTurnId = turn.turn.id; + delegation.lastStatus = undefined; + delegation.lastFinal = undefined; + delegation.completedAt = undefined; + delegation.injectedAt = undefined; + delegation.mirroredAt = undefined; + delegation.taskMirroredAt = undefined; + delegation.reportedAt = undefined; + delegation.updatedAt = this.#now().toISOString(); + return { delegation, turnId: turn.turn.id }; + } + + async read(args: Record): Promise<{ + delegation: WorkspaceDelegation; + latestTurnId?: string; + latestStatus?: string; + lastFinal?: ThreadSnapshot["lastFinal"]; + terminalTurnIds: string[]; + }> { + const delegation = this.requireDelegation(args); + const response = await this.#client.readThread({ + threadId: delegation.codexThreadId, + includeTurns: true, + }); + const snapshot = threadSnapshotFromThread(response.thread); + const turns = Array.isArray(response.thread.turns) ? response.thread.turns : []; + const latest = record(turns[turns.length - 1]); + const latestStatus = stringValue(latest.status); + if (latestStatus === "completed") { + delegation.status = "complete"; + } else if (latestStatus === "failed" || latestStatus === "interrupted") { + delegation.status = "failed"; + } else if (latestStatus) { + delegation.status = "active"; + } + delegation.lastTurnId = stringValue(latest.id) ?? delegation.lastTurnId; + delegation.lastStatus = latestStatus ?? delegation.lastStatus; + delegation.lastFinal = snapshot.lastFinal?.text ?? delegation.lastFinal; + if (latestStatus && isTerminalTurnStatus(latestStatus)) { + delegation.completedAt ??= this.#now().toISOString(); + } + delegation.updatedAt = this.#now().toISOString(); + return { + delegation, + latestTurnId: stringValue(latest.id), + latestStatus, + lastFinal: snapshot.lastFinal, + terminalTurnIds: snapshot.terminalTurnIds, + }; + } + + setPolicy(args: Record): { delegations: WorkspaceDelegation[] } { + const groupId = stringValue(args.groupId); + const mode = returnModeFromArgs(args, undefined); + if (!mode) { + throw new Error("Missing required argument: returnMode"); + } + const delegations = groupId + ? this.#delegations().filter((delegation) => delegation.groupId === groupId) + : [this.requireDelegation(args)]; + if (delegations.length === 0) { + throw new Error("No matching workspace delegations."); + } + const now = this.#now().toISOString(); + for (const delegation of delegations) { + delegation.returnMode = mode; + delegation.updatedAt = now; + } + return { delegations }; + } + + async flushResults(args: Record): Promise<{ flushed: WorkspaceDelegation[] }> { + const groupId = stringValue(args.groupId); + const delegations = groupId + ? this.#delegations().filter((delegation) => delegation.groupId === groupId) + : stringValue(args.delegationId) || stringValue(args.threadId) || stringValue(args.id) + ? [this.requireDelegation(args)] + : this.#delegations(); + const flushed: WorkspaceDelegation[] = []; + for (const delegation of delegations) { + if (!isTerminalDelegation(delegation)) { + continue; + } + await this.#options.recordResult?.(delegation); + await this.#options.mirrorResult?.(delegation); + flushed.push(delegation); + } + if (flushed.length > 0 && stringValue(args.wake) !== "false") { + this.#options.enqueueWake?.({ + kind: groupId ? "group" : "delegation", + groupId, + delegationIds: flushed.map((delegation) => delegation.id), + reason: groupId + ? `Delegation group ${groupId} was manually flushed.` + : "Delegation results were manually flushed.", + }); + await this.#options.processPendingWakes?.(); + } + return { flushed }; + } + + listGroups(): Array<{ + groupId: string; + total: number; + active: number; + terminal: number; + pendingWake: boolean; + }> { + const groups = new Map(); + for (const delegation of this.#delegations()) { + if (!delegation.groupId) { + continue; + } + const existing = groups.get(delegation.groupId) ?? []; + existing.push(delegation); + groups.set(delegation.groupId, existing); + } + return [...groups.entries()].map(([groupId, delegations]) => ({ + groupId, + total: delegations.length, + active: delegations.filter((delegation) => delegation.status === "active").length, + terminal: delegations.filter(isTerminalDelegation).length, + pendingWake: (this.#state.pendingWakes ?? []).some((wake) => + wake.groupId === groupId && !wake.startedAt + ), + })); + } + + upsert(input: WorkspaceDelegation): WorkspaceDelegation { + const delegations = this.#delegations(); + const index = delegations.findIndex((delegation) => + delegation.id === input.id || + delegation.codexThreadId === input.codexThreadId + ); + if (index >= 0) { + delegations[index] = { ...delegations[index], ...input }; + return delegations[index] as WorkspaceDelegation; + } + delegations.push(input); + return input; + } + + requireDelegation(args: Record): WorkspaceDelegation { + const id = stringValue(args.delegationId) ?? stringValue(args.id); + const threadId = stringValue(args.threadId); + const delegation = this.#delegations().find((candidate) => + (id && candidate.id === id) || + (threadId && candidate.codexThreadId === threadId) + ); + if (!delegation) { + throw new Error("Unknown workspace delegation."); + } + return delegation; + } + + delegationForThread(threadId: string): WorkspaceDelegation | undefined { + return this.#delegations().find((delegation) => + delegation.codexThreadId === threadId + ); + } + + #delegations(): WorkspaceDelegation[] { + this.#state.delegations ??= []; + return this.#state.delegations; + } +} + +type ThreadSnapshot = { + terminalTurnIds: string[]; + lastFinal?: { + turnId: string; + text: string; + }; +}; + +export function workspaceDelegationId(threadId: string): string { + return delegationId(threadId); +} + +export function returnModeFromArgs( + args: Record, + fallback: WorkspaceDelegationReturnMode | undefined, +): WorkspaceDelegationReturnMode | undefined { + const value = stringValue(args.returnMode) ?? stringValue(args.returnPolicy); + if (!value) { + return fallback; + } + if (value === "immediate") { + return "wake_on_done"; + } + if (value === "group_barrier") { + return "wake_on_group"; + } + if ( + value === "detached" || + value === "record_only" || + value === "wake_on_done" || + value === "wake_on_group" || + value === "manual" + ) { + return value; + } + throw new Error(`Invalid returnMode: ${value}`); +} + +export function isTerminalDelegation(delegation: WorkspaceDelegation): boolean { + return delegation.status === "complete" || + delegation.status === "failed" || + delegation.status === "reported"; +} + +function threadSnapshotFromThread(thread: { turns?: unknown[] }): ThreadSnapshot { + const turns = Array.isArray(thread.turns) ? thread.turns : []; + const terminalTurnIds: string[] = []; + let lastFinal: ThreadSnapshot["lastFinal"]; + for (const turn of turns) { + const parsed = record(turn); + const turnId = stringValue(parsed.id); + if (turnId && isTerminalTurnStatus(parsed.status)) { + terminalTurnIds.push(turnId); + } + } + for (const turn of [...turns].reverse()) { + const parsed = record(turn); + const turnId = stringValue(parsed.id); + const text = lastFinalTextFromTurn(parsed); + if (turnId && text) { + lastFinal = { turnId, text }; + break; + } + } + if (lastFinal && !terminalTurnIds.includes(lastFinal.turnId)) { + terminalTurnIds.push(lastFinal.turnId); + } + return { + terminalTurnIds: [...new Set(terminalTurnIds)], + lastFinal, + }; +} + +function resumeResponseCwd(response: unknown): string | undefined { + const responseRecord = record(response); + return stringValue(responseRecord.cwd) ?? + stringValue(record(responseRecord.thread).cwd); +} + +function lastFinalTextFromTurn(turn: Record): string { + const items = Array.isArray(turn.items) ? turn.items : []; + for (const item of [...items].reverse()) { + const candidate = record(item); + if ( + candidate.type === "agentMessage" && + candidate.phase === "final_answer" + ) { + return stringValue(candidate.text)?.trim() ?? ""; + } + } + return ""; +} + +function isTerminalTurnStatus(value: unknown): boolean { + return value === "completed" || value === "failed" || value === "interrupted"; +} + +function requiredArg(args: Record, name: string): string { + const value = stringValue(args[name]); + if (!value) { + throw new Error(`Missing required argument: ${name}`); + } + return value; +} + +function firstLine(value: string | undefined): string | undefined { + const line = value?.split(/\r?\n/, 1)[0]?.trim(); + return line || undefined; +} + +function compactId(value: string): string { + return value.length > 14 ? `${value.slice(0, 6)}...${value.slice(-6)}` : value; +} + +function delegationId(threadId: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < threadId.length; index += 1) { + hash ^= threadId.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `delegation-${(hash >>> 0).toString(16).padStart(8, "0")}`; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/packages/codex-client/src/workspace-backend/index.ts b/packages/codex-client/src/workspace-backend/index.ts new file mode 100644 index 0000000..3d45b35 --- /dev/null +++ b/packages/codex-client/src/workspace-backend/index.ts @@ -0,0 +1,54 @@ +export { + CodexWorkspaceBackendClient, + type CodexWorkspaceBackendClientOptions, + type CodexWorkspaceBackendTransport, + type WorkspaceBackendEvent, +} from "./client.ts"; +export { + CodexWorkspaceBackendProtocolServer, + type CodexWorkspaceBackendAppServer, + type CodexWorkspaceBackendPeer, + type CodexWorkspaceBackendProtocolServerOptions, + type WorkspaceBackendMethodHandler, +} from "./server.ts"; +export { + APP_SERVER_CALL_METHOD, + APP_SERVER_NOTIFICATION_METHOD, + APP_SERVER_NOTIFY_METHOD, + APP_SERVER_REQUEST_METHOD, + APP_SERVER_RESPOND_ERROR_METHOD, + APP_SERVER_RESPOND_METHOD, + WORKSPACE_BACKEND_EVENT_METHOD, + WORKSPACE_BACKEND_INITIALIZE_METHOD, + appServerCallParams, + appServerNotificationParams, + appServerNotifyParams, + appServerRequestParams, + appServerRespondErrorParams, + appServerRespondParams, + workspaceBackendEventParams, + workspaceBackendOwnedMethodPrefixes, + isWorkspaceBackendOwnedMethod, + type AppServerCallParams, + type AppServerNotificationParams, + type AppServerNotifyParams, + type AppServerRequestParams, + type AppServerRespondErrorParams, + type AppServerRespondParams, + type WorkspaceBackendEventParams, + type WorkspaceBackendInitializeParams, + type WorkspaceBackendInitializeResponse, +} from "./protocol.ts"; +export { + WorkspaceDelegationCapability, + isTerminalDelegation, + returnModeFromArgs, + workspaceDelegationId, + type WorkspaceDelegation, + type WorkspaceDelegationAppServer, + type WorkspaceDelegationCapabilityOptions, + type WorkspaceDelegationReturnMode, + type WorkspaceDelegationState, + type WorkspaceDelegationStatus, + type WorkspacePendingWake, +} from "./delegation.ts"; diff --git a/packages/codex-client/src/gateway/protocol.ts b/packages/codex-client/src/workspace-backend/protocol.ts similarity index 84% rename from packages/codex-client/src/gateway/protocol.ts rename to packages/codex-client/src/workspace-backend/protocol.ts index ac90c2b..dbba9f3 100644 --- a/packages/codex-client/src/gateway/protocol.ts +++ b/packages/codex-client/src/workspace-backend/protocol.ts @@ -4,8 +4,8 @@ import type { JsonRpcRequest, } from "../app-server/rpc.ts"; -export const GATEWAY_INITIALIZE_METHOD = "gateway.initialize"; -export const GATEWAY_EVENT_METHOD = "gateway.event"; +export const WORKSPACE_BACKEND_INITIALIZE_METHOD = "workspace.initialize"; +export const WORKSPACE_BACKEND_EVENT_METHOD = "workspace.event"; export const APP_SERVER_CALL_METHOD = "appServer.call"; export const APP_SERVER_NOTIFY_METHOD = "appServer.notify"; export const APP_SERVER_RESPOND_METHOD = "appServer.respond"; @@ -13,7 +13,7 @@ export const APP_SERVER_RESPOND_ERROR_METHOD = "appServer.respondError"; export const APP_SERVER_NOTIFICATION_METHOD = "appServer.notification"; export const APP_SERVER_REQUEST_METHOD = "appServer.request"; -export type GatewayInitializeParams = { +export type WorkspaceBackendInitializeParams = { clientInfo?: { name?: string; title?: string | null; @@ -22,7 +22,7 @@ export type GatewayInitializeParams = { capabilities?: Record; }; -export type GatewayInitializeResponse = { +export type WorkspaceBackendInitializeResponse = { ok: true; serverInfo: { name: string; @@ -30,7 +30,7 @@ export type GatewayInitializeResponse = { }; capabilities: { appServerPassThrough: true; - gatewayCommands: string[]; + workspaceMethods: string[]; flowInspection: boolean; }; }; @@ -65,7 +65,7 @@ export type AppServerRequestParams = { message: JsonRpcRequest; }; -export type GatewayEvent = +export type WorkspaceBackendEvent = | { type: "connected"; at: string; @@ -86,23 +86,23 @@ export type GatewayEvent = message: string; } | { - type: "unsupportedGatewayCommand"; + type: "unsupportedWorkspaceBackendMethod"; at: string; method: string; }; -export type GatewayEventParams = { - event: GatewayEvent; +export type WorkspaceBackendEventParams = { + event: WorkspaceBackendEvent; }; -export const gatewayOwnedMethodPrefixes = [ - "gateway.delegation.", - "gateway.workbench.", - "gateway.flow.", +export const workspaceBackendOwnedMethodPrefixes = [ + "delegation.", + "workbench.", + "flow.", ] as const; -export function isGatewayOwnedMethod(method: string): boolean { - return gatewayOwnedMethodPrefixes.some((prefix) => method.startsWith(prefix)); +export function isWorkspaceBackendOwnedMethod(method: string): boolean { + return workspaceBackendOwnedMethodPrefixes.some((prefix) => method.startsWith(prefix)); } export function appServerCallParams( @@ -167,16 +167,16 @@ export function appServerRequestParams( return message ? { message } : undefined; } -export function gatewayEventParams( +export function workspaceBackendEventParams( value: unknown, -): GatewayEventParams | undefined { +): WorkspaceBackendEventParams | undefined { const input = record(value); const event = record(input.event); const type = stringValue(event.type); if (!type) { return undefined; } - return { event: event as unknown as GatewayEvent }; + return { event: event as unknown as WorkspaceBackendEvent }; } function jsonRpcNotification(value: unknown): JsonRpcNotification | undefined { diff --git a/packages/codex-client/src/gateway/server.ts b/packages/codex-client/src/workspace-backend/server.ts similarity index 69% rename from packages/codex-client/src/gateway/server.ts rename to packages/codex-client/src/workspace-backend/server.ts index ac40fd5..e8b64c5 100644 --- a/packages/codex-client/src/gateway/server.ts +++ b/packages/codex-client/src/workspace-backend/server.ts @@ -15,18 +15,23 @@ import { APP_SERVER_REQUEST_METHOD, APP_SERVER_RESPOND_ERROR_METHOD, APP_SERVER_RESPOND_METHOD, - GATEWAY_EVENT_METHOD, - GATEWAY_INITIALIZE_METHOD, + WORKSPACE_BACKEND_EVENT_METHOD, + WORKSPACE_BACKEND_INITIALIZE_METHOD, appServerCallParams, appServerNotifyParams, appServerRespondErrorParams, appServerRespondParams, - isGatewayOwnedMethod, - type GatewayEvent, - type GatewayInitializeResponse, + isWorkspaceBackendOwnedMethod, + type WorkspaceBackendEvent, + type WorkspaceBackendInitializeResponse, } from "./protocol.ts"; -export type CodexGatewayAppServer = CodexEventEmitter & { +export type WorkspaceBackendMethodHandler = ( + params: unknown, + request: JsonRpcRequest, +) => unknown | Promise; + +export type CodexWorkspaceBackendAppServer = CodexEventEmitter & { connect?(): Promise; close?(): void; request(method: string, params?: unknown): Promise; @@ -35,35 +40,39 @@ export type CodexGatewayAppServer = CodexEventEmitter & { respondError(id: JsonRpcId, code: number, message: string, data?: unknown): void; }; -export type CodexGatewayPeer = { +export type CodexWorkspaceBackendPeer = { send(message: string): void; }; -export type CodexGatewayProtocolServerOptions = { - appServer: CodexGatewayAppServer; +export type CodexWorkspaceBackendProtocolServerOptions = { + appServer: CodexWorkspaceBackendAppServer; now?: () => Date; serverName?: string; serverVersion?: string; flowInspection?: boolean; - gatewayCommands?: string[]; + workspaceMethods?: string[]; + methods?: Record; }; -export class CodexGatewayProtocolServer { - readonly appServer: CodexGatewayAppServer; - #peers = new Set(); +export class CodexWorkspaceBackendProtocolServer { + readonly appServer: CodexWorkspaceBackendAppServer; + #peers = new Set(); #now: () => Date; #serverName: string; #serverVersion: string; #flowInspection: boolean; - #gatewayCommands: string[]; + #workspaceMethods: string[]; + #methods: Map; - constructor(options: CodexGatewayProtocolServerOptions) { + constructor(options: CodexWorkspaceBackendProtocolServerOptions) { this.appServer = options.appServer; this.#now = options.now ?? (() => new Date()); - this.#serverName = options.serverName ?? "codex-gateway-local"; + this.#serverName = options.serverName ?? "codex-workspace-backend-local"; this.#serverVersion = options.serverVersion ?? "0.1.0"; this.#flowInspection = options.flowInspection ?? false; - this.#gatewayCommands = options.gatewayCommands ?? []; + this.#methods = new Map(Object.entries(options.methods ?? {})); + this.#workspaceMethods = options.workspaceMethods ?? + [...this.#methods.keys()].sort(); this.appServer.on("notification", (message) => { this.broadcastNotification(APP_SERVER_NOTIFICATION_METHOD, { message }); @@ -72,14 +81,14 @@ export class CodexGatewayProtocolServer { this.broadcastNotification(APP_SERVER_REQUEST_METHOD, { message }); }); this.appServer.on("error", (error) => { - this.broadcastGatewayEvent({ + this.broadcastWorkspaceBackendEvent({ type: "appServer.error", at: this.#now().toISOString(), message: errorMessage(error), }); }); this.appServer.on("close", (code, reason) => { - this.broadcastGatewayEvent({ + this.broadcastWorkspaceBackendEvent({ type: "appServer.closed", at: this.#now().toISOString(), code: typeof code === "number" ? code : null, @@ -88,19 +97,19 @@ export class CodexGatewayProtocolServer { }); } - addPeer(peer: CodexGatewayPeer): void { + addPeer(peer: CodexWorkspaceBackendPeer): void { this.#peers.add(peer); - this.sendGatewayEvent(peer, { + this.sendWorkspaceBackendEvent(peer, { type: "connected", at: this.#now().toISOString(), }); } - removePeer(peer: CodexGatewayPeer): void { + removePeer(peer: CodexWorkspaceBackendPeer): void { this.#peers.delete(peer); } - async handleMessage(peer: CodexGatewayPeer, data: string): Promise { + async handleMessage(peer: CodexWorkspaceBackendPeer, data: string): Promise { let parsed: unknown; try { parsed = JSON.parse(data) as unknown; @@ -128,23 +137,28 @@ export class CodexGatewayProtocolServer { } } - broadcastGatewayEvent(event: GatewayEvent): void { - this.broadcastNotification(GATEWAY_EVENT_METHOD, { event }); + broadcastWorkspaceBackendEvent(event: WorkspaceBackendEvent): void { + this.broadcastNotification(WORKSPACE_BACKEND_EVENT_METHOD, { event }); } - sendGatewayEvent(peer: CodexGatewayPeer, event: GatewayEvent): void { + sendWorkspaceBackendEvent(peer: CodexWorkspaceBackendPeer, event: WorkspaceBackendEvent): void { peer.send(JSON.stringify({ jsonrpc: "2.0", - method: GATEWAY_EVENT_METHOD, + method: WORKSPACE_BACKEND_EVENT_METHOD, params: { event }, } satisfies JsonRpcNotification)); } async #handleRequest(request: JsonRpcRequest): Promise { try { - if (request.method === GATEWAY_INITIALIZE_METHOD) { + if (request.method === WORKSPACE_BACKEND_INITIALIZE_METHOD) { return successResponse(request.id, this.#initializeResponse()); } + const workspaceMethod = this.#methods.get(request.method); + if (workspaceMethod) { + const result = await workspaceMethod(request.params, request); + return successResponse(request.id, result ?? { ok: true }); + } if (request.method === APP_SERVER_CALL_METHOD) { const params = appServerCallParams(request.params); if (!params) { @@ -186,19 +200,19 @@ export class CodexGatewayProtocolServer { ); return successResponse(request.id, { ok: true }); } - if (isGatewayOwnedMethod(request.method)) { - this.broadcastGatewayEvent({ - type: "unsupportedGatewayCommand", + if (isWorkspaceBackendOwnedMethod(request.method)) { + this.broadcastWorkspaceBackendEvent({ + type: "unsupportedWorkspaceBackendMethod", at: this.#now().toISOString(), method: request.method, }); return errorResponse( request.id, -32601, - `Gateway command is not implemented: ${request.method}`, + `Workspace backend method is not implemented: ${request.method}`, ); } - return errorResponse(request.id, -32601, `Unknown gateway method: ${request.method}`); + return errorResponse(request.id, -32601, `Unknown workspace backend method: ${request.method}`); } catch (error) { return errorResponse(request.id, -32603, errorMessage(error)); } @@ -209,7 +223,7 @@ export class CodexGatewayProtocolServer { if (notification.method === APP_SERVER_NOTIFY_METHOD) { const params = appServerNotifyParams(notification.params); if (!params) { - this.broadcastGatewayEvent({ + this.broadcastWorkspaceBackendEvent({ type: "appServer.error", at: this.#now().toISOString(), message: "Invalid appServer.notify params", @@ -219,15 +233,15 @@ export class CodexGatewayProtocolServer { this.appServer.notify(params.method, params.params); return; } - if (isGatewayOwnedMethod(notification.method)) { - this.broadcastGatewayEvent({ - type: "unsupportedGatewayCommand", + if (isWorkspaceBackendOwnedMethod(notification.method)) { + this.broadcastWorkspaceBackendEvent({ + type: "unsupportedWorkspaceBackendMethod", at: this.#now().toISOString(), method: notification.method, }); } } catch (error) { - this.broadcastGatewayEvent({ + this.broadcastWorkspaceBackendEvent({ type: "appServer.error", at: this.#now().toISOString(), message: errorMessage(error), @@ -235,7 +249,7 @@ export class CodexGatewayProtocolServer { } } - #initializeResponse(): GatewayInitializeResponse { + #initializeResponse(): WorkspaceBackendInitializeResponse { return { ok: true, serverInfo: { @@ -244,7 +258,7 @@ export class CodexGatewayProtocolServer { }, capabilities: { appServerPassThrough: true, - gatewayCommands: this.#gatewayCommands, + workspaceMethods: this.#workspaceMethods, flowInspection: this.#flowInspection, }, }; diff --git a/packages/codex-client/test/workbench.test.ts b/packages/codex-client/test/workbench.test.ts index 43e8985..4f8dc7a 100644 --- a/packages/codex-client/test/workbench.test.ts +++ b/packages/codex-client/test/workbench.test.ts @@ -63,7 +63,7 @@ test("derives goal, plan, running command, activity, and final answer state", () params: { threadId: "thread-1", turnId: "turn-1", - item: dynamicToolItem("tool-1", "codex_gateway", "list_flow_runs", "completed"), + item: dynamicToolItem("tool-1", "codex_workspace", "list_flow_runs", "completed"), completedAtMs: fixedNow.getTime(), }, }, { now: fixedNow }); @@ -102,7 +102,7 @@ test("derives goal, plan, running command, activity, and final answer state", () expect.objectContaining({ itemId: "tool-1", kind: "tool", - label: "codex_gateway.list_flow_runs", + label: "codex_workspace.list_flow_runs", status: "completed", }), ]), diff --git a/packages/codex-client/test/gateway.test.ts b/packages/codex-client/test/workspace-backend.test.ts similarity index 74% rename from packages/codex-client/test/gateway.test.ts rename to packages/codex-client/test/workspace-backend.test.ts index 58e0deb..3489bba 100644 --- a/packages/codex-client/test/gateway.test.ts +++ b/packages/codex-client/test/workspace-backend.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "bun:test"; import { - CodexGatewayClient, - CodexGatewayProtocolServer, - type CodexGatewayAppServer, - type CodexGatewayPeer, -} from "../src/gateway/index.ts"; + CodexWorkspaceBackendClient, + CodexWorkspaceBackendProtocolServer, + type CodexWorkspaceBackendAppServer, + type CodexWorkspaceBackendPeer, +} from "../src/workspace-backend/index.ts"; import { CodexEventEmitter } from "../src/app-server/events.ts"; import type { JsonRpcId, @@ -12,10 +12,10 @@ import type { JsonRpcResponse, } from "../src/app-server/rpc.ts"; -describe("Codex gateway protocol", () => { +describe("Codex workspace backend protocol", () => { test("server proxies appServer.call without interpreting native app-server methods", async () => { const appServer = new FakeAppServer(); - const server = new CodexGatewayProtocolServer({ appServer }); + const server = new CodexWorkspaceBackendProtocolServer({ appServer }); const peer = new MemoryPeer(); server.addPeer(peer); @@ -38,28 +38,50 @@ describe("Codex gateway protocol", () => { }); }); - test("server declares gateway-owned commands but does not fake implementations", async () => { + test("server handles registered workspace methods without forwarding them", async () => { const appServer = new FakeAppServer(); - const server = new CodexGatewayProtocolServer({ appServer }); + const server = new CodexWorkspaceBackendProtocolServer({ + appServer, + methods: { + "delegation.list": () => ({ delegations: [] }), + }, + }); const peer = new MemoryPeer(); server.addPeer(peer); await server.handleMessage(peer, JSON.stringify({ jsonrpc: "2.0", id: "delegation", - method: "gateway.delegation.start", + method: "delegation.list", + params: {}, + })); + + expect(appServer.requests).toEqual([]); + expect(peer.response("delegation")?.result).toEqual({ delegations: [] }); + }); + + test("unknown workspace backend methods return method not found", async () => { + const appServer = new FakeAppServer(); + const server = new CodexWorkspaceBackendProtocolServer({ appServer }); + const peer = new MemoryPeer(); + server.addPeer(peer); + + await server.handleMessage(peer, JSON.stringify({ + jsonrpc: "2.0", + id: "delegation", + method: "delegation.start", params: { prompt: "do work" }, })); expect(appServer.requests).toEqual([]); expect(peer.response("delegation")?.error?.code).toBe(-32601); - expect(peer.notifications("gateway.event")).toContainEqual( + expect(peer.notifications("workspace.event")).toContainEqual( expect.objectContaining({ - method: "gateway.event", + method: "workspace.event", params: { event: expect.objectContaining({ - type: "unsupportedGatewayCommand", - method: "gateway.delegation.start", + type: "unsupportedWorkspaceBackendMethod", + method: "delegation.start", }), }, }), @@ -68,7 +90,7 @@ describe("Codex gateway protocol", () => { test("server proxies appServer.notify notifications without a response", async () => { const appServer = new FakeAppServer(); - const server = new CodexGatewayProtocolServer({ appServer }); + const server = new CodexWorkspaceBackendProtocolServer({ appServer }); const peer = new MemoryPeer(); await server.handleMessage(peer, JSON.stringify({ @@ -87,8 +109,8 @@ describe("Codex gateway protocol", () => { }); test("client uses appServer.call for native helpers and unwraps app-server notifications", async () => { - const transport = new FakeGatewayTransport(); - const client = new CodexGatewayClient({ + const transport = new FakeWorkspaceBackendTransport(); + const client = new CodexWorkspaceBackendClient({ transport, clientName: "test-web", clientTitle: "Test Web", @@ -114,7 +136,7 @@ describe("Codex gateway protocol", () => { expect(transport.requests).toEqual([ { - method: "gateway.initialize", + method: "workspace.initialize", params: { clientInfo: { name: "test-web", @@ -151,7 +173,7 @@ describe("Codex gateway protocol", () => { }); }); -class FakeAppServer extends CodexEventEmitter implements CodexGatewayAppServer { +class FakeAppServer extends CodexEventEmitter implements CodexWorkspaceBackendAppServer { requests: Array<{ method: string; params?: unknown }> = []; notifications: Array<{ method: string; params?: unknown }> = []; responses: Array<{ id: JsonRpcId; result: unknown }> = []; @@ -185,7 +207,7 @@ class FakeAppServer extends CodexEventEmitter implements CodexGatewayAppServer { } } -class MemoryPeer implements CodexGatewayPeer { +class MemoryPeer implements CodexWorkspaceBackendPeer { messages: unknown[] = []; send(message: string): void { @@ -205,7 +227,7 @@ class MemoryPeer implements CodexGatewayPeer { } } -class FakeGatewayTransport extends CodexEventEmitter { +class FakeWorkspaceBackendTransport extends CodexEventEmitter { readonly requestTimeoutMs = 60_000; requests: Array<{ method: string; params?: unknown }> = []; started = false; @@ -220,13 +242,13 @@ class FakeGatewayTransport extends CodexEventEmitter { async request(method: string, params?: unknown): Promise { this.requests.push({ method, params }); - if (method === "gateway.initialize") { + if (method === "workspace.initialize") { return { ok: true, serverInfo: { name: "fake", version: "0.1.0" }, capabilities: { appServerPassThrough: true, - gatewayCommands: [], + workspaceMethods: [], flowInspection: false, }, } as T; diff --git a/packages/flow-runtime/README.md b/packages/flow-runtime/README.md index f5e60e8..dfe529c 100644 --- a/packages/flow-runtime/README.md +++ b/packages/flow-runtime/README.md @@ -68,7 +68,7 @@ const backend = createFlowBackendHttpClient({ const { runs } = await backend.listRuns({ status: "completed", limit: 20 }); ``` -The client normalizes systemd-local, Convex-adapter, and codex-service-style +The client normalizes workspace-local, Convex-adapter, and codex-service-style run/event responses into stable view models with `processStatus`, `resultStatus`, `effectiveStatus`, `needsAttention`, attempts, latest output, and result payload data. Semantic statuses such as `blocked` and