Merge gateway primitives into workspace backend
This commit is contained in:
parent
c96668ec76
commit
94e4dfc61a
74 changed files with 2771 additions and 2048 deletions
|
|
@ -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.
|
||||
|
|
|
|||
51
README.md
51
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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<LocalCodexGatewayBackendOptions, "presenter"> & {
|
||||
Omit<LocalCodexWorkspaceBackendOptions, "presenter"> & {
|
||||
transport: DiscordBridgeTransport;
|
||||
backend?: undefined;
|
||||
};
|
||||
|
||||
export type DiscordCodexBridgeBackendOptions = {
|
||||
backend: CodexGatewayBackend;
|
||||
backend: CodexWorkspaceBackend;
|
||||
transport: DiscordBridgeTransport;
|
||||
logger?: DiscordBridgeLogger;
|
||||
config?: Pick<DiscordBridgeConfig, "debug" | "logLevel">;
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<v2.SandboxMode>([
|
|||
"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<string, string | boolean>,
|
||||
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<string, DiscordGatewaySurfaceConfig>();
|
||||
function mergeWorkspaceSurfaces(
|
||||
surfaces: DiscordWorkspaceSurfaceConfig[],
|
||||
): DiscordWorkspaceSurfaceConfig[] {
|
||||
const byKey = new Map<string, DiscordWorkspaceSurfaceConfig>();
|
||||
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<string>();
|
||||
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 <path> Persistent bridge state file
|
||||
--allowed-channel-ids <ids> Comma-separated parent channel ids
|
||||
--home-channel-id <id> Enable gateway mode for one Discord home channel
|
||||
--main-thread-id <id> Resume an existing Codex operator thread for gateway mode
|
||||
--home-channel-id <id> Enable workspace mode for one Discord home channel
|
||||
--main-thread-id <id> Resume an existing Codex operator thread for workspace mode
|
||||
--workspace-forum-channel-id <id>
|
||||
Optional workbench forum channel for workspace posts
|
||||
--task-threads-channel-id <id> Optional workbench text channel for task threads
|
||||
--flow-backend-url <url> Optional codex-flow-systemd-local backend URL
|
||||
--flow-backend-url <url> Optional workspace flow HTTP backend URL
|
||||
--hook-spool-dir <path> Directory drained for Codex hook events
|
||||
[dir] Optional Codex thread directory, resolved from home
|
||||
--dir <path> Codex thread directory, resolved from home
|
||||
|
|
|
|||
|
|
@ -732,7 +732,7 @@ function discordBridgeCommands(): ApplicationCommandDataResolvable[] {
|
|||
},
|
||||
{
|
||||
name: "status",
|
||||
description: "Show Codex gateway status",
|
||||
description: "Show Codex workspace status",
|
||||
},
|
||||
{
|
||||
name: "threads",
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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<string, unknown> {
|
||||
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<string, unknown> => group !== undefined),
|
||||
];
|
||||
}
|
||||
|
|
@ -217,10 +217,10 @@ function hookGroup(command: string): Record<string, unknown> {
|
|||
};
|
||||
}
|
||||
|
||||
function removeGatewayStopHookHandlers(input: unknown): Record<string, unknown> | undefined {
|
||||
function removeWorkspaceStopHookHandlers(input: unknown): Record<string, unknown> | 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<string, unknown>
|
|||
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");
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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<void>;
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<DiscordGatewayState["pendingWakes"]>[number] {
|
||||
): NonNullable<DiscordWorkspaceState["pendingWakes"]>[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
|
||||
|
|
|
|||
|
|
@ -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<DiscordGatewayHookEvent> {
|
||||
): Promise<DiscordWorkspaceHookEvent> {
|
||||
return await writeHookSpoolEvent(input, options);
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ export async function writeHookSpoolEvent(
|
|||
spoolDir?: string;
|
||||
now?: () => Date;
|
||||
} = {},
|
||||
): Promise<DiscordGatewayHookEvent> {
|
||||
): Promise<DiscordWorkspaceHookEvent> {
|
||||
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<void> {
|
|||
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" ||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export type DiscordBridgeConfig = {
|
|||
allowedUserIds: Set<string>;
|
||||
allowedChannelIds: Set<string>;
|
||||
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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type {
|
|||
DiscordInbound,
|
||||
} from "./types.ts";
|
||||
|
||||
export type CodexGatewayBackend = {
|
||||
export type CodexWorkspaceBackend = {
|
||||
start(): Promise<void>;
|
||||
startTransportDependentWork?(): Promise<void>;
|
||||
startBackgroundWork?(): Promise<void>;
|
||||
|
|
@ -15,7 +15,7 @@ export type CodexGatewayBackend = {
|
|||
flushSummariesForTest?(): Promise<void>;
|
||||
};
|
||||
|
||||
export type CodexGatewayPresenter = {
|
||||
export type CodexWorkspacePresenter = {
|
||||
createWorkspacePost?(
|
||||
locationId: string,
|
||||
title: string,
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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.",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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");
|
||||
}
|
||||
|
|
@ -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<typeof Bun.serve> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<Bun.ServerWebSocket<unknown>, 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<GatewayCliArgs, { type: "serve" }>,
|
||||
): 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<unknown>,
|
||||
client: CodexAppServerClient,
|
||||
): Promise<void> {
|
||||
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();
|
||||
|
|
@ -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<CodexGatewayClient | null>(null);
|
||||
const clientRef = useRef<CodexWorkspaceBackendClient | null>(null);
|
||||
const authRef = useRef<CodexAuthClient | null>(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() {
|
|||
<h1 className="truncate text-base font-semibold">Codex Bare</h1>
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{connectedUrl ?? "No gateway connection"}
|
||||
{connectedUrl ?? "No workspace backend connection"}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
export const gatewayStorageKey = "codex-bare.gateway-url";
|
||||
|
||||
export type GatewayUrlOptions = {
|
||||
envUrl?: string;
|
||||
location: Pick<Location, "host" | "protocol">;
|
||||
storage?: Pick<Storage, "getItem">;
|
||||
};
|
||||
|
||||
export function initialGatewayWsUrl(options: GatewayUrlOptions): string {
|
||||
return options.storage?.getItem(gatewayStorageKey) ??
|
||||
options.envUrl ??
|
||||
proxiedGatewayWsUrl(options.location);
|
||||
}
|
||||
|
||||
export function proxiedGatewayWsUrl(
|
||||
location: Pick<Location, "host" | "protocol">,
|
||||
): string {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${location.host}/__codex-gateway`;
|
||||
}
|
||||
20
apps/web/src/workspace-backend-url.ts
Normal file
20
apps/web/src/workspace-backend-url.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export const workspaceBackendStorageKey = "codex-bare.workspace-backend-url";
|
||||
|
||||
export type WorkspaceBackendUrlOptions = {
|
||||
envUrl?: string;
|
||||
location: Pick<Location, "host" | "protocol">;
|
||||
storage?: Pick<Storage, "getItem">;
|
||||
};
|
||||
|
||||
export function initialWorkspaceBackendWsUrl(options: WorkspaceBackendUrlOptions): string {
|
||||
return options.storage?.getItem(workspaceBackendStorageKey) ??
|
||||
options.envUrl ??
|
||||
proxiedWorkspaceBackendWsUrl(options.location);
|
||||
}
|
||||
|
||||
export function proxiedWorkspaceBackendWsUrl(
|
||||
location: Pick<Location, "host" | "protocol">,
|
||||
): string {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${location.host}/__codex-workspace-backend`;
|
||||
}
|
||||
|
|
@ -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<string, string>([
|
||||
[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");
|
||||
});
|
||||
});
|
||||
41
apps/web/test/workspace-backend-url.test.ts
Normal file
41
apps/web/test/workspace-backend-url.test.ts
Normal file
|
|
@ -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<string, string>([
|
||||
[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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
@ -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<string, string | undefined> = 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> Host to bind. Defaults to 127.0.0.1.
|
||||
--port <port> Port to bind. Defaults to 3586.
|
||||
--app-server-url <url> Existing app-server WebSocket URL.
|
||||
--local-app-server Start a local app-server over stdio.
|
||||
--cwd <dir> Workspace root for flow discovery.
|
||||
--data-dir <dir> Durable flow backend state directory.
|
||||
--secret <secret> Optional HMAC secret for HTTP flow dispatch.
|
||||
--executor <executor> Flow executor: direct or systemd-run.
|
||||
--bun <path> Bun command for flow execution.
|
||||
--flow-runner <path> 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
|
||||
`;
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
@ -203,19 +203,19 @@ export function parseCli(argv: string[], env: Record<string, string | undefined>
|
|||
}
|
||||
|
||||
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 <dir>] [--data-dir <dir>] [--host <host>] [--port <port>]",
|
||||
" codex-flow-systemd-local dispatch --event <event.json> [--cwd <dir>] [--data-dir <dir>] [--wait]",
|
||||
" codex-flow-systemd-local list-events [--type <type>] [--limit <n>]",
|
||||
" codex-flow-systemd-local show-event <event-id>",
|
||||
" codex-flow-systemd-local replay-event <event-id> [--wait]",
|
||||
" codex-flow-systemd-local list-runs [--event-id <event-id>] [--status <status>] [--limit <n>]",
|
||||
" codex-flow-systemd-local show-run <run-id>",
|
||||
" codex-workspace-backend-local serve [--cwd <dir>] [--data-dir <dir>] [--host <host>] [--port <port>]",
|
||||
" codex-workspace-backend-local dispatch --event <event.json> [--cwd <dir>] [--data-dir <dir>] [--wait]",
|
||||
" codex-workspace-backend-local list-events [--type <type>] [--limit <n>]",
|
||||
" codex-workspace-backend-local show-event <event-id>",
|
||||
" codex-workspace-backend-local replay-event <event-id> [--wait]",
|
||||
" codex-workspace-backend-local list-runs [--event-id <event-id>] [--status <status>] [--limit <n>]",
|
||||
" codex-workspace-backend-local show-run <run-id>",
|
||||
"",
|
||||
"Environment:",
|
||||
" CODEX_FLOW_BACKEND_SECRET Optional HMAC secret for HTTP dispatches",
|
||||
195
apps/workspace-backend/src/flow/server.ts
Normal file
195
apps/workspace-backend/src/flow/server.ts
Normal file
|
|
@ -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<string, string | undefined>;
|
||||
};
|
||||
|
||||
export class WorkspaceFlowCapability {
|
||||
readonly config: FlowBackendConfig;
|
||||
readonly store: FlowBackendStore;
|
||||
#env: Record<string, string | undefined>;
|
||||
#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<unknown> {
|
||||
return await dispatchFlowEvent({
|
||||
config: this.config,
|
||||
store: this.store,
|
||||
event: normalizeFlowEvent(event),
|
||||
env: this.#env,
|
||||
});
|
||||
}
|
||||
|
||||
async replay(eventId: string, options: { wait?: boolean } = {}): Promise<unknown> {
|
||||
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<Response | undefined> {
|
||||
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<typeof Bun.serve> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -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),
|
||||
355
apps/workspace-backend/src/index.ts
Normal file
355
apps/workspace-backend/src/index.ts
Normal file
|
|
@ -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<void> {
|
||||
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<Bun.ServerWebSocket<unknown>, 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<WorkspaceBackendCliArgs, { type: "serve" }>,
|
||||
): 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<WorkspaceBackendCliArgs, { type: "serve" }>,
|
||||
): 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<string, WorkspaceBackendMethodHandler> {
|
||||
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<unknown>,
|
||||
client: CodexAppServerClient,
|
||||
flow: WorkspaceFlowCapability,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
await main();
|
||||
|
|
@ -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,
|
||||
|
|
@ -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" });
|
||||
191
apps/workspace-backend/test/integration.test.ts
Normal file
191
apps/workspace-backend/test/integration.test.ts
Normal file
|
|
@ -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<Bun.ServerWebSocket<unknown>, 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<T = unknown>(method: string, params?: unknown): Promise<T> {
|
||||
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<Record<string, unknown>>,
|
||||
): Promise<Map<string | number, RpcResponse>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const responses = new Map<string | number, RpcResponse>();
|
||||
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"),
|
||||
);
|
||||
}
|
||||
|
|
@ -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"]
|
||||
50
bun.lock
50
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=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
63
docs/pages/concepts/workspace-backend-deployments.md
Normal file
63
docs/pages/concepts/workspace-backend-deployments.md
Normal file
|
|
@ -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.
|
||||
40
docs/pages/concepts/workspace-backends.md
Normal file
40
docs/pages/concepts/workspace-backends.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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://<web-host>/__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.
|
||||
54
docs/pages/guides/run-web-over-local-workspace-backend.md
Normal file
54
docs/pages/guides/run-web-over-local-workspace-backend.md
Normal file
|
|
@ -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://<web-host>/__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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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=<hex digest>
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ bun run flow run <flow> <step> --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 <workspace>
|
||||
|
|
@ -34,6 +34,6 @@ bun run flow:backend replay-event <event-id> --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. |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<GatewayInitializeResponse>(
|
||||
GATEWAY_INITIALIZE_METHOD,
|
||||
await this.transport.request<WorkspaceBackendInitializeResponse>(
|
||||
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<T = unknown>(method: string, params?: unknown): Promise<T> {
|
||||
workspaceRequest<T = unknown>(method: string, params?: unknown): Promise<T> {
|
||||
return this.transport.request<T>(method, params);
|
||||
}
|
||||
|
||||
|
|
@ -187,4 +187,4 @@ export class CodexGatewayClient extends CodexEventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
export type { GatewayEvent };
|
||||
export type { WorkspaceBackendEvent };
|
||||
491
packages/codex-client/src/workspace-backend/delegation.ts
Normal file
491
packages/codex-client/src/workspace-backend/delegation.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<v2.ThreadStartResponse>;
|
||||
resumeThread(params: v2.ThreadResumeParams): Promise<v2.ThreadResumeResponse>;
|
||||
setThreadName(params: v2.ThreadSetNameParams): Promise<v2.ThreadSetNameResponse>;
|
||||
startTurn(params: v2.TurnStartParams): Promise<v2.TurnStartResponse>;
|
||||
readThread(params: v2.ThreadReadParams): Promise<v2.ThreadReadResponse>;
|
||||
};
|
||||
|
||||
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<string, unknown>) => Record<string, unknown> | undefined;
|
||||
surfaceKeyForCwd?: (cwd?: string) => string | undefined;
|
||||
recordResult?: (delegation: WorkspaceDelegation) => Promise<void>;
|
||||
mirrorResult?: (delegation: WorkspaceDelegation) => Promise<void>;
|
||||
enqueueWake?: (input: {
|
||||
kind: WorkspacePendingWake["kind"];
|
||||
delegationIds: string[];
|
||||
groupId?: string;
|
||||
reason: string;
|
||||
}) => void;
|
||||
processPendingWakes?: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): { 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<string, unknown>): 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<string, WorkspaceDelegation[]>();
|
||||
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<string, unknown>): 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<string, unknown>,
|
||||
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, unknown>): 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<string, unknown>, 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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
54
packages/codex-client/src/workspace-backend/index.ts
Normal file
54
packages/codex-client/src/workspace-backend/index.ts
Normal file
|
|
@ -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";
|
||||
|
|
@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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 {
|
||||
|
|
@ -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<unknown>;
|
||||
|
||||
export type CodexWorkspaceBackendAppServer = CodexEventEmitter & {
|
||||
connect?(): Promise<void>;
|
||||
close?(): void;
|
||||
request<T = unknown>(method: string, params?: unknown): Promise<T>;
|
||||
|
|
@ -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<string, WorkspaceBackendMethodHandler>;
|
||||
};
|
||||
|
||||
export class CodexGatewayProtocolServer {
|
||||
readonly appServer: CodexGatewayAppServer;
|
||||
#peers = new Set<CodexGatewayPeer>();
|
||||
export class CodexWorkspaceBackendProtocolServer {
|
||||
readonly appServer: CodexWorkspaceBackendAppServer;
|
||||
#peers = new Set<CodexWorkspaceBackendPeer>();
|
||||
#now: () => Date;
|
||||
#serverName: string;
|
||||
#serverVersion: string;
|
||||
#flowInspection: boolean;
|
||||
#gatewayCommands: string[];
|
||||
#workspaceMethods: string[];
|
||||
#methods: Map<string, WorkspaceBackendMethodHandler>;
|
||||
|
||||
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<void> {
|
||||
async handleMessage(peer: CodexWorkspaceBackendPeer, data: string): Promise<void> {
|
||||
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<JsonRpcResponse> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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",
|
||||
}),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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<T = unknown>(method: string, params?: unknown): Promise<T> {
|
||||
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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue