Remove legacy flow runtime surface

This commit is contained in:
matamune 2026-05-26 14:56:47 +00:00
parent 9ca23a72e9
commit 391ebbc857
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
80 changed files with 81 additions and 10613 deletions

View file

@ -28,16 +28,11 @@ or docs surface that solves the problem:
- `packages/codex-client` owns the public `@peezy.tech/codex-flows` package,
app-server clients, generated protocol types, workspace helpers, and bundled
bins.
- `packages/flow-runtime` and `packages/flow-backend-convex` are legacy
backend-library packages while the single-package platform migration
continues; do not add new product surface there.
- `apps/discord-bridge` and `apps/workspace-voice-gateway` are gateway packages
that depend on `@peezy.tech/codex-flows`.
- `apps/workspace-backend`, `apps/web`, `apps/cli`, and `apps/flow-runner` are
- `apps/workspace-backend`, `apps/web`, and `apps/cli` are
workspace-local apps that are also bundled into the core package where
appropriate.
- `flows` contains bundled legacy flow packages used to maintain Codex bindings
and Peezy fork releases until they move to turn automation.
- `docs/pages` is the canonical user documentation.
Generated app-server protocol files live under

View file

@ -106,8 +106,8 @@ The canonical user-facing package is:
- `@peezy.tech/codex-flows`
Older flow runtime packages may still exist in the monorepo during conversion,
but they are no longer part of the primary product surface.
Legacy automation packages have been removed from the monorepo; new automation
surface belongs in the core package and plugin-native turn automation.
Release procedure and remote policy are in [`RELEASE.md`](RELEASE.md). In short:
jojo.build is the canonical development remote, Codeberg is a push mirror, and

View file

@ -18,7 +18,6 @@
"paths": {
"@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/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"]

View file

@ -24,7 +24,6 @@ CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID=1502107617512919221
CODEX_DISCORD_TASK_THREADS_CHANNEL_ID=1502107617512919222
CODEX_DISCORD_ALLOWED_CHANNEL_IDS=1502107617512919220
CODEX_DISCORD_DIR=/home/peezy
CODEX_FLOW_BACKEND_URL=http://127.0.0.1:8090
CODEX_DISCORD_HOOK_SPOOL_DIR=/home/peezy/.codex/discord-bridge/stop-hooks
```
@ -89,19 +88,16 @@ are attached only to that main thread and expose:
- `set_delegation_policy`
- `flush_delegation_results`
- `list_delegation_groups`
- `list_flow_runs`
- `list_flow_events`
Those tools can:
- list tracked delegated Codex sessions and backend runs/events
- list tracked delegated Codex sessions
- start a delegated Codex session in a requested cwd
- resume a delegated Codex session by thread id
- send a turn to a delegated session
- observe or summarize delegated session state
- group delegations for fan-out/fan-in coordination
- record completed delegation results into the main operator thread
- inspect flow backend state through `CODEX_FLOW_BACKEND_URL`
Workspace state stores delegation records, including optional Discord detail
thread ids for noisy work. Delegated Codex sessions do not receive the privileged

View file

@ -137,10 +137,6 @@ export function parseConfig(argv: string[], env: NodeJS.ProcessEnv): ParsedConfi
),
statePath,
workspace: workspaceConfig(args, env, cwd),
flowBackendUrl:
stringFlag(args, "flow-backend-url") ??
env.CODEX_FLOW_BACKEND_URL ??
env.CODEX_GATEWAY_BACKEND_URL,
cwd,
model: stringFlag(args, "model") ?? env.CODEX_DISCORD_MODEL,
modelProvider:
@ -661,7 +657,6 @@ Options:
--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 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

View file

@ -12,10 +12,6 @@ import {
type WorkspaceDelegation,
type WorkspacePendingWake,
} from "@peezy.tech/codex-flows/workspace-backend";
import {
createFlowBackendHttpClient,
type FlowBackendClient,
} from "@peezy.tech/codex-flows/flow-runtime/backend-client";
import type { DiscordConsoleOutput } from "./console-output.ts";
import type {
@ -137,7 +133,6 @@ export type LocalCodexWorkspaceBackendOptions = {
now?: () => Date;
logger?: DiscordBridgeLogger;
consoleOutput?: DiscordConsoleOutput;
flowBackendClient?: FlowBackendClient;
};
export class LocalCodexWorkspaceBackend implements CodexWorkspaceBackend {
@ -156,7 +151,6 @@ export class LocalCodexWorkspaceBackend implements CodexWorkspaceBackend {
#workspaceStopHookWatcher: FSWatcher | undefined;
#workspaceStopHookDrainTimer: ReturnType<typeof setTimeout> | undefined;
#workspaceStopHookDrainChain: Promise<void> = Promise.resolve();
#flowBackendClient: FlowBackendClient | undefined;
#transportStarted = false;
#threadPickersByMessage = new Map<string, WorkspaceThreadPicker>();
#threadPickersById = new Map<string, WorkspaceThreadPicker>();
@ -175,9 +169,8 @@ export class LocalCodexWorkspaceBackend implements CodexWorkspaceBackend {
debug: this.config.debug,
logLevel: this.config.logLevel,
now: this.#now,
});
});
this.#consoleOutput = options.consoleOutput;
this.#flowBackendClient = options.flowBackendClient;
}
async start(): Promise<void> {
@ -1165,7 +1158,6 @@ export class LocalCodexWorkspaceBackend implements CodexWorkspaceBackend {
"",
"**Delegation Backend**",
`Status: ${state.workspace?.toolsVersion === workspaceToolsVersion ? "privileged workspace tools available to the main Codex operator thread" : "waiting for a tool-enabled main Codex operator thread"}.`,
`Flow backend: \`${this.config.flowBackendUrl ?? "not configured"}\``,
"",
"**Workbench**",
workbench
@ -1289,12 +1281,6 @@ export class LocalCodexWorkspaceBackend implements CodexWorkspaceBackend {
groups: this.#delegationGroups(),
};
}
if (tool === "list_flow_runs") {
return await this.#listFlowRuns(args);
}
if (tool === "list_flow_events") {
return await this.#listFlowEvents(args);
}
throw new Error(`Unknown workspace tool: ${tool}`);
}
@ -3024,40 +3010,6 @@ export class LocalCodexWorkspaceBackend implements CodexWorkspaceBackend {
return true;
}
async #listFlowRuns(args: Record<string, unknown>): Promise<unknown> {
const result = await this.#requireFlowBackendClient().listRuns({
eventId: stringValue(args.eventId),
status: stringValue(args.status),
limit: positiveIntegerValue(args.limit),
});
return {
...(result.eventId ? { eventId: result.eventId } : {}),
runs: result.runs,
};
}
async #listFlowEvents(args: Record<string, unknown>): Promise<unknown> {
const result = await this.#requireFlowBackendClient().listEvents({
type: stringValue(args.type),
limit: positiveIntegerValue(args.limit),
});
return {
events: result.events,
};
}
#requireFlowBackendClient(): FlowBackendClient {
if (this.#flowBackendClient) {
return this.#flowBackendClient;
}
const baseUrl = this.config.flowBackendUrl;
if (!baseUrl) {
throw new Error("No flow backend URL configured.");
}
this.#flowBackendClient = createFlowBackendHttpClient({ baseUrl });
return this.#flowBackendClient;
}
#delegationForThread(threadId: string): DiscordWorkspaceDelegation | undefined {
return this.#workspaceDelegations().find((delegation) =>
delegation.codexThreadId === threadId
@ -3640,25 +3592,6 @@ function workspaceToolSpecs(): v2.DynamicToolSpec[] {
description: "List delegation groups and their terminal/active counts.",
inputSchema: objectSchema({}),
},
{
namespace: "codex_workspace",
name: "list_flow_runs",
description: "List runs from the configured workspace flow backend.",
inputSchema: objectSchema({
eventId: optionalStringSchema("Optional event id filter."),
status: optionalStringSchema("Optional run status filter."),
limit: optionalStringSchema("Optional max result count."),
}),
},
{
namespace: "codex_workspace",
name: "list_flow_events",
description: "List events from the configured workspace flow backend.",
inputSchema: objectSchema({
type: optionalStringSchema("Optional event type filter."),
limit: optionalStringSchema("Optional max result count."),
}),
},
];
}
@ -4104,17 +4037,6 @@ 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 compactId(value: string): string {
return value.length > 14 ? `${value.slice(0, 6)}...${value.slice(-6)}` : value;
}

View file

@ -11,7 +11,6 @@ export type DiscordBridgeConfig = {
allowedChannelIds: Set<string>;
statePath: string;
workspace?: DiscordWorkspaceConfig;
flowBackendUrl?: string;
cwd?: string;
model?: string;
modelProvider?: string;

View file

@ -5,7 +5,6 @@ import path from "node:path";
import { describe, expect, test } from "vite-plus/test";
import type { JsonRpcNotification, JsonRpcRequest } from "@peezy.tech/codex-flows/rpc";
import type { v2 } from "@peezy.tech/codex-flows/generated";
import type { FlowBackendClient } from "@peezy.tech/codex-flows/flow-runtime/backend-client";
import {
DiscordCodexBridge,
@ -187,10 +186,6 @@ describe("DiscordCodexBridge", () => {
namespace: "codex_workspace",
name: "start_delegation",
}),
expect.objectContaining({
namespace: "codex_workspace",
name: "list_flow_runs",
}),
]),
);
expect(client.setThreadNameCalls[0]).toEqual({
@ -316,79 +311,6 @@ describe("DiscordCodexBridge", () => {
await bridge.stop();
});
test("workspace flow inspection uses backend client and preserves tool payload shape", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const flowBackendClient = new FakeFlowBackendClient();
const bridge = new DiscordCodexBridge({
client,
transport,
store: new MemoryStateStore(),
config: testConfig({
workspace: { homeChannelId: "home-channel" },
}),
flowBackendClient,
});
await bridge.start();
await waitFor(() => bridge.stateForTest().sessions.length === 1);
client.emitRequest({
id: "tool-runs",
method: "item/tool/call",
params: {
threadId: "codex-thread-1",
namespace: "codex_workspace",
tool: "list_flow_runs",
arguments: {
eventId: "event-1",
status: "completed",
limit: "5",
},
},
});
client.emitRequest({
id: "tool-events",
method: "item/tool/call",
params: {
threadId: "codex-thread-1",
namespace: "codex_workspace",
tool: "list_flow_events",
arguments: {
type: "upstream.release",
limit: "3",
},
},
});
await waitFor(() => client.responses.length === 2);
expect(flowBackendClient.listRunsCalls).toEqual([
{ eventId: "event-1", status: "completed", limit: 5 },
]);
expect(flowBackendClient.listEventsCalls).toEqual([
{ type: "upstream.release", limit: 3 },
]);
expect(workspaceToolResult(client.responses[0]?.result)).toEqual({
eventId: "event-1",
runs: [
expect.objectContaining({
id: "run-1",
status: "blocked",
effectiveStatus: "blocked",
needsAttention: true,
}),
],
});
expect(workspaceToolResult(client.responses[1]?.result)).toEqual({
events: [
expect.objectContaining({
id: "event-1",
type: "upstream.release",
}),
],
});
await bridge.stop();
});
test("workspace workbench opens delegation task threads lazily from workspace posts", async () => {
const hookSpoolDir = await testHookSpoolDir();
const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "discord-workbench-root-"));
@ -4612,73 +4534,6 @@ function testThread(input: {
} as v2.Thread;
}
class FakeFlowBackendClient implements FlowBackendClient {
listRunsCalls: Array<{ eventId?: string; status?: string; limit?: number }> = [];
listEventsCalls: Array<{ type?: string; limit?: number }> = [];
async listRuns(options: { eventId?: string; status?: string; limit?: number } = {}) {
this.listRunsCalls.push(options);
return {
eventId: options.eventId,
runs: [
{
id: "run-1",
eventId: options.eventId,
flowName: "openai-codex-bindings",
stepName: "regenerate-bindings",
processStatus: "completed",
resultStatus: "blocked" as const,
status: "blocked",
effectiveStatus: "blocked",
needsAttention: true,
attemptCount: 1,
attempts: [],
output: [],
raw: {},
},
],
raw: {},
};
}
async getRun(): Promise<never> {
throw new Error("getRun should not be called by Discord flow inspection");
}
async listEvents(options: { type?: string; limit?: number } = {}) {
this.listEventsCalls.push(options);
return {
events: [
{
id: "event-1",
type: options.type,
receivedAt: "2026-05-15T00:00:00.000Z",
runIds: ["run-1"],
runs: [],
raw: {},
},
],
raw: {},
};
}
async getEvent(): Promise<never> {
throw new Error("getEvent should not be called by Discord flow inspection");
}
async dispatchEvent(): Promise<never> {
throw new Error("dispatchEvent should not be exposed through Discord in this increment");
}
async replayEvent(): Promise<never> {
throw new Error("replayEvent should not be exposed through Discord in this increment");
}
async cancelRun(): Promise<never> {
throw new Error("cancelRun should not be exposed through Discord in this increment");
}
}
class FakeCodexClient implements CodexBridgeClient {
startThreadCalls: v2.ThreadStartParams[] = [];
resumeThreadCalls: v2.ThreadResumeParams[] = [];

View file

@ -201,8 +201,6 @@ describe("parseConfig", () => {
"workspace-forum",
"--task-threads-channel-id",
"task-channel",
"--flow-backend-url",
"http://127.0.0.1:8089",
],
{},
);
@ -213,7 +211,6 @@ describe("parseConfig", () => {
CODEX_DISCORD_GATEWAY_MAIN_THREAD_ID: "env-thread",
CODEX_DISCORD_GATEWAY_WORKSPACE_FORUM_CHANNEL_ID: "env-workspace-forum",
CODEX_DISCORD_GATEWAY_TASK_THREADS_CHANNEL_ID: "env-task-channel",
CODEX_FLOW_BACKEND_URL: "http://127.0.0.1:8090",
},
);
@ -226,14 +223,12 @@ describe("parseConfig", () => {
workspaceForumChannelId: "workspace-forum",
taskThreadsChannelId: "task-channel",
});
expect(fromFlag.config.flowBackendUrl).toBe("http://127.0.0.1:8089");
expect(fromEnv.config.workspace).toEqual({
homeChannelId: "env-home",
mainThreadId: "env-thread",
workspaceForumChannelId: "env-workspace-forum",
taskThreadsChannelId: "env-task-channel",
});
expect(fromEnv.config.flowBackendUrl).toBe("http://127.0.0.1:8090");
}
});

View file

@ -19,13 +19,10 @@
"paths": {
"@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/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"],
"@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"],
"@peezy.tech/codex-flows/flow-runtime": ["../../packages/flow-runtime/src/index.ts"],
"@peezy.tech/codex-flows/flow-runtime/*": ["../../packages/flow-runtime/src/*"]
"@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"]
}
},
"include": ["src", "test"]

View file

@ -1,23 +0,0 @@
{
"name": "codex-flow-runner",
"version": "0.132.5",
"description": "CLI for listing, firing, and running Codex flow packages.",
"type": "module",
"private": true,
"license": "Apache-2.0",
"bin": {
"codex-flow-runner": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"check:types": "tsc --noEmit",
"test": "vp test run --root ../.. apps/flow-runner/test"
},
"dependencies": {
"@peezy.tech/codex-flows": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

View file

@ -1,224 +0,0 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import path from "node:path";
import {
discoverFlows,
createLocalFlowClient,
runFlowStep,
type FlowEvent,
type FlowRunRuntimeInput,
type LoadedFlow,
type FlowStep,
} from "@peezy.tech/codex-flows/flow-runtime";
type Cli =
| { kind: "help" }
| { kind: "list"; cwd: string }
| { kind: "fire"; cwd: string; eventPath: string }
| {
kind: "run";
cwd: string;
flow: string;
step: string;
eventPath: string;
runtime: FlowRunRuntimeInput;
};
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 = parseArgs(process.argv.slice(2));
if (cli.kind === "help") {
process.stdout.write(helpText());
return;
}
const flows = await discoverFlows({ cwd: cli.cwd });
if (cli.kind === "list") {
for (const flow of flows) {
process.stdout.write(`${flow.manifest.name}\t${flow.root}\n`);
}
return;
}
const event = await readEvent(cli.eventPath);
if (cli.kind === "fire") {
const client = createLocalFlowClient({
cwd: cli.cwd,
env: process.env,
});
const dispatch = await client.dispatchEvent(event);
const results = dispatch.runs.map((run) => {
if (!run.resultPayload && run.error) {
throw new Error(run.error);
}
return {
flow: run.flowName,
step: run.stepName,
result: run.resultPayload,
};
});
process.stdout.write(`${JSON.stringify({ eventId: event.id, results }, null, 2)}\n`);
return;
}
const flow = requireFlow(flows, cli.flow);
const step = requireStep(flow, cli.step);
const result = await runAndReport(flow, step, event, cli.runtime);
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}
async function runAndReport(
flow: LoadedFlow,
step: FlowStep,
event: FlowEvent,
runtime: FlowRunRuntimeInput,
): Promise<Record<string, unknown>> {
const result = await runFlowStep({
flow,
step,
event,
env: process.env,
runtime: {
eventId: event.id,
...runtime,
},
});
return {
flow: flow.manifest.name,
step: step.name,
result,
};
}
async function readEvent(eventPath: string): Promise<FlowEvent> {
const parsed = JSON.parse(await readFile(path.resolve(eventPath), "utf8")) as unknown;
if (!isRecord(parsed) || typeof parsed.id !== "string" || typeof parsed.type !== "string") {
throw new Error("event file must contain at least string id and type");
}
return {
receivedAt: new Date().toISOString(),
payload: {},
...parsed,
} as FlowEvent;
}
function requireFlow(flows: LoadedFlow[], name: string): LoadedFlow {
const flow = flows.find((entry) => entry.manifest.name === name);
if (!flow) {
throw new Error(`Unknown flow: ${name}`);
}
return flow;
}
function requireStep(flow: LoadedFlow, name: string): FlowStep {
const step = flow.manifest.steps.find((entry) => entry.name === name);
if (!step) {
throw new Error(`Unknown step ${name} in flow ${flow.manifest.name}`);
}
return step;
}
function parseArgs(argv: string[]): Cli {
let cwd = process.cwd();
const runtime: FlowRunRuntimeInput = {};
const args: string[] = [];
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg) {
continue;
}
if (arg === "--cwd") {
cwd = path.resolve(required(argv, ++index, arg));
continue;
}
if (arg.startsWith("--cwd=")) {
cwd = path.resolve(arg.slice("--cwd=".length));
continue;
}
if (arg === "--run-id") {
runtime.runId = required(argv, ++index, arg);
continue;
}
if (arg.startsWith("--run-id=")) {
runtime.runId = arg.slice("--run-id=".length);
continue;
}
if (arg === "--attempt-id") {
runtime.attemptId = required(argv, ++index, arg);
continue;
}
if (arg.startsWith("--attempt-id=")) {
runtime.attemptId = arg.slice("--attempt-id=".length);
continue;
}
if (arg === "--replay") {
runtime.replay = true;
continue;
}
if (arg === "--workspace-backend-url") {
runtime.workspaceBackendUrl = required(argv, ++index, arg);
continue;
}
if (arg.startsWith("--workspace-backend-url=")) {
runtime.workspaceBackendUrl = arg.slice("--workspace-backend-url=".length);
continue;
}
args.push(arg);
}
const command = args[0];
if (!command || command === "-h" || command === "--help" || command === "help") {
return { kind: "help" };
}
if (command === "list") {
return { kind: "list", cwd };
}
if (command === "fire") {
return { kind: "fire", cwd, eventPath: eventPathArg(args, 1) };
}
if (command === "run") {
const flow = args[1];
const step = args[2];
if (!flow || !step) {
throw new Error("run requires <flow> <step>");
}
return { kind: "run", cwd, flow, step, eventPath: eventPathArg(args, 3), runtime };
}
throw new Error(`Unknown command: ${command}`);
}
function eventPathArg(args: string[], start: number): string {
for (let index = start; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--event") {
return required(args, index + 1, "--event");
}
if (arg?.startsWith("--event=")) {
return arg.slice("--event=".length);
}
}
throw new Error("missing --event <path>");
}
function required(args: string[], index: number, flag: string): string {
const value = args[index];
if (!value) {
throw new Error(`${flag} requires a value`);
}
return value;
}
function helpText(): string {
return [
"Usage:",
" codex-flow-runner [--cwd <dir>] list",
" codex-flow-runner [--cwd <dir>] fire --event <event.json>",
" codex-flow-runner [--cwd <dir>] run <flow> <step> --event <event.json> [--run-id <id>] [--attempt-id <id>] [--replay] [--workspace-backend-url <ws-url>]",
"",
].join("\n");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -1,201 +0,0 @@
import { expect, test } from "vite-plus/test";
import { spawn } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const testDir = path.dirname(fileURLToPath(import.meta.url));
test("fire preserves the existing event/results payload shape", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "flow-runner-"));
try {
await writeFlow(directory);
const eventPath = path.join(directory, "event.json");
await writeFile(
eventPath,
JSON.stringify({
id: "event-1",
type: "demo.event",
receivedAt: "2026-05-15T00:00:00.000Z",
payload: { name: "Ada" },
}),
);
const runner = path.resolve(testDir, "..", "src", "index.ts");
const child = spawn(process.execPath, [
"--import",
import.meta.resolve("tsx"),
runner,
"--cwd",
directory,
"fire",
"--event",
eventPath,
], { stdio: ["ignore", "pipe", "pipe"] });
const [stdout, stderr, exitCode] = await Promise.all([
collectText(child.stdout),
collectText(child.stderr),
exitCodeFor(child),
]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
expect(JSON.parse(stdout)).toEqual({
eventId: "event-1",
results: [
{
flow: "demo",
step: "hello",
result: {
status: "completed",
message: "hello Ada",
},
},
],
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("run passes runtime metadata flags into Node step context", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "flow-runner-"));
try {
await writeFlow(directory, [
"export default async (context) => ({",
" status: 'completed',",
" artifacts: {",
" eventId: context.runtime.eventId,",
" runId: context.runtime.runId,",
" attemptId: context.runtime.attemptId,",
" replay: context.runtime.replay,",
" workspaceBackendUrl: context.runtime.workspaceBackendUrl,",
" },",
"});",
"",
].join("\n"));
const eventPath = path.join(directory, "event.json");
await writeFile(
eventPath,
JSON.stringify({
id: "event-1",
type: "demo.event",
receivedAt: "2026-05-15T00:00:00.000Z",
payload: { name: "Ada" },
}),
);
const runner = path.resolve(testDir, "..", "src", "index.ts");
const child = spawn(process.execPath, [
"--import",
import.meta.resolve("tsx"),
runner,
"--cwd",
directory,
"run",
"demo",
"hello",
"--event",
eventPath,
"--run-id",
"run_123",
"--attempt-id",
"attempt_1",
"--replay",
"--workspace-backend-url",
"ws://127.0.0.1:3586",
], { stdio: ["ignore", "pipe", "pipe"] });
const [stdout, stderr, exitCode] = await Promise.all([
collectText(child.stdout),
collectText(child.stderr),
exitCodeFor(child),
]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
expect(JSON.parse(stdout)).toEqual({
flow: "demo",
step: "hello",
result: {
status: "completed",
artifacts: {
eventId: "event-1",
runId: "run_123",
attemptId: "attempt_1",
replay: true,
workspaceBackendUrl: "ws://127.0.0.1:3586",
},
},
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
async function writeFlow(root: string, script?: string): Promise<void> {
const flowRoot = path.join(root, "flows/demo");
await mkdir(path.join(flowRoot, "exec"), { recursive: true });
await mkdir(path.join(flowRoot, "schemas"), { recursive: true });
await writeFile(
path.join(flowRoot, "flow.toml"),
[
'name = "demo"',
"version = 1",
'description = "demo"',
"",
"[[steps]]",
'name = "hello"',
'runner = "node"',
'script = "exec/hello.ts"',
"timeout_ms = 30000",
"",
"[steps.trigger]",
'type = "demo.event"',
'schema = "schemas/demo-event.schema.json"',
"",
].join("\n"),
);
await writeFile(
path.join(flowRoot, "schemas/demo-event.schema.json"),
JSON.stringify({
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
},
}),
);
await writeFile(
path.join(flowRoot, "exec/hello.ts"),
script ?? [
"async function main() {",
" const chunks = [];",
" for await (const chunk of process.stdin) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);",
" const context = JSON.parse(Buffer.concat(chunks).toString('utf8'));",
" const name = context.flow.event.payload.name;",
" console.log(`FLOW_RESULT ${JSON.stringify({ status: 'completed', message: `hello ${name}` })}`);",
"}",
"void main();",
"",
].join("\n"),
);
}
async function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
let output = "";
if (!stream) {
return output;
}
for await (const chunk of stream) {
output += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
}
return output;
}
function exitCodeFor(child: ReturnType<typeof spawn>): Promise<number | null> {
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => resolve(code));
});
}

View file

@ -1,28 +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"],
"baseUrl": ".",
"paths": {
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy.tech/codex-flows/flows": ["../../packages/codex-client/src/app-server/flows.ts"],
"@peezy.tech/codex-flows/flow-runtime": ["../../packages/flow-runtime/src/index.ts"],
"@peezy.tech/codex-flows/flow-runtime/*": ["../../packages/flow-runtime/src/*"],
"@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"]
}
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}

View file

@ -24,7 +24,6 @@
"@/*": ["./src/*"],
"@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/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/*"],

View file

@ -10,7 +10,6 @@
"@/*": ["./src/*"],
"@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/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/*"],

View file

@ -1,211 +0,0 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import {
discoverFlows,
matchingSteps,
type FlowEvent,
type FlowStep,
type LoadedFlow,
} from "@peezy.tech/codex-flows/flow-runtime";
import type { FlowBackendConfig } from "./config.ts";
import {
executeCommand,
flowCommand,
flowRunExecutionEnv,
parseRunnerResult,
} from "./executor.ts";
import { FlowBackendStore, type FlowRunRecord } from "./store.ts";
export type DispatchFlowEventOptions = {
config: FlowBackendConfig;
store: FlowBackendStore;
event: FlowEvent;
wait?: boolean;
env?: Record<string, string | undefined>;
replay?: boolean;
};
export type DispatchFlowEventResult = {
status: "accepted" | "duplicate";
eventId: string;
runIds: string[];
matched: number;
};
export async function dispatchFlowEvent(options: DispatchFlowEventOptions): Promise<DispatchFlowEventResult> {
const inserted = options.replay ? false : options.store.insertEvent(options.event);
if (!inserted && !options.replay) {
return {
status: "duplicate",
eventId: options.event.id,
runIds: options.store.listRunsByEvent(options.event.id).map((run) => run.id),
matched: 0,
};
}
const eventPath = await writeEventFile(options.config.dataDir, options.event, options.replay ? "replay" : undefined);
const flows = await discoverFlows({ cwd: options.config.cwd });
const matches = await matchingSteps(flows, options.event);
const promises: Array<Promise<void>> = [];
const replayNonce = options.replay ? `${Date.now()}:${Math.random()}` : undefined;
for (const match of matches) {
const run = createRunRecord(options.config, options.event, match.flow, match.step, eventPath, replayNonce);
options.store.createRun(run);
const promise = executeAndRecord({
config: options.config,
store: options.store,
run,
env: options.env,
});
if (options.wait) {
promises.push(promise);
} else {
promise.catch((error) => {
options.store.markRunCompleted(run.id, {
status: "failed",
stdout: "",
stderr: "",
error: error instanceof Error ? error.message : String(error),
});
});
}
}
if (promises.length > 0) {
await Promise.all(promises);
}
return {
status: "accepted",
eventId: options.event.id,
runIds: matches.map((match) => runId(options.event.id, match.flow.manifest.name, match.step.name, replayNonce)),
matched: matches.length,
};
}
export async function replayFlowEvent(options: Omit<DispatchFlowEventOptions, "event" | "replay"> & {
eventId: string;
}): Promise<DispatchFlowEventResult> {
const event = options.store.getEvent(options.eventId);
if (!event) {
throw new Error(`Unknown event: ${options.eventId}`);
}
return dispatchFlowEvent({
...options,
event: event.raw,
replay: true,
});
}
export async function readFlowEvent(pathValue: string): Promise<FlowEvent> {
return normalizeFlowEvent(JSON.parse(await readFile(path.resolve(pathValue), "utf8")) as unknown);
}
export function normalizeFlowEvent(value: unknown): FlowEvent {
if (!isRecord(value) || typeof value.id !== "string" || typeof value.type !== "string") {
throw new Error("FlowEvent requires string id and type");
}
return {
receivedAt: typeof value.receivedAt === "string" ? value.receivedAt : new Date().toISOString(),
payload: isRecord(value.payload) ? value.payload : {},
...value,
} as FlowEvent;
}
async function executeAndRecord(options: {
config: FlowBackendConfig;
store: FlowBackendStore;
run: FlowRunRecord;
env?: Record<string, string | undefined>;
}): Promise<void> {
const command = flowCommand({
config: options.config,
runId: options.run.id,
eventId: options.run.eventId,
eventPath: options.run.eventPath,
flowName: options.run.flowName,
stepName: options.run.stepName,
attemptId: options.run.id,
replay: options.run.id.endsWith("_replay"),
workspaceBackendUrl: options.config.workspaceBackendUrl,
env: options.env,
});
options.store.markRunRunning(options.run.id, JSON.stringify(command), command.unit);
let result: Awaited<ReturnType<typeof executeCommand>>;
try {
result = await executeCommand(command, options.config, flowRunExecutionEnv({
config: options.config,
runId: options.run.id,
eventId: options.run.eventId,
eventPath: options.run.eventPath,
flowName: options.run.flowName,
stepName: options.run.stepName,
attemptId: options.run.id,
replay: options.run.id.endsWith("_replay"),
workspaceBackendUrl: options.config.workspaceBackendUrl,
env: options.env,
}));
} catch (error) {
options.store.markRunCompleted(options.run.id, {
status: "failed",
stdout: "",
stderr: "",
error: error instanceof Error ? error.message : String(error),
});
return;
}
const status = result.exitCode === 0 ? "completed" : "failed";
options.store.markRunCompleted(options.run.id, {
status,
resultJson: parseRunnerResult(result.stdout),
stdout: result.stdout,
stderr: result.stderr,
...(status === "failed" ? { error: `flow runner exited with ${result.exitCode ?? "unknown"}` } : {}),
});
}
function createRunRecord(
config: FlowBackendConfig,
event: FlowEvent,
flow: LoadedFlow,
step: FlowStep,
eventPath: string,
replayNonce?: string,
): FlowRunRecord {
return {
id: runId(event.id, flow.manifest.name, step.name, replayNonce),
eventId: event.id,
flowName: flow.manifest.name,
stepName: step.name,
status: "queued",
backend: "workspace-local",
executor: config.executor,
eventPath,
createdAt: new Date().toISOString(),
};
}
function runId(eventId: string, flowName: string, stepName: string, replayNonce?: string): string {
const hash = createHash("sha256")
.update(`${eventId}\0${flowName}\0${stepName}${replayNonce ? `\0${replayNonce}` : ""}`)
.digest("hex")
.slice(0, 12);
return replayNonce ? `run_${hash}_replay` : `run_${hash}`;
}
async function writeEventFile(dataDir: string, event: FlowEvent, suffix?: string): Promise<string> {
const directory = path.join(dataDir, "events");
await mkdir(directory, { recursive: true });
const filePath = path.join(directory, `${safeFileName(suffix ? `${event.id}:${suffix}:${Date.now()}` : event.id)}.json`);
await writeFile(filePath, `${JSON.stringify(event, null, 2)}\n`);
return filePath;
}
function safeFileName(value: string): string {
const hash = createHash("sha256").update(value).digest("hex").slice(0, 12);
const base = value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
return `${base || "event"}-${hash}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -1,315 +0,0 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
export type FlowBackendExecutor = "direct" | "systemd-run";
export type FlowBackendConfig = {
cwd: string;
dataDir: string;
host: string;
port: number;
secret?: string;
executor: FlowBackendExecutor;
nodeCommand: string;
flowRunnerPath: string;
forwardEnv: string[];
workspaceBackendUrl?: string;
};
export type FlowBackendCli =
| { kind: "help" }
| { kind: "serve"; config: FlowBackendConfig }
| { kind: "dispatch"; config: FlowBackendConfig; eventPath: string; wait: boolean }
| { kind: "list-events"; config: FlowBackendConfig; limit?: number; type?: string }
| { kind: "show-event"; config: FlowBackendConfig; eventId: string }
| { kind: "replay-event"; config: FlowBackendConfig; eventId: string; wait: boolean }
| { kind: "list-runs"; config: FlowBackendConfig; eventId?: string; status?: string; limit?: number }
| { kind: "show-run"; config: FlowBackendConfig; runId: string };
export function readConfig(
env: Record<string, string | undefined> = process.env,
overrides: Partial<FlowBackendConfig> = {},
): FlowBackendConfig {
const cwd = path.resolve(overrides.cwd ?? env.CODEX_FLOW_BACKEND_CWD ?? process.cwd());
const dataDir = path.resolve(overrides.dataDir ?? env.CODEX_FLOW_BACKEND_DATA_DIR ?? path.join(cwd, ".codex", "flow-backend"));
return {
cwd,
dataDir,
host: overrides.host ?? env.CODEX_FLOW_BACKEND_HOST ?? "127.0.0.1",
port: overrides.port ?? numberEnv(env.CODEX_FLOW_BACKEND_PORT, 7345),
...(overrides.secret ?? env.CODEX_FLOW_BACKEND_SECRET
? { secret: overrides.secret ?? env.CODEX_FLOW_BACKEND_SECRET }
: {}),
executor: overrides.executor ?? executorEnv(env.CODEX_FLOW_BACKEND_EXECUTOR),
nodeCommand: overrides.nodeCommand ?? env.CODEX_FLOW_BACKEND_NODE ?? process.execPath,
flowRunnerPath: path.resolve(
overrides.flowRunnerPath ?? env.CODEX_FLOW_RUNNER_PATH ?? defaultFlowRunnerPath(),
),
forwardEnv: overrides.forwardEnv ?? forwardEnv(env.CODEX_FLOW_BACKEND_FORWARD_ENV),
...(overrides.workspaceBackendUrl ?? env.CODEX_WORKSPACE_BACKEND_WS_URL
? { workspaceBackendUrl: overrides.workspaceBackendUrl ?? env.CODEX_WORKSPACE_BACKEND_WS_URL }
: {}),
};
}
export function parseCli(argv: string[], env: Record<string, string | undefined> = process.env): FlowBackendCli {
const command = argv[0];
if (!command || command === "help" || command === "-h" || command === "--help") {
return { kind: "help" };
}
let cwd: string | undefined;
let dataDir: string | undefined;
let host: string | undefined;
let port: number | undefined;
let secret: string | undefined;
let executor: FlowBackendExecutor | undefined;
let nodeCommand: string | undefined;
let flowRunnerPath: string | undefined;
let workspaceBackendUrl: string | undefined;
let wait = false;
let eventPath: string | undefined;
let eventId: string | undefined;
let runId: string | undefined;
let status: string | undefined;
let limit: number | undefined;
let type: string | undefined;
const rest = argv.slice(1);
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index];
if (!arg) {
continue;
}
if (arg === "--cwd") {
cwd = required(rest, ++index, arg);
continue;
}
if (arg === "--data-dir") {
dataDir = required(rest, ++index, arg);
continue;
}
if (arg === "--host") {
host = required(rest, ++index, arg);
continue;
}
if (arg === "--port") {
port = Number(required(rest, ++index, arg));
continue;
}
if (arg === "--secret") {
secret = required(rest, ++index, arg);
continue;
}
if (arg === "--executor") {
executor = executorEnv(required(rest, ++index, arg));
continue;
}
if (arg === "--node") {
nodeCommand = required(rest, ++index, arg);
continue;
}
if (arg === "--flow-runner") {
flowRunnerPath = required(rest, ++index, arg);
continue;
}
if (arg === "--workspace-backend-url") {
workspaceBackendUrl = required(rest, ++index, arg);
continue;
}
if (arg.startsWith("--workspace-backend-url=")) {
workspaceBackendUrl = arg.slice("--workspace-backend-url=".length);
continue;
}
if (arg === "--event") {
eventPath = required(rest, ++index, arg);
continue;
}
if (arg === "--wait") {
wait = true;
continue;
}
if (arg === "--event-id") {
eventId = required(rest, ++index, arg);
continue;
}
if (arg.startsWith("--event-id=")) {
eventId = arg.slice("--event-id=".length);
continue;
}
if (arg === "--run-id") {
runId = required(rest, ++index, arg);
continue;
}
if (arg.startsWith("--run-id=")) {
runId = arg.slice("--run-id=".length);
continue;
}
if (arg === "--status") {
status = required(rest, ++index, arg);
continue;
}
if (arg.startsWith("--status=")) {
status = arg.slice("--status=".length);
continue;
}
if (arg === "--limit") {
limit = Number(required(rest, ++index, arg));
continue;
}
if (arg.startsWith("--limit=")) {
limit = Number(arg.slice("--limit=".length));
continue;
}
if (arg === "--type") {
type = required(rest, ++index, arg);
continue;
}
if (arg.startsWith("--type=")) {
type = arg.slice("--type=".length);
continue;
}
if (!arg.startsWith("-") && !eventId && (command === "show-event" || command === "replay-event")) {
eventId = arg;
continue;
}
if (!arg.startsWith("-") && !runId && command === "show-run") {
runId = arg;
continue;
}
throw new Error(`Unknown option: ${arg}`);
}
const config = readConfig(env, {
...(cwd ? { cwd } : {}),
...(dataDir ? { dataDir } : {}),
...(host ? { host } : {}),
...(port !== undefined ? { port } : {}),
...(secret ? { secret } : {}),
...(executor ? { executor } : {}),
...(nodeCommand ? { nodeCommand } : {}),
...(flowRunnerPath ? { flowRunnerPath } : {}),
...(workspaceBackendUrl ? { workspaceBackendUrl } : {}),
});
if (command === "serve") {
return { kind: "serve", config };
}
if (command === "dispatch") {
if (!eventPath) {
throw new Error("dispatch requires --event <path>");
}
return { kind: "dispatch", config, eventPath, wait };
}
if (command === "list-events" || command === "events") {
return { kind: "list-events", config, limit, type };
}
if (command === "show-event" || command === "event") {
return { kind: "show-event", config, eventId: requireValue(eventId, "show-event requires <event-id>") };
}
if (command === "replay-event" || command === "replay") {
return { kind: "replay-event", config, eventId: requireValue(eventId, "replay-event requires <event-id>"), wait };
}
if (command === "list-runs" || command === "runs") {
return { kind: "list-runs", config, eventId, status, limit };
}
if (command === "show-run" || command === "run") {
return { kind: "show-run", config, runId: requireValue(runId, "show-run requires <run-id>") };
}
throw new Error(`Unknown command: ${command}`);
}
export function defaultFlowRunnerPath(): string {
return path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"..",
"flow-runner",
"src",
"index.ts",
);
}
export function helpText(): string {
return [
"Usage:",
" 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",
" CODEX_FLOW_BACKEND_EXECUTOR direct or systemd-run",
" CODEX_WORKSPACE_BACKEND_WS_URL Workspace backend WebSocket URL passed to flow steps",
" CODEX_FLOW_PUSH/PUBLISH Optional release-flow action gates",
"",
].join("\n");
}
function numberEnv(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function executorEnv(value: string | undefined): FlowBackendExecutor {
if (value === "systemd-run") {
return "systemd-run";
}
if (!value || value === "direct") {
return "direct";
}
throw new Error("executor must be direct or systemd-run");
}
function forwardEnv(value: string | undefined): string[] {
const defaults = [
"CODEX_FLOW_COMMIT",
"CODEX_FLOW_PUSH",
"CODEX_FLOW_PUBLISH",
"CODEX_FLOW_FORCE",
"CODEX_FLOW_SQUASH_PATCH_STACK",
"CODEX_APP_SERVER_CODEX_COMMAND",
"CODEX_APP_SERVER_CODEX_PACKAGE",
"CODEX_APP_SERVER_DLX_COMMAND",
"CODEX_FLOW_ATTEMPT_ID",
"CODEX_HOME",
"CODEX_FLOW_EVENT_ID",
"PEEZY_CODEX_REPO",
"CODEX_FLOW_LAUNCHED_BY",
"CODEX_FLOW_REPLAY",
"CODEX_FLOW_RUN_ID",
"CODEX_WORKSPACE_BACKEND_WS_URL",
"PEEZY_CODEX_TARGET_BRANCH",
"PEEZY_CODEX_CARGO_TARGET_DIR",
"HOME",
"PATH",
];
if (!value) {
return defaults;
}
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
function required(args: string[], index: number, flag: string): string {
const value = args[index];
if (!value) {
throw new Error(`${flag} requires a value`);
}
return value;
}
function requireValue<T>(value: T | undefined, message: string): T {
if (value === undefined) {
throw new Error(message);
}
return value;
}

View file

@ -1,177 +0,0 @@
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import type { FlowBackendConfig } from "./config.ts";
const flowRuntimeEnvNames = [
"CODEX_FLOW_EVENT_ID",
"CODEX_FLOW_RUN_ID",
"CODEX_FLOW_ATTEMPT_ID",
"CODEX_FLOW_REPLAY",
"CODEX_WORKSPACE_BACKEND_WS_URL",
"CODEX_FLOW_LAUNCHED_BY",
];
export type FlowCommandSpec = {
command: string;
args: string[];
unit?: string;
};
export type ExecuteFlowRunOptions = {
config: FlowBackendConfig;
runId: string;
eventId: string;
eventPath: string;
flowName: string;
stepName: string;
attemptId?: string;
replay?: boolean;
workspaceBackendUrl?: string;
env?: Record<string, string | undefined>;
};
export type ExecuteFlowRunResult = {
command: FlowCommandSpec;
exitCode: number | null;
stdout: string;
stderr: string;
};
export async function executeFlowRun(options: ExecuteFlowRunOptions): Promise<ExecuteFlowRunResult> {
const command = flowCommand(options);
const result = await executeCommand(command, options.config, flowRunExecutionEnv(options));
return { command, ...result };
}
export async function executeCommand(
command: FlowCommandSpec,
config: FlowBackendConfig,
env: Record<string, string | undefined> = process.env,
): Promise<Omit<ExecuteFlowRunResult, "command">> {
const child = spawn(command.command, command.args, {
cwd: config.cwd,
env: forwardedEnv(config, env),
});
const [stdout, stderr, exitCode] = await Promise.all([
collectText(child.stdout),
collectText(child.stderr),
exitCodeFor(child),
]);
return { exitCode, stdout, stderr };
}
export function flowCommand(options: ExecuteFlowRunOptions): FlowCommandSpec {
const runnerArgs = [
options.config.flowRunnerPath,
"--cwd",
options.config.cwd,
"run",
options.flowName,
options.stepName,
"--event",
options.eventPath,
"--run-id",
options.runId,
"--attempt-id",
options.attemptId ?? options.runId,
];
const workspaceBackendUrl = options.workspaceBackendUrl ?? options.config.workspaceBackendUrl;
if (workspaceBackendUrl) {
runnerArgs.push("--workspace-backend-url", workspaceBackendUrl);
}
if (options.replay) {
runnerArgs.push("--replay");
}
if (options.config.executor === "direct") {
return { command: options.config.nodeCommand, args: runnerArgs };
}
const unit = `codex-flow-${safeUnit(options.runId)}`;
return {
command: "systemd-run",
unit,
args: [
"--user",
"--collect",
"--wait",
`--unit=${unit}`,
`--working-directory=${options.config.cwd}`,
...systemdSetEnvArgs(options.config, flowRunExecutionEnv(options)),
options.config.nodeCommand,
...runnerArgs,
],
};
}
export function parseRunnerResult(stdout: string): string | undefined {
const trimmed = stdout.trim();
if (!trimmed.startsWith("{")) {
return undefined;
}
try {
return JSON.stringify(JSON.parse(trimmed));
} catch {
return undefined;
}
}
function forwardedEnv(config: FlowBackendConfig, env: Record<string, string | undefined>): Record<string, string> {
const next: Record<string, string> = {};
const source: Record<string, string | undefined> = { ...process.env, ...env };
for (const name of config.forwardEnv) {
const value = source[name];
if (value !== undefined) {
next[name] = value;
}
}
for (const name of flowRuntimeEnvNames) {
const value = source[name];
if (value !== undefined) {
next[name] = value;
}
}
return next;
}
export function flowRunExecutionEnv(options: ExecuteFlowRunOptions): Record<string, string | undefined> {
return {
...(options.env ?? process.env),
CODEX_FLOW_EVENT_ID: options.eventId,
CODEX_FLOW_RUN_ID: options.runId,
CODEX_FLOW_ATTEMPT_ID: options.attemptId ?? options.runId,
CODEX_FLOW_REPLAY: options.replay ? "1" : "0",
CODEX_WORKSPACE_BACKEND_WS_URL: options.workspaceBackendUrl ?? options.config.workspaceBackendUrl,
CODEX_FLOW_LAUNCHED_BY: "codex-workspace-backend-local",
};
}
function systemdSetEnvArgs(config: FlowBackendConfig, env: Record<string, string | undefined>): string[] {
return Object.entries(forwardedEnv(config, env)).map(([key, value]) => `--setenv=${key}=${value}`);
}
function safeUnit(value: string): string {
const hash = createHash("sha256").update(value).digest("hex").slice(0, 10);
return `${value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48)}-${hash}`;
}
function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
return new Promise((resolve, reject) => {
let output = "";
if (!stream) {
resolve(output);
return;
}
stream.setEncoding("utf8");
stream.on("data", (chunk: string) => {
output += chunk;
});
stream.once("error", reject);
stream.once("end", () => resolve(output));
});
}
function exitCodeFor(child: ReturnType<typeof spawn>): Promise<number | null> {
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => resolve(code));
});
}

View file

@ -1,263 +0,0 @@
import http from "node:http";
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): http.Server {
const flow = new WorkspaceFlowCapability({ config });
const server = http.createServer((request, response) => {
void handleNodeHttpRequest(request, response, config, async (webRequest) =>
await flow.handleHttp(webRequest) ?? json({ error: "not found" }, 404),
);
});
server.listen(config.port, config.host);
server.once("close", () => flow.close());
return server;
}
export async function handleNodeHttpRequest(
request: http.IncomingMessage,
response: http.ServerResponse,
config: Pick<FlowBackendConfig, "host" | "port">,
handler: (request: Request) => Promise<Response>,
): Promise<void> {
try {
const webResponse = await handler(await toWebRequest(request, config));
await writeWebResponse(response, webResponse);
} catch (error) {
await writeWebResponse(response, json({
error: error instanceof Error ? error.message : String(error),
}, 500));
}
}
async function toWebRequest(
request: http.IncomingMessage,
config: Pick<FlowBackendConfig, "host" | "port">,
): Promise<Request> {
const host = request.headers.host ?? `${config.host}:${config.port}`;
const url = new URL(request.url ?? "/", `http://${host}`);
const body = request.method === "GET" || request.method === "HEAD"
? undefined
: await collectBody(request);
return new Request(url, {
method: request.method,
headers: nodeHeaders(request.headers),
body,
});
}
async function writeWebResponse(
response: http.ServerResponse,
webResponse: Response,
): Promise<void> {
response.statusCode = webResponse.status;
for (const [name, value] of webResponse.headers) {
response.setHeader(name, value);
}
const body = Buffer.from(await webResponse.arrayBuffer());
response.end(body);
}
function nodeHeaders(headers: http.IncomingHttpHeaders): Headers {
const result = new Headers();
for (const [name, value] of Object.entries(headers)) {
if (Array.isArray(value)) {
for (const entry of value) {
result.append(name, entry);
}
} else if (value !== undefined) {
result.set(name, value);
}
}
return result;
}
async function collectBody(request: http.IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
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);
}

View file

@ -1,18 +0,0 @@
import { createHmac, timingSafeEqual } from "node:crypto";
export function signBody(secret: string, body: string): string {
return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
}
export function verifyBodySignature(secret: string, body: string, signature: string | null): boolean {
if (!signature?.startsWith("sha256=")) {
return false;
}
const expected = Buffer.from(signBody(secret, body));
const actual = Buffer.from(signature);
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
export function requestSignature(headers: Headers): string | null {
return headers.get("x-flow-signature-256") ?? headers.get("x-patch-flow-signature-256");
}

View file

@ -1,293 +0,0 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import type { FlowEvent } from "@peezy.tech/codex-flows/flow-runtime";
export type FlowRunStatus = "queued" | "running" | "completed" | "failed";
export type FlowRunRecord = {
id: string;
eventId: string;
flowName: string;
stepName: string;
status: FlowRunStatus;
backend: "workspace-local";
executor: string;
unit?: string;
eventPath: string;
commandJson?: string;
resultJson?: string;
stdout?: string;
stderr?: string;
error?: string;
createdAt: string;
startedAt?: string;
completedAt?: string;
};
export type FlowEventRecord = {
id: string;
type: string;
source?: string;
occurredAt?: string;
receivedAt: string;
payload: Record<string, unknown>;
raw: FlowEvent;
createdAt: string;
};
export type ListRunsOptions = {
eventId?: string;
status?: FlowRunStatus;
limit?: number;
};
export type ListEventsOptions = {
type?: string;
limit?: number;
};
export class FlowBackendStore {
readonly dbPath: string;
#db: DatabaseSync;
constructor(dbPath: string) {
this.dbPath = dbPath;
mkdirSync(path.dirname(dbPath), { recursive: true });
this.#db = new DatabaseSync(dbPath);
this.#db.exec(`
create table if not exists flow_events (
id text primary key,
type text not null,
source text,
occurred_at text,
received_at text not null,
payload_json text not null,
raw_json text not null,
created_at text not null
);
create table if not exists flow_runs (
id text primary key,
event_id text not null,
flow_name text not null,
step_name text not null,
status text not null,
backend text not null,
executor text not null,
unit text,
event_path text not null,
command_json text,
result_json text,
stdout text,
stderr text,
error text,
created_at text not null,
started_at text,
completed_at text
);
create index if not exists flow_runs_event_id_idx on flow_runs(event_id);
create index if not exists flow_runs_status_idx on flow_runs(status);
create index if not exists flow_events_type_idx on flow_events(type);
`);
}
insertEvent(event: FlowEvent): boolean {
const result = this.#db
.prepare(
`insert or ignore into flow_events
(id, type, source, occurred_at, received_at, payload_json, raw_json, created_at)
values ($id, $type, $source, $occurredAt, $receivedAt, $payloadJson, $rawJson, $createdAt)`,
)
.run({
$id: event.id,
$type: event.type,
$source: event.source ?? null,
$occurredAt: event.occurredAt ?? null,
$receivedAt: event.receivedAt,
$payloadJson: JSON.stringify(event.payload),
$rawJson: JSON.stringify(event),
$createdAt: new Date().toISOString(),
});
return result.changes > 0;
}
createRun(record: FlowRunRecord): void {
this.#db
.prepare(
`insert into flow_runs
(id, event_id, flow_name, step_name, status, backend, executor, unit, event_path,
command_json, result_json, stdout, stderr, error, created_at, started_at, completed_at)
values
($id, $eventId, $flowName, $stepName, $status, $backend, $executor, $unit, $eventPath,
$commandJson, $resultJson, $stdout, $stderr, $error, $createdAt, $startedAt, $completedAt)`,
)
.run(runParams(record));
}
markRunRunning(id: string, commandJson: string, unit?: string): void {
this.#db
.prepare(
`update flow_runs
set status = 'running', started_at = $startedAt, command_json = $commandJson, unit = $unit
where id = $id`,
)
.run({
$id: id,
$startedAt: new Date().toISOString(),
$commandJson: commandJson,
$unit: unit ?? null,
});
}
markRunCompleted(id: string, values: { status: FlowRunStatus; resultJson?: string; stdout: string; stderr: string; error?: string }): void {
this.#db
.prepare(
`update flow_runs
set status = $status, completed_at = $completedAt, result_json = $resultJson,
stdout = $stdout, stderr = $stderr, error = $error
where id = $id`,
)
.run({
$id: id,
$status: values.status,
$completedAt: new Date().toISOString(),
$resultJson: values.resultJson ?? null,
$stdout: values.stdout,
$stderr: values.stderr,
$error: values.error ?? null,
});
}
listRunsByEvent(eventId: string): FlowRunRecord[] {
return this.listRuns({ eventId, limit: 1_000 });
}
listRuns(options: ListRunsOptions = {}): FlowRunRecord[] {
const clauses: string[] = [];
const params: Record<string, string | number> = {
$limit: clampLimit(options.limit),
};
if (options.eventId) {
clauses.push("event_id = $eventId");
params.$eventId = options.eventId;
}
if (options.status) {
clauses.push("status = $status");
params.$status = options.status;
}
const where = clauses.length > 0 ? `where ${clauses.join(" and ")}` : "";
return this.#db
.prepare(`select * from flow_runs ${where} order by created_at desc, id desc limit $limit`)
.all(params)
.map(rowToRunRecord);
}
getRun(id: string): FlowRunRecord | undefined {
const row = this.#db.prepare("select * from flow_runs where id = $id").get({ $id: id });
return row ? rowToRunRecord(row) : undefined;
}
listEvents(options: ListEventsOptions = {}): FlowEventRecord[] {
const clauses: string[] = [];
const params: Record<string, string | number> = {
$limit: clampLimit(options.limit),
};
if (options.type) {
clauses.push("type = $type");
params.$type = options.type;
}
const where = clauses.length > 0 ? `where ${clauses.join(" and ")}` : "";
return this.#db
.prepare(`select * from flow_events ${where} order by created_at desc, id desc limit $limit`)
.all(params)
.map(rowToEventRecord);
}
getEvent(id: string): FlowEventRecord | undefined {
const row = this.#db.prepare("select * from flow_events where id = $id").get({ $id: id });
return row ? rowToEventRecord(row) : undefined;
}
close(): void {
this.#db.close();
}
}
function clampLimit(value: number | undefined): number {
if (!value || !Number.isFinite(value)) {
return 50;
}
return Math.max(1, Math.min(500, Math.trunc(value)));
}
function runParams(record: FlowRunRecord): Record<string, string | null> {
return {
$id: record.id,
$eventId: record.eventId,
$flowName: record.flowName,
$stepName: record.stepName,
$status: record.status,
$backend: record.backend,
$executor: record.executor,
$unit: record.unit ?? null,
$eventPath: record.eventPath,
$commandJson: record.commandJson ?? null,
$resultJson: record.resultJson ?? null,
$stdout: record.stdout ?? null,
$stderr: record.stderr ?? null,
$error: record.error ?? null,
$createdAt: record.createdAt,
$startedAt: record.startedAt ?? null,
$completedAt: record.completedAt ?? null,
};
}
function rowToRunRecord(row: unknown): FlowRunRecord {
if (!isRecord(row)) {
throw new Error("invalid run row");
}
return {
id: String(row.id),
eventId: String(row.event_id),
flowName: String(row.flow_name),
stepName: String(row.step_name),
status: String(row.status) as FlowRunStatus,
backend: "workspace-local",
executor: String(row.executor),
...(typeof row.unit === "string" ? { unit: row.unit } : {}),
eventPath: String(row.event_path),
...(typeof row.command_json === "string" ? { commandJson: row.command_json } : {}),
...(typeof row.result_json === "string" ? { resultJson: row.result_json } : {}),
...(typeof row.stdout === "string" ? { stdout: row.stdout } : {}),
...(typeof row.stderr === "string" ? { stderr: row.stderr } : {}),
...(typeof row.error === "string" ? { error: row.error } : {}),
createdAt: String(row.created_at),
...(typeof row.started_at === "string" ? { startedAt: row.started_at } : {}),
...(typeof row.completed_at === "string" ? { completedAt: row.completed_at } : {}),
};
}
function rowToEventRecord(row: unknown): FlowEventRecord {
if (!isRecord(row)) {
throw new Error("invalid event row");
}
const payload = JSON.parse(String(row.payload_json)) as unknown;
const raw = JSON.parse(String(row.raw_json)) as unknown;
if (!isRecord(payload) || !isRecord(raw)) {
throw new Error("invalid event json");
}
return {
id: String(row.id),
type: String(row.type),
...(typeof row.source === "string" ? { source: row.source } : {}),
...(typeof row.occurred_at === "string" ? { occurredAt: row.occurred_at } : {}),
receivedAt: String(row.received_at),
payload,
raw: raw as FlowEvent,
createdAt: String(row.created_at),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -1,231 +0,0 @@
import { expect, test } from "vite-plus/test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
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" });
const signature = signBody("secret", body);
expect(verifyBodySignature("secret", body, signature)).toBe(true);
expect(verifyBodySignature("secret", `${body}\n`, signature)).toBe(false);
});
test("reads generic and Patch dispatch signatures", () => {
expect(requestSignature(new Headers({ "x-flow-signature-256": "sha256=generic" }))).toBe("sha256=generic");
expect(requestSignature(new Headers({ "x-patch-flow-signature-256": "sha256=patch" }))).toBe("sha256=patch");
});
test("dispatches matching flow steps and records runs", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "flow-backend-"));
try {
await writeFlow(directory);
const config = readConfig(
{},
{
cwd: directory,
dataDir: path.join(directory, ".codex", "flow-backend"),
executor: "direct",
nodeCommand: process.execPath,
},
);
const store = new FlowBackendStore(path.join(config.dataDir, "flow-backend.sqlite"));
try {
const result = await dispatchFlowEvent({
config,
store,
wait: true,
env: {},
event: {
id: "event-1",
type: "demo.event",
receivedAt: "2026-05-13T00:00:00.000Z",
payload: { name: "Ada" },
},
});
expect(result).toMatchObject({ status: "accepted", eventId: "event-1", matched: 1 });
const runs = store.listRunsByEvent("event-1");
expect(runs).toHaveLength(1);
expect(runs[0]).toMatchObject({
flowName: "demo",
stepName: "hello",
status: "completed",
});
expect(runs[0]?.stdout).toContain("hello Ada");
expect(store.listEvents()).toHaveLength(1);
expect(store.getEvent("event-1")).toMatchObject({
id: "event-1",
type: "demo.event",
payload: { name: "Ada" },
});
const replay = await replayFlowEvent({
config,
store,
eventId: "event-1",
wait: true,
env: {},
});
expect(replay).toMatchObject({ status: "accepted", eventId: "event-1", matched: 1 });
const replayRuns = store.listRuns({ eventId: "event-1" });
expect(replayRuns).toHaveLength(2);
expect(replayRuns.map((run) => run.status).sort()).toEqual(["completed", "completed"]);
expect(replayRuns.some((run) => run.id.endsWith("_replay"))).toBe(true);
const replayRun = replayRuns.find((run) => run.id.endsWith("_replay"));
expect(replayRun ? store.getRun(replayRun.id)?.stdout : "").toContain("hello Ada");
} finally {
store.close();
}
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("parses inspection and replay commands", () => {
expect(parseCli(["list-events", "--type", "demo.event", "--limit", "10"], {})).toMatchObject({
kind: "list-events",
type: "demo.event",
limit: 10,
});
expect(parseCli(["show-event", "event-1"], {})).toMatchObject({
kind: "show-event",
eventId: "event-1",
});
expect(parseCli(["replay-event", "event-1", "--wait"], {})).toMatchObject({
kind: "replay-event",
eventId: "event-1",
wait: true,
});
expect(parseCli(["list-runs", "--event-id", "event-1", "--status", "failed"], {})).toMatchObject({
kind: "list-runs",
eventId: "event-1",
status: "failed",
});
expect(parseCli(["show-run", "run_123"], {})).toMatchObject({
kind: "show-run",
runId: "run_123",
});
expect(parseCli(["dispatch", "--event", "event.json", "--workspace-backend-url", "ws://127.0.0.1:3586"], {})).toMatchObject({
kind: "dispatch",
config: {
workspaceBackendUrl: "ws://127.0.0.1:3586",
},
});
});
test("builds systemd-run commands without executing them", () => {
const config = readConfig({}, {
cwd: "/tmp/project",
executor: "systemd-run",
nodeCommand: "/usr/bin/node",
workspaceBackendUrl: "ws://127.0.0.1:3586",
});
const command = flowCommand({
config,
runId: "run_123",
eventId: "event-1",
eventPath: "/tmp/event.json",
flowName: "demo",
stepName: "hello",
attemptId: "attempt-1",
replay: true,
env: {
CODEX_FLOW_PUSH: "1",
PEEZY_CODEX_REPO: "/tmp/codex",
},
});
expect(command.command).toBe("systemd-run");
expect(command.args).toContain("--user");
expect(command.args).toContain("--wait");
expect(command.args).toContain("--setenv=CODEX_FLOW_PUSH=1");
expect(command.args).toContain("--setenv=PEEZY_CODEX_REPO=/tmp/codex");
expect(command.args).toContain("--setenv=CODEX_FLOW_EVENT_ID=event-1");
expect(command.args).toContain("--setenv=CODEX_FLOW_RUN_ID=run_123");
expect(command.args).toContain("--setenv=CODEX_FLOW_ATTEMPT_ID=attempt-1");
expect(command.args).toContain("--setenv=CODEX_FLOW_REPLAY=1");
expect(command.args).toContain("--setenv=CODEX_WORKSPACE_BACKEND_WS_URL=ws://127.0.0.1:3586");
expect(command.args).toContain("/usr/bin/node");
expect(command.args).toContain("--run-id");
expect(command.args).toContain("run_123");
expect(command.args).toContain("--attempt-id");
expect(command.args).toContain("attempt-1");
expect(command.args).toContain("--workspace-backend-url");
expect(command.args).toContain("ws://127.0.0.1:3586");
expect(command.args).toContain("--replay");
});
test("always forwards generated flow runtime env even with custom forwardEnv", () => {
const config = readConfig({}, {
cwd: "/tmp/project",
executor: "systemd-run",
nodeCommand: "/usr/bin/node",
forwardEnv: ["PATH"],
workspaceBackendUrl: "ws://127.0.0.1:3586",
});
const command = flowCommand({
config,
runId: "run_123",
eventId: "event-1",
eventPath: "/tmp/event.json",
flowName: "demo",
stepName: "hello",
});
expect(command.args).toContain("--setenv=CODEX_FLOW_EVENT_ID=event-1");
expect(command.args).toContain("--setenv=CODEX_FLOW_RUN_ID=run_123");
expect(command.args).toContain("--setenv=CODEX_WORKSPACE_BACKEND_WS_URL=ws://127.0.0.1:3586");
});
async function writeFlow(root: string): Promise<void> {
const flowRoot = path.join(root, "flows", "demo");
await mkdir(path.join(flowRoot, "exec"), { recursive: true });
await mkdir(path.join(flowRoot, "schemas"), { recursive: true });
await writeFile(
path.join(flowRoot, "flow.toml"),
[
'name = "demo"',
"version = 1",
"",
"[[steps]]",
'name = "hello"',
'runner = "node"',
'script = "exec/hello.ts"',
"timeout_ms = 30000",
"",
"[steps.trigger]",
'type = "demo.event"',
'schema = "schemas/demo-event.schema.json"',
"",
].join("\n"),
);
await writeFile(
path.join(flowRoot, "schemas/demo-event.schema.json"),
JSON.stringify({
type: "object",
required: ["name"],
properties: { name: { type: "string" } },
}),
);
await writeFile(
path.join(flowRoot, "exec/hello.ts"),
[
"async function main() {",
" const chunks = [];",
" for await (const chunk of process.stdin) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);",
" const context = JSON.parse(Buffer.concat(chunks).toString('utf8'));",
" const name = context.flow.event.payload.name;",
" console.log(`FLOW_RESULT ${JSON.stringify({ status: 'completed', message: `hello ${name}` })}`);",
"}",
"void main();",
"",
].join("\n"),
);
}

View file

@ -18,9 +18,6 @@
"baseUrl": ".",
"paths": {
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy.tech/codex-flows/flows": ["../../packages/codex-client/src/app-server/flows.ts"],
"@peezy.tech/codex-flows/flow-runtime": ["../../packages/flow-runtime/src/index.ts"],
"@peezy.tech/codex-flows/flow-runtime/*": ["../../packages/flow-runtime/src/*"],
"@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"]
}
},

View file

@ -1,4 +1,3 @@
import { createCodexFlowClient, type CodexFlowClient } from "@peezy.tech/codex-flows/flows";
import type { ReasoningEffort, v2 } from "@peezy.tech/codex-flows/generated";
import {
CodexWorkspaceBackendClient,
@ -66,7 +65,6 @@ export class CodexTurnAnnouncer implements TurnAnnouncer {
#logger: Logger;
#clientOptions: Partial<CodexWorkspaceBackendClientOptions>;
#client?: CodexWorkspaceBackendClient;
#flow?: CodexFlowClient;
#threadId?: string;
constructor(options: CodexTurnAnnouncerOptions) {
@ -85,37 +83,34 @@ export class CodexTurnAnnouncer implements TurnAnnouncer {
}
async polish(context: TurnCompletionContext): Promise<AnnouncerDecision> {
const flow = await this.#ensureFlow();
const client = await this.#ensureClient();
const threadId = await this.#ensureThread();
const prompt = JSON.stringify({
task: "polish-workspace-voice-announcement",
maxCharacters: this.#maxPhraseChars,
turn: context,
});
const result = await flow.startFlow({
const started = await client.startTurn({
threadId,
resume: false,
input: prompt,
input: [{ type: "text", text: prompt, text_elements: [] }],
model: this.#model,
cwd: this.#cwd,
approvalPolicy: "never",
sandbox: "read-only",
baseInstructions: announcerInstructions,
turn: {
effort: this.#reasoningEffort,
outputSchema: announcerOutputSchema(this.#maxPhraseChars),
},
wait: {
timeoutMs: this.#timeoutMs,
throwOnFailure: true,
},
sandboxPolicy: { type: "readOnly", networkAccess: false },
effort: this.#reasoningEffort,
outputSchema: announcerOutputSchema(this.#maxPhraseChars),
});
const text = result.completedTurn ? finalTextFromTurn(result.completedTurn) : "";
const completedTurn = await waitForTurn(client, {
threadId,
turnId: started.turn.id,
timeoutMs: this.#timeoutMs,
});
const text = finalTextFromTurn(completedTurn);
const decision = parseAnnouncerDecision(text);
if (!decision) {
this.#logger.warn("announcer.invalidOutput", {
threadId: result.threadId,
turnId: result.turnId,
threadId,
turnId: started.turn.id,
});
return new TemplateTurnAnnouncer().polish(context);
}
@ -123,13 +118,12 @@ export class CodexTurnAnnouncer implements TurnAnnouncer {
}
close(): void {
this.#flow?.close();
this.#client?.close();
}
async #ensureFlow(): Promise<CodexFlowClient> {
if (this.#flow) {
return this.#flow;
async #ensureClient(): Promise<CodexWorkspaceBackendClient> {
if (this.#client) {
return this.#client;
}
this.#client = new CodexWorkspaceBackendClient({
...this.#clientOptions,
@ -142,19 +136,12 @@ export class CodexTurnAnnouncer implements TurnAnnouncer {
clientTitle: "Codex Workspace Voice Announcer",
clientVersion: "0.1.0",
});
this.#flow = createCodexFlowClient({
client: this.#client,
closeInjectedClient: false,
clientName: "codex-workspace-voice-announcer",
clientTitle: "Codex Workspace Voice Announcer",
clientVersion: "0.1.0",
});
await this.#flow.connect();
return this.#flow;
await this.#client.connect();
return this.#client;
}
async #ensureThread(): Promise<string> {
await this.#ensureFlow();
await this.#ensureClient();
if (this.#threadId) {
return this.#threadId;
}
@ -229,6 +216,41 @@ function announcerOutputSchema(
};
}
async function waitForTurn(
client: CodexWorkspaceBackendClient,
options: {
threadId: string;
turnId: string;
timeoutMs: number;
},
): Promise<v2.Turn> {
const startedAt = Date.now();
while (true) {
const response = await client.readThread({
threadId: options.threadId,
includeTurns: true,
});
const turn = response.thread.turns.find((entry) => entry.id === options.turnId);
if (!turn) {
throw new Error(`Announcer turn ${options.turnId} was not found`);
}
if (turn.status !== "inProgress") {
if (turn.status === "failed") {
throw new Error(turn.error?.message ?? `Announcer turn ${options.turnId} failed`);
}
return turn;
}
if (Date.now() - startedAt >= options.timeoutMs) {
throw new Error(`Timed out waiting for announcer turn ${options.turnId}`);
}
await delay(Math.min(1000, Math.max(0, options.timeoutMs - (Date.now() - startedAt))));
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseJsonObject(rawText: string): Record<string, unknown> | undefined {
const trimmed = rawText.trim();
if (!trimmed) {

View file

@ -93,7 +93,6 @@ class FakeWorkspaceTransport extends CodexEventEmitter {
capabilities: {
appServerPassThrough: true,
workspaceMethods: [],
flowInspection: false,
},
} satisfies WorkspaceBackendInitializeResponse as T;
}

View file

@ -18,7 +18,6 @@
"baseUrl": ".",
"paths": {
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy.tech/codex-flows/flows": ["../../packages/codex-client/src/app-server/flows.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"],

View file

@ -5,9 +5,19 @@ type AutomationContext = {
};
prompt?: string;
cwd?: string;
turn: {
start(params: {
id?: string;
prompt: string;
cwd?: string;
}): Promise<{
threadId: string;
turnId: string;
}>;
};
};
export default function checkRelease(context: AutomationContext) {
export default async function checkRelease(context: AutomationContext) {
const payload = context.event?.payload ?? {};
if (payload.repo !== "openai/codex") {
return {
@ -32,9 +42,14 @@ export default function checkRelease(context: AutomationContext) {
"",
"Start by checking whether the matching @openai/codex package exists and whether regenerated app-server TypeScript bindings would change this repository.",
].filter(Boolean);
return {
action: "turn",
const turn = await context.turn.start({
id: "binding-check",
cwd: context.cwd,
prompt: lines.join("\n"),
});
return {
status: "started",
message: `Started openai/codex binding check for ${payload.tag}.`,
turn,
};
}

View file

@ -1,358 +0,0 @@
import { spawn } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
type FlowContext = {
flow: {
name?: string;
root?: string;
step?: string;
config?: Record<string, unknown>;
event: {
id: string;
type: string;
payload: Record<string, unknown>;
};
};
runtime?: {
workspaceBackendUrl?: string;
};
};
type CommandResult = {
label: string;
cmd: string[];
cwd: string;
exitCode: number | null;
stdout: string;
stderr: string;
};
const context = JSON.parse(await readStdinText()) as FlowContext;
const config = context.flow.config ?? {};
const repoRoot = path.resolve(envConfig(stringConfig("codex_flows_repo_env", "")) || stringConfig("codex_flows_repo", process.cwd()));
const commands: CommandResult[] = [];
try {
const tag = stringValue(context.flow.event.payload.tag, "payload.tag");
const version = versionFromTag(tag);
const packageName = stringConfig("package_name", "@peezy.tech/codex-flows");
const generatedDir = path.resolve(repoRoot, stringConfig("generated_dir", "packages/codex-client/src/app-server/generated"));
const packageJsonPath = path.resolve(repoRoot, stringConfig("package_json", "packages/codex-client/package.json"));
const published = await npmPackageExists(packageName, version);
if (published && !enabled("force", false)) {
finish("skipped", `${packageName}@${version} is already published.`, { version, tag });
}
await requireCleanWorktree();
await run("regenerate app-server TypeScript bindings", [
"npx",
"-y",
`@openai/codex@${version}`,
"app-server",
"generate-ts",
"--experimental",
"--out",
generatedDir,
]);
await updatePackageVersion(packageJsonPath, version);
await run("refresh dependencies", ["vp", "install"]);
const changedStatus = await run("changed file status", ["git", "status", "--short"]);
if (!changedStatus.stdout.trim()) {
finish("skipped", `No generated binding changes for ${tag}.`, { version, tag });
}
const followupTurn = await maybeRunFollowupTurn({ tag, version, changedStatus: changedStatus.stdout });
await run("codex-flows package release check", ["vp", "run", "--filter", packageName, "release:check"]);
await run("workspace typecheck", ["vp", "run", "check:types"]);
await run("workspace tests", ["vp", "run", "test"]);
await run("git diff check", ["git", "diff", "--check"]);
const status = await run("final git status", ["git", "status", "--short"]);
if (enabled("commit", true)) {
await commitRemainingChanges({ version, generatedDir, packageJsonPath });
}
if (enabled("push", false)) {
await run("push jojo main", ["git", "push", "origin", "HEAD:main"]);
}
if (enabled("publish", false)) {
await run("push GitHub main", ["git", "push", "github", "HEAD:main"]);
await run("trigger GitHub trusted publish", [
"gh",
"workflow",
"run",
stringConfig("github_publish_workflow", "publish-codex-flows.yml"),
"--repo",
stringConfig("github_repo", "peezy-tech/codex-flows"),
"-f",
`confirm_package=${packageName}`,
]);
}
finish("changed", `${packageName} regenerated for openai/codex ${tag}.`, {
version,
tag,
committed: enabled("commit", true),
pushed: enabled("push", false),
published: enabled("publish", false),
followupTurn,
});
} catch (error) {
finish("failed", error instanceof Error ? error.message : String(error));
}
async function requireCleanWorktree(): Promise<void> {
const status = await run("dirty worktree check", ["git", "status", "--porcelain=v1"]);
if (status.stdout.trim()) {
finish("blocked", "codex-flows checkout has local changes before the release update.", {
dirtyStatus: status.stdout,
});
}
}
async function npmPackageExists(packageName: string, version: string): Promise<boolean> {
const result = await run("published package check", [
"npm",
"view",
`${packageName}@${version}`,
"version",
"--json",
], { allowFailure: true });
return result.exitCode === 0 && result.stdout.includes(version);
}
async function updatePackageVersion(packageJsonPath: string, version: string): Promise<void> {
const parsed = JSON.parse(await readFile(packageJsonPath, "utf8")) as Record<string, unknown>;
parsed.version = version;
await writeFile(packageJsonPath, `${JSON.stringify(parsed, null, "\t")}\n`);
}
async function maybeRunFollowupTurn(input: {
tag: string;
version: string;
changedStatus: string;
}): Promise<Record<string, unknown> | undefined> {
if (!enabled("followup_turn", true)) {
return undefined;
}
const runCodexAgentTurnFromFlow = await loadRunCodexAgentTurnFromFlow();
const threadJson = stringConfig("followup_thread_json", ".codex/flow-artifacts/openai-codex-bindings-thread.json");
const prompt = [
`OpenAI Codex ${input.tag} regenerated app-server TypeScript bindings for @peezy.tech/codex-flows ${input.version}.`,
"",
"Review the changed files and make any source, test, or export updates needed so the package still builds against the regenerated bindings.",
"Keep changes focused on codex-flows compatibility with the regenerated app-server surface.",
"If you make source, test, export, or generated-file changes, commit them before finishing.",
`Use a focused commit message such as "flow: update codex-flows for openai codex ${input.version}".`,
"Do not push, publish, or edit release metadata beyond what this flow already changed.",
"",
"Current changed files:",
input.changedStatus.trim(),
].join("\n");
const turn = await runCodexAgentTurnFromFlow(
{
flow: {
name: context.flow.name ?? "openai-codex-bindings",
root: repoRoot,
step: context.flow.step ?? "regenerate-bindings",
event: context.flow.event,
},
runtime: context.runtime,
},
{
prompt,
cwd: repoRoot,
approvalPolicy: "never",
sandbox: "danger-full-access",
appServerUrl: process.env.CODEX_APP_SERVER_URL,
requestTimeoutMs: numberConfig("followup_request_timeout_ms", 900000),
wait: {
timeoutMs: numberConfig("followup_wait_timeout_ms", 900000),
throwOnFailure: true,
},
exportThreadJson: threadJson || false,
},
) as {
artifacts?: Record<string, unknown>;
threadId?: string;
turnId?: string;
threadJsonPath?: string;
};
return {
threadId: turn.threadId,
turnId: turn.turnId,
threadJsonPath: turn.threadJsonPath,
...(turn.artifacts ?? {}),
};
}
async function commitRemainingChanges(input: {
version: string;
generatedDir: string;
packageJsonPath: string;
}): Promise<CommandResult | undefined> {
const status = await run("dirty check before release commit", ["git", "status", "--porcelain=v1"]);
if (!status.stdout.trim()) {
return undefined;
}
await run("stage release update", [
"git",
"add",
"--",
input.generatedDir,
input.packageJsonPath,
path.join(repoRoot, "pnpm-lock.yaml"),
path.join(repoRoot, "packages/codex-client/src"),
path.join(repoRoot, "packages/codex-client/test"),
path.join(repoRoot, "packages/codex-client/scripts"),
]);
const staged = await run("staged release update status", ["git", "diff", "--cached", "--quiet"], { allowFailure: true });
if (staged.exitCode === 0) {
return undefined;
}
return await run("commit release update", [
"git",
"commit",
"-m",
`flow: update codex-flows for openai codex ${input.version}`,
]);
}
async function loadRunCodexAgentTurnFromFlow(): Promise<(
context: unknown,
options: Record<string, unknown>,
) => Promise<unknown>> {
const candidates = [
"@peezy.tech/codex-flows/flows",
pathToFileURL(path.join(repoRoot, "packages/codex-client/src/app-server/flows.ts")).href,
];
const errors: string[] = [];
for (const candidate of candidates) {
try {
const module = await import(candidate) as { runCodexAgentTurnFromFlow?: unknown };
if (typeof module.runCodexAgentTurnFromFlow === "function") {
return module.runCodexAgentTurnFromFlow as (
context: unknown,
options: Record<string, unknown>,
) => Promise<unknown>;
}
errors.push(`${candidate}: runCodexAgentTurnFromFlow is not exported`);
} catch (error) {
errors.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`);
}
}
throw new Error(`Could not load codex-flows follow-up turn helper:\n${errors.join("\n")}`);
}
async function run(
label: string,
cmd: string[],
options: { allowFailure?: boolean; cwd?: string } = {},
): Promise<CommandResult> {
const child = spawn(cmd[0] ?? "", cmd.slice(1), {
cwd: options.cwd ?? repoRoot,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
const [stdout, stderr, exitCode] = await Promise.all([
collectText(child.stdout),
collectText(child.stderr),
exitCodeFor(child),
]);
const result = { label, cmd, cwd: options.cwd ?? repoRoot, exitCode, stdout, stderr };
commands.push(result);
if (exitCode !== 0 && !options.allowFailure) {
throw new Error(`${label} failed with exit ${exitCode}:\n${stderr || stdout}`);
}
return result;
}
function finish(status: string, message: string, artifacts: Record<string, unknown> = {}): never {
const trimmedCommands = commands.map((command) => ({
...command,
stdout: truncate(command.stdout),
stderr: truncate(command.stderr),
}));
console.log(`FLOW_RESULT ${JSON.stringify({ status, message, artifacts: { ...artifacts, commands: trimmedCommands } })}`);
process.exit(0);
}
function enabled(name: string, fallback: boolean): boolean {
const envName = `CODEX_FLOW_${name.toUpperCase()}`;
const envValue = process.env[envName];
if (envValue !== undefined) {
return ["1", "true", "yes", "on"].includes(envValue.trim().toLowerCase());
}
const value = config[name];
return typeof value === "boolean" ? value : fallback;
}
function envConfig(name: string): string | undefined {
return name.trim() ? process.env[name]?.trim() || undefined : undefined;
}
function stringConfig(name: string, fallback: string): string {
const value = config[name];
return typeof value === "string" && value.trim() ? value : fallback;
}
function numberConfig(name: string, fallback: number): number {
const envName = `CODEX_FLOW_${name.toUpperCase()}`;
const envValue = process.env[envName];
const raw = envValue !== undefined ? envValue : config[name];
const value = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : fallback;
return Number.isFinite(value) ? value : fallback;
}
function stringValue(value: unknown, name: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${name} must be a non-empty string`);
}
return value;
}
function versionFromTag(tag: string): string {
const match = tag.match(/[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?/);
if (!match) {
throw new Error(`Could not infer semantic version from release tag ${tag}`);
}
return match[0];
}
function truncate(value: string, max = 4000): string {
return value.length <= max ? value : `${value.slice(0, max)}\n...[truncated ${value.length - max} chars]`;
}
async function readStdinText(): Promise<string> {
const chunks: Uint8Array[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
async function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
let output = "";
if (!stream) {
return output;
}
for await (const chunk of stream) {
output += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
}
return output;
}
function exitCodeFor(child: ReturnType<typeof spawn>): Promise<number | null> {
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => resolve(code));
});
}

View file

@ -1,28 +0,0 @@
name = "openai-codex-bindings"
version = 1
description = "Regenerate @peezy.tech/codex-flows bindings from a canonical openai/codex release."
[config]
package_name = "@peezy.tech/codex-flows"
codex_flows_repo_env = "PEEZY_CODEX_FLOWS_REPO"
codex_flows_repo = "/home/peezy/meta-workspace/codex-flows"
generated_dir = "packages/codex-client/src/app-server/generated"
package_json = "packages/codex-client/package.json"
commit = true
followup_turn = true
followup_thread_json = ".codex/flow-artifacts/openai-codex-bindings-thread.json"
push = false
publish = false
github_repo = "peezy-tech/codex-flows"
github_publish_workflow = "publish-codex-flows.yml"
[[steps]]
name = "regenerate-bindings"
runner = "node"
script = "exec/update-bindings.ts"
cwd = "../.."
timeout_ms = 1200000
[steps.trigger]
type = "upstream.release"
schema = "schemas/upstream-release.schema.json"

View file

@ -1,11 +0,0 @@
{
"type": "object",
"required": ["repo", "tag"],
"properties": {
"provider": { "type": "string" },
"repo": { "type": "string", "enum": ["openai/codex"] },
"tag": { "type": "string" },
"url": { "type": "string" },
"publishedAt": { "type": "string" }
}
}

View file

@ -1,522 +0,0 @@
import { existsSync } from "node:fs";
import { spawn } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
type FlowContext = {
flow: {
config?: Record<string, unknown>;
event: {
id: string;
payload?: Record<string, unknown>;
};
};
};
type CommandResult = {
label: string;
command: string[];
cwd: string;
code: number | null;
stdout: string;
stderr: string;
};
type PatchBranch = {
name: string;
sha: string;
subject: string;
};
type AppliedPatch = PatchBranch & {
appliedSha: string;
};
let context: FlowContext;
let config: Record<string, unknown> = {};
let payload: Record<string, unknown> = {};
const commands: CommandResult[] = [];
function finish(value: Record<string, unknown>): never {
process.stdout.write(`FLOW_RESULT ${JSON.stringify(value)}\n`);
process.exit(0);
}
void main();
async function main(): Promise<void> {
context = JSON.parse(await readStdinText()) as FlowContext;
config = context.flow.config ?? {};
payload = context.flow.event.payload ?? {};
try {
const sourcePackage = stringValue(payload.packageName);
const sourceVersion = stringValue(payload.version);
const packageName = stringConfig("package_name", "@peezy.tech/codex-flows");
const codexPackageName = stringConfig("codex_package_name", "@peezy.tech/codex");
if (!sourcePackage || !sourceVersion) {
finish({ status: "failed", message: "downstream.release requires packageName and version." });
}
if (sourcePackage !== packageName && sourcePackage !== codexPackageName) {
finish({ status: "skipped", message: `Ignoring downstream release for ${sourcePackage}.` });
}
const repoRoot = path.resolve(
envConfig(stringConfig("codex_flows_repo_env", "")) ||
stringConfig("codex_flows_repo", process.cwd()),
);
const repoFullName = stringConfig("repo_full_name", "peezy-tech/codex-flows");
const sourceRemote = stringConfig("source_remote", "origin");
const sourceBranch = stringConfig("source_branch", "main");
const sourceRef = stringConfig(
"source_ref",
enabled("fetch", true) ? `refs/remotes/${sourceRemote}/${sourceBranch}` : sourceBranch,
);
const forkBranch = stringConfig("fork_branch", "fork");
const patchPrefix = normalizePatchPrefix(stringConfig("patch_prefix", "patch/"));
const worktreeDir = path.resolve(
repoRoot,
stringConfig("worktree_dir", ".codex/flow-artifacts/codex-flows-fork-worktree"),
);
const artifactDir = path.resolve(
repoRoot,
stringConfig("artifact_dir", ".codex/flow-artifacts/codex-flows-fork-release"),
);
const fetchEnabled = enabled("fetch", true);
const refreshPatchBranches = enabled("refresh_patch_branches", true);
const commitEnabled = enabled("commit", true);
const pushEnabled = enabled("push", false);
const publishEnabled = enabled("publish", false);
const linkLocalPackage = enabled("link_local_package", false);
const verifyCommands = stringArrayConfig("verify_commands", [
"vp install",
`vp run --filter ${packageName} release:check`,
]);
await requireCleanRepo(repoRoot);
if (fetchEnabled) {
await runChecked("fetch source branch", ["git", "fetch", sourceRemote, sourceBranch, "--prune"], repoRoot);
}
const baseSha = (await runChecked("resolve source ref", [
"git",
"rev-parse",
"--verify",
`${sourceRef}^{commit}`,
], repoRoot)).stdout.trim();
const patchBranches = await listPatchBranches(repoRoot, patchPrefix);
if (patchBranches.length === 0) {
finish({
status: "blocked",
message: `codex-flows fork release requires at least one ${patchPrefix} branch.`,
artifacts: {
repoRoot,
sourceRef,
baseSha,
patchPrefix,
commands: commandArtifacts(),
},
});
}
await prepareWorktree(repoRoot, worktreeDir, baseSha);
const applied = await applyPatchStack({
repoRoot,
worktreeDir,
patchBranches,
refreshPatchBranches,
});
const baseVersion = sourcePackage === packageName
? sourceVersion
: await readPackageVersion(path.join(worktreeDir, "packages/codex-client/package.json"));
const codexVersion = sourcePackage === codexPackageName
? sourceVersion
: envConfig(stringConfig("codex_version_env", "")) || await npmPackageVersion(codexPackageName);
const forkVersion = forkPackageVersion(baseVersion, codexVersion);
await applyReleaseMetadata({
worktreeDir,
packageName,
codexPackageName,
codexVersion,
forkVersion,
});
for (const command of verifyCommands) {
await runChecked(`verify: ${command}`, ["bash", "-lc", command], worktreeDir);
}
await rm(artifactDir, { recursive: true, force: true });
await mkdir(artifactDir, { recursive: true });
const pack = await runChecked(
"pack fork release",
["npm", "pack", "--pack-destination", artifactDir],
path.join(worktreeDir, "packages/codex-client"),
);
const tarball = pack.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
const tarballPath = tarball ? path.join(artifactDir, tarball) : undefined;
if (linkLocalPackage) {
await runChecked(
"link fork release package",
["pnpm", "link", "--global"],
path.join(worktreeDir, "packages/codex-client"),
);
}
const status = await runChecked("read fork release diff", ["git", "status", "--porcelain"], worktreeDir);
if (commitEnabled && status.stdout.trim()) {
await runChecked("stage fork release metadata", ["git", "add", "--all"], worktreeDir);
await runChecked("commit fork release metadata", [
"git",
"commit",
"-m",
`release: codex-flows fork ${forkVersion}`,
], worktreeDir);
}
const commitSha = (await runChecked("read fork release head", ["git", "rev-parse", "HEAD"], worktreeDir)).stdout.trim();
if (commitEnabled || !status.stdout.trim()) {
await runChecked("update fork branch", ["git", "branch", "-f", forkBranch, commitSha], repoRoot);
}
let pushed = false;
if (pushEnabled) {
await runChecked(
"push fork branch",
["git", "push", sourceRemote, `HEAD:refs/heads/${forkBranch}`, "--force-with-lease"],
worktreeDir,
);
pushed = true;
}
let published = false;
if (publishEnabled && tarballPath) {
await runChecked("publish fork package", [
"npm",
"publish",
tarballPath,
"--access",
"public",
"--tag",
stringConfig("fork_dist_tag", "fork"),
], worktreeDir);
published = true;
}
finish({
status: status.stdout.trim() || applied.length > 0 ? "changed" : "completed",
message: `Prepared ${packageName} fork ${forkVersion} from ${sourcePackage}@${sourceVersion}.`,
artifacts: {
eventId: context.flow.event.id,
sourcePackage,
sourceVersion,
packageName,
baseVersion,
codexPackageName,
codexVersion,
forkVersion,
repoRoot,
sourceRef,
forkBranch,
patchPrefix,
baseSha,
commitSha,
applied,
refreshedPatchBranches: refreshPatchBranches,
worktreeDir,
tarballPath,
linked: linkLocalPackage,
pushed,
published,
candidateRefs: [{
kind: "branch",
repo: repoFullName,
ref: `refs/heads/${forkBranch}`,
sha: commitSha,
pushed,
}],
commands: commandArtifacts(),
},
});
} catch (error) {
finish({
status: "failed",
message: error instanceof Error ? error.message : String(error),
artifacts: { commands: commandArtifacts() },
});
}
}
async function requireCleanRepo(repoRoot: string): Promise<void> {
const status = await runChecked("read repository status", ["git", "status", "--porcelain"], repoRoot);
const relevant = status.stdout
.split(/\r?\n/)
.filter((line) => line.trim())
.filter((line) => !line.includes(".codex/flow-artifacts/"));
if (relevant.length > 0) {
finish({
status: "blocked",
message: "codex-flows checkout has local changes before fork release preparation.",
artifacts: { status: relevant.join("\n"), commands: commandArtifacts() },
});
}
}
async function prepareWorktree(repoRoot: string, worktreeDir: string, baseSha: string): Promise<void> {
if (existsSync(worktreeDir)) {
await run("remove old fork worktree", ["git", "worktree", "remove", "--force", worktreeDir], repoRoot);
await rm(worktreeDir, { recursive: true, force: true });
}
await run("prune worktrees", ["git", "worktree", "prune"], repoRoot);
await runChecked("create fork worktree", ["git", "worktree", "add", "--detach", worktreeDir, baseSha], repoRoot);
}
async function applyPatchStack(input: {
repoRoot: string;
worktreeDir: string;
patchBranches: PatchBranch[];
refreshPatchBranches: boolean;
}): Promise<AppliedPatch[]> {
const applied: AppliedPatch[] = [];
for (const patchBranch of input.patchBranches) {
const pick = await run(`apply ${patchBranch.name}`, ["git", "cherry-pick", patchBranch.sha], input.worktreeDir, {
allowFailure: true,
});
if (pick.code !== 0) {
const status = await run("patch rebuild conflict status", ["git", "status", "--short", "--branch"], input.worktreeDir, {
allowFailure: true,
});
const unmerged = await run("unmerged files", ["git", "diff", "--name-only", "--diff-filter=U"], input.worktreeDir, {
allowFailure: true,
});
finish({
status: "needs_intervention",
message: `codex-flows fork rebuild stopped while applying ${patchBranch.name}.`,
artifacts: {
failedPatch: patchBranch,
applied,
statusOutput: status.stdout,
unmergedFiles: lines(unmerged.stdout),
commands: commandArtifacts(),
},
});
}
const appliedSha = (await runChecked("read applied patch head", ["git", "rev-parse", "HEAD"], input.worktreeDir)).stdout.trim();
const appliedPatch = { ...patchBranch, appliedSha };
applied.push(appliedPatch);
if (input.refreshPatchBranches) {
await runChecked(
`refresh ${patchBranch.name}`,
["git", "branch", "-f", patchBranch.name, appliedSha],
input.repoRoot,
);
}
}
return applied;
}
async function listPatchBranches(repoRoot: string, patchPrefix: string): Promise<PatchBranch[]> {
const refsPath = `refs/heads/${patchPrefix.replace(/\/+$/, "")}`;
const result = await run("list patch branches", [
"git",
"for-each-ref",
"--format=%(refname:short)%09%(objectname)%09%(contents:subject)",
refsPath,
], repoRoot, { allowFailure: true });
if (result.code !== 0 || !result.stdout.trim()) {
return [];
}
return result.stdout
.trim()
.split(/\r?\n/)
.map((line) => {
const [name = "", sha = "", subject = ""] = line.split("\t");
return { name, sha, subject };
})
.filter((branch) => branch.name.startsWith(patchPrefix))
.sort((left, right) => left.name.localeCompare(right.name));
}
async function applyReleaseMetadata(input: {
worktreeDir: string;
packageName: string;
codexPackageName: string;
codexVersion: string;
forkVersion: string;
}): Promise<void> {
const packageJsonPath = path.join(input.worktreeDir, "packages/codex-client/package.json");
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as Record<string, unknown>;
packageJson.version = input.forkVersion;
packageJson.dependencies = sortRecord({
...(recordValue(packageJson.dependencies)),
[input.codexPackageName]: input.codexVersion,
});
await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, "\t")}\n`, "utf8");
}
function forkPackageVersion(baseVersion: string, codexVersion: string): string {
const prefix = sanitizePrerelease(stringConfig("fork_version_prefix", "peezy"));
const codex = sanitizePrerelease(codexVersion);
return baseVersion.includes("-")
? `${baseVersion}.${prefix}.${codex}`
: `${baseVersion}-${prefix}.${codex}`;
}
function sanitizePrerelease(value: string): string {
return value
.replace(/^v/, "")
.replace(/[^0-9A-Za-z]+/g, ".")
.split(".")
.filter(Boolean)
.join(".") || "0";
}
async function readPackageVersion(packageJsonPath: string): Promise<string> {
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: string };
if (!packageJson.version) {
throw new Error(`Could not read package version from ${packageJsonPath}`);
}
return packageJson.version;
}
async function npmPackageVersion(packageName: string): Promise<string> {
const result = await runChecked("read latest Codex fork package version", ["npm", "view", packageName, "version", "--json"], process.cwd());
return JSON.parse(result.stdout) as string;
}
async function runChecked(label: string, command: string[], cwd: string): Promise<CommandResult> {
const result = await run(label, command, cwd);
if (result.code !== 0) {
throw new Error(`${label} failed with exit ${result.code}:\n${result.stderr || result.stdout}`);
}
return result;
}
async function run(
label: string,
command: string[],
cwd: string,
options: { allowFailure?: boolean } = {},
): Promise<CommandResult> {
process.stderr.write(`+ ${label}: ${command.join(" ")}\n`);
const proc = spawn(command[0] ?? "", command.slice(1), {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
const [stdout, stderr, code] = await Promise.all([
collectText(proc.stdout),
collectText(proc.stderr),
exitCodeFor(proc),
]);
if (stdout) process.stderr.write(stdout);
if (stderr) process.stderr.write(stderr);
const result = { label, command, cwd, code, stdout, stderr };
commands.push(result);
if (code !== 0 && !options.allowFailure) {
throw new Error(`${label} failed with exit ${code}:\n${stderr || stdout}`);
}
return result;
}
function commandArtifacts(): Array<Record<string, unknown>> {
return commands.map((command) => ({
...command,
stdout: truncate(command.stdout),
stderr: truncate(command.stderr),
}));
}
function enabled(name: string, fallback: boolean): boolean {
const envName = `CODEX_FLOW_${name.toUpperCase()}`;
const envValue = process.env[envName];
if (envValue !== undefined) {
return booleanValue(envValue);
}
const value = config[name];
if (typeof value === "boolean") return value;
if (typeof value === "string") return booleanValue(value);
return fallback;
}
function stringConfig(name: string, fallback: string): string {
const value = config[name];
return typeof value === "string" && value.trim() ? value : fallback;
}
function stringArrayConfig(name: string, fallback: string[]): string[] {
const value = config[name];
if (!Array.isArray(value)) return fallback;
const entries = value.filter((entry): entry is string =>
typeof entry === "string" && entry.trim().length > 0
);
return entries.length > 0 ? entries : fallback;
}
function envConfig(name: string): string | undefined {
return name ? process.env[name]?.trim() || undefined : undefined;
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function recordValue(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function sortRecord(value: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)));
}
function booleanValue(value: string): boolean {
const normalized = value.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
function normalizePatchPrefix(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "patch/";
}
return trimmed.endsWith("/") ? trimmed : `${trimmed}/`;
}
function lines(value: string): string[] {
return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
}
function truncate(value: string, max = 4000): string {
if (value.length <= max) {
return value;
}
return `${value.slice(0, max)}\n...[truncated ${value.length - max} chars]`;
}
async function readStdinText(): Promise<string> {
const chunks: Uint8Array[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
async function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
let output = "";
if (!stream) {
return output;
}
for await (const chunk of stream) {
output += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
}
return output;
}
function exitCodeFor(child: ReturnType<typeof spawn>): Promise<number | null> {
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => resolve(code));
});
}

View file

@ -1,43 +0,0 @@
name = "peezy-codex-flows-fork"
version = 1
description = "Build a local fork release of codex-flows when Peezy Codex or codex-flows releases."
[config]
package_name = "@peezy.tech/codex-flows"
codex_package_name = "@peezy.tech/codex"
codex_version_env = "PEEZY_CODEX_VERSION"
codex_flows_repo_env = "PEEZY_CODEX_FLOWS_REPO"
codex_flows_repo = "/home/peezy/meta-workspace/codex-flows"
repo_full_name = "peezy-tech/codex-flows"
source_remote = "origin"
source_branch = "main"
source_ref = "refs/remotes/origin/main"
fork_branch = "fork"
patch_prefix = "patch/"
fork_dist_tag = "fork"
fork_version_prefix = "peezy"
worktree_dir = ".codex/flow-artifacts/codex-flows-fork-worktree"
artifact_dir = ".codex/flow-artifacts/codex-flows-fork-release"
verify_commands = [
"vp install",
"vp run --filter @peezy.tech/codex-flows release:check",
]
fetch = true
refresh_patch_branches = true
commit = true
push = false
publish = false
link_local_package = false
[guidance]
skills = ["jojo-development-flow", "flow-package-author"]
[[steps]]
name = "release-fork"
runner = "node"
script = "exec/release-fork.ts"
timeout_ms = 1200000
[steps.trigger]
type = "downstream.release"
schema = "schemas/downstream-release.schema.json"

View file

@ -1,21 +0,0 @@
{
"type": "object",
"required": ["packageName", "version"],
"properties": {
"packageName": {
"type": "string",
"enum": ["@peezy.tech/codex", "@peezy.tech/codex-flows"]
},
"version": { "type": "string" },
"tag": { "type": "string" },
"repo": {
"type": "string",
"enum": ["peezy-tech/codex", "peezy-tech/codex-flows"]
},
"provider": { "type": "string" },
"sourceId": { "type": "string" },
"entryId": { "type": "string" },
"publishedAt": { "type": "string" },
"url": { "type": "string" }
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,46 +0,0 @@
name = "peezy-codex-fork"
version = 1
description = "Maintain the Peezy Codex fork patch workspace from openai/codex releases and main updates."
[config]
package_name = "@peezy.tech/codex"
codex_repo_env = "PEEZY_CODEX_REPO"
codex_repo = "/home/peezy/meta-workspace/codex"
target_branch_env = "PEEZY_CODEX_TARGET_BRANCH"
target_branch = "main"
upstream_branch = "upstream"
patch_prefix = "patch/"
upstream_remote = "upstream"
upstream_main_ref = "main"
upstream_repo_url = "https://github.com/openai/codex.git"
cargo_target_dir_env = "PEEZY_CODEX_CARGO_TARGET_DIR"
cargo_target_dir = "/tmp/peezy-codex-flow-target"
stage_npm_wrapper = true
link_local_package = false
push = false
publish = false
intervention_turn = true
intervention_thread_json = ".codex/flow-artifacts/peezy-codex-fork-intervention-thread.json"
[guidance]
skills = ["jojo-development-flow", "flow-package-author"]
[[steps]]
name = "release-cycle"
runner = "node"
script = "exec/update-fork.ts"
timeout_ms = 3600000
[steps.trigger]
type = "upstream.release"
schema = "schemas/upstream-release.schema.json"
[[steps]]
name = "main-branch-update"
runner = "node"
script = "exec/update-fork.ts"
timeout_ms = 3600000
[steps.trigger]
type = "upstream.branch_update"
schema = "schemas/upstream-branch-update.schema.json"

View file

@ -1,16 +0,0 @@
{
"type": "object",
"required": ["repo", "ref"],
"properties": {
"provider": { "type": "string" },
"repo": { "type": "string", "enum": ["openai/codex"] },
"repoOwner": { "type": "string" },
"repoName": { "type": "string" },
"ref": { "type": "string" },
"sha": { "type": "string" },
"url": { "type": "string" },
"publishedAt": { "type": "string" },
"sourceId": { "type": "string" },
"entryId": { "type": "string" }
}
}

View file

@ -1,11 +0,0 @@
{
"type": "object",
"required": ["repo", "tag"],
"properties": {
"provider": { "type": "string" },
"repo": { "type": "string", "enum": ["openai/codex"] },
"tag": { "type": "string" },
"url": { "type": "string" },
"publishedAt": { "type": "string" }
}
}

View file

@ -1,652 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import {
CodexAppServerClient,
type CodexAppServerClientOptions,
} from "./client.ts";
import type { v2 } from "./generated/index.ts";
import type { JsonRpcNotification } from "./rpc.ts";
import { CodexWorkspaceBackendClient } from "../workspace-backend/client.ts";
type JsonValue = NonNullable<v2.TurnStartParams["outputSchema"]>;
type ThreadConfig = NonNullable<v2.ThreadStartParams["config"]>;
type ThreadResumeConfig = NonNullable<v2.ThreadResumeParams["config"]>;
export type CodexFlowAppServerClient = {
connect(): Promise<void>;
close(): void;
startThread(params: v2.ThreadStartParams): Promise<v2.ThreadStartResponse>;
resumeThread(params: v2.ThreadResumeParams): Promise<v2.ThreadResumeResponse>;
readThread(params: v2.ThreadReadParams): Promise<v2.ThreadReadResponse>;
startTurn(params: v2.TurnStartParams): Promise<v2.TurnStartResponse>;
on?(event: string, listener: (...args: any[]) => void): unknown;
off?(event: string, listener: (...args: any[]) => void): unknown;
};
export type CodexFlowClientOptions = {
client?: CodexFlowAppServerClient;
appServerUrl?: string;
requestTimeoutMs?: number;
clientName?: string;
clientTitle?: string;
clientVersion?: string;
closeInjectedClient?: boolean;
};
export type CodexFlowInputItem =
| v2.UserInput
| {
type: "text";
text: string;
text_elements?: v2.TextElement[];
};
export type CodexFlowInput =
| string
| CodexFlowInputItem
| CodexFlowInputItem[];
export type CodexFlowThreadOptions = Partial<
Omit<v2.ThreadStartParams, "experimentalRawEvents" | "persistExtendedHistory">
> & {
experimentalRawEvents?: boolean;
persistExtendedHistory?: boolean;
};
export type CodexFlowResumeOptions = Partial<
Omit<v2.ThreadResumeParams, "threadId" | "persistExtendedHistory">
> & {
persistExtendedHistory?: boolean;
};
export type CodexFlowTurnOptions = Partial<
Omit<v2.TurnStartParams, "threadId" | "input">
>;
export type CodexFlowWaitOptions = {
timeoutMs?: number;
pollIntervalMs?: number;
signal?: AbortSignal;
throwOnFailure?: boolean;
};
export type StartCodexFlowParams = {
threadId?: string;
prompt?: string;
input?: CodexFlowInput;
cwd?: string | null;
model?: string | null;
modelProvider?: string | null;
serviceTier?: string | null;
approvalPolicy?: v2.AskForApproval | null;
approvalsReviewer?: v2.ApprovalsReviewer | null;
sandbox?: v2.SandboxMode | null;
permissions?: v2.ThreadStartParams["permissions"];
config?: ThreadConfig | ThreadResumeConfig | null;
baseInstructions?: string | null;
developerInstructions?: string | null;
personality?: v2.ThreadStartParams["personality"];
outputSchema?: JsonValue | null;
thread?: CodexFlowThreadOptions;
resume?: CodexFlowResumeOptions | false;
turn?: CodexFlowTurnOptions;
wait?: boolean | CodexFlowWaitOptions;
};
export type CodexFlowStartResult = {
thread: v2.Thread;
turn: v2.Turn;
threadId: string;
turnId: string;
completedTurn?: v2.Turn;
};
export type CodexFlowRunContextLike = {
flow: {
name: string;
root: string;
step: string;
event?: unknown;
};
runtime?: {
workspaceBackendUrl?: string;
};
};
export type RunCodexAgentTurnFromFlowOptions =
StartCodexFlowParams & {
flowClient?: CodexFlowClient;
client?: CodexFlowAppServerClient;
appServerUrl?: string;
requestTimeoutMs?: number;
exportThreadJson?: string | false;
};
export type RunCodexAgentTurnFromFlowResult = CodexFlowStartResult & {
threadJsonPath?: string;
exportedThread?: v2.Thread;
artifacts: {
threadId: string;
turnId: string;
turnStatus?: v2.TurnStatus;
threadJsonPath?: string;
};
};
export type WaitForTurnParams = {
threadId: string;
turnId: string;
timeoutMs?: number;
pollIntervalMs?: number;
signal?: AbortSignal;
throwOnFailure?: boolean;
};
const DEFAULT_WAIT_TIMEOUT_MS = 120_000;
const DEFAULT_POLL_INTERVAL_MS = 1_000;
export class CodexFlowTimeoutError extends Error {
readonly threadId: string;
readonly turnId: string;
readonly timeoutMs: number;
constructor(params: { threadId: string; turnId: string; timeoutMs: number }) {
super(
`Timed out waiting for Codex turn ${params.turnId} on thread ${params.threadId}`,
);
this.name = "CodexFlowTimeoutError";
this.threadId = params.threadId;
this.turnId = params.turnId;
this.timeoutMs = params.timeoutMs;
}
}
export class CodexFlowTurnFailedError extends Error {
readonly threadId: string;
readonly turn: v2.Turn;
constructor(threadId: string, turn: v2.Turn) {
super(turn.error?.message ?? `Codex turn ${turn.id} failed`);
this.name = "CodexFlowTurnFailedError";
this.threadId = threadId;
this.turn = turn;
}
}
export class CodexFlowClient {
readonly client: CodexFlowAppServerClient;
#connected = false;
#closeClient: boolean;
constructor(options: CodexFlowClientOptions = {}) {
this.client =
options.client ??
new CodexAppServerClient({
...clientIdentityOptions(options),
webSocketTransportOptions: options.appServerUrl
? {
url: options.appServerUrl,
requestTimeoutMs: options.requestTimeoutMs,
}
: undefined,
transportOptions: options.requestTimeoutMs
? { requestTimeoutMs: options.requestTimeoutMs }
: undefined,
});
this.#closeClient = options.client
? options.closeInjectedClient === true
: true;
}
async connect(): Promise<void> {
if (this.#connected) {
return;
}
await this.client.connect();
this.#connected = true;
}
close(): void {
this.#connected = false;
if (this.#closeClient) {
this.client.close();
}
}
async startFlow(params: StartCodexFlowParams): Promise<CodexFlowStartResult> {
await this.connect();
const input = [
...toCodexUserInput(params.prompt),
...toCodexUserInput(params.input),
];
if (input.length === 0) {
throw new Error("Codex flow input is required");
}
const thread = await this.#openThread(params);
const turnResponse = await this.client.startTurn(
turnStartParams(thread.id, input, params),
);
const result: CodexFlowStartResult = {
thread,
turn: turnResponse.turn,
threadId: thread.id,
turnId: turnResponse.turn.id,
};
const waitOptions = normalizeWait(params.wait);
if (waitOptions) {
result.completedTurn = await this.waitForTurn({
threadId: thread.id,
turnId: turnResponse.turn.id,
...waitOptions,
});
}
return result;
}
async waitForTurn(params: WaitForTurnParams): Promise<v2.Turn> {
await this.connect();
const timeoutMs = params.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
const pollIntervalMs = params.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const signal = params.signal;
const throwOnFailure = params.throwOnFailure === true;
return new Promise<v2.Turn>((resolve, reject) => {
let settled = false;
let polling = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
let interval: ReturnType<typeof setInterval> | undefined;
const settle = (turn: v2.Turn): void => {
if (settled) {
return;
}
settled = true;
cleanup();
try {
resolve(maybeThrowForFailedTurn(params.threadId, turn, throwOnFailure));
} catch (error) {
reject(error);
}
};
const fail = (error: Error): void => {
if (settled) {
return;
}
settled = true;
cleanup();
reject(error);
};
const onNotification = (message: JsonRpcNotification): void => {
const turn = completedTurnFromNotification(
message,
params.threadId,
params.turnId,
);
if (turn) {
settle(turn);
}
};
const onClose = (): void => {
fail(new Error("Codex app-server connection closed while waiting for a turn"));
};
const onAbort = (): void => {
fail(new Error("Codex flow wait aborted"));
};
const poll = (): void => {
if (polling || settled) {
return;
}
polling = true;
void this.#findTurn(params.threadId, params.turnId)
.then((turn) => {
if (turn && isTerminalTurn(turn)) {
settle(turn);
}
})
.catch((error: unknown) => {
if (!isRetryableThreadReadError(error)) {
fail(asError(error));
}
})
.finally(() => {
polling = false;
});
};
const cleanup = (): void => {
if (timeout) clearTimeout(timeout);
if (interval) clearInterval(interval);
this.client.off?.("notification", onNotification);
this.client.off?.("close", onClose);
signal?.removeEventListener("abort", onAbort);
};
this.client.on?.("notification", onNotification);
this.client.on?.("close", onClose);
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) {
onAbort();
return;
}
timeout = setTimeout(
() =>
fail(
new CodexFlowTimeoutError({
threadId: params.threadId,
turnId: params.turnId,
timeoutMs,
}),
),
timeoutMs,
);
if (pollIntervalMs > 0) {
interval = setInterval(poll, pollIntervalMs);
}
poll();
});
}
async readThread(
threadId: string,
options: { includeTurns?: boolean } = {},
): Promise<v2.Thread> {
await this.connect();
const response = await this.client.readThread({
threadId,
includeTurns: options.includeTurns === true,
});
return response.thread;
}
async #openThread(params: StartCodexFlowParams): Promise<v2.Thread> {
if (params.threadId) {
if (params.resume === false) {
return this.readThread(params.threadId, { includeTurns: false });
}
const response = await this.client.resumeThread(
threadResumeParams(params.threadId, params),
);
return response.thread;
}
const response = await this.client.startThread(threadStartParams(params));
return response.thread;
}
async #findTurn(threadId: string, turnId: string): Promise<v2.Turn | undefined> {
const thread = await this.readThread(threadId, { includeTurns: true });
return thread.turns.find((turn) => turn.id === turnId);
}
}
export function createCodexFlowClient(
options: CodexFlowClientOptions = {},
): CodexFlowClient {
return new CodexFlowClient(options);
}
export async function runCodexAgentTurnFromFlow(
context: CodexFlowRunContextLike,
options: RunCodexAgentTurnFromFlowOptions,
): Promise<RunCodexAgentTurnFromFlowResult> {
const flowClient = options.flowClient ?? createCodexFlowClientForRunContext(context, options);
const shouldClose = options.flowClient === undefined;
try {
const {
flowClient: _flowClient,
client: _client,
appServerUrl: _appServerUrl,
requestTimeoutMs: _requestTimeoutMs,
exportThreadJson,
...startOptions
} = options;
const started = await flowClient.startFlow({
...startOptions,
cwd: startOptions.cwd ?? context.flow.root,
wait: startOptions.wait ?? { throwOnFailure: true },
});
let threadJsonPath: string | undefined;
let exportedThread: v2.Thread | undefined;
if (exportThreadJson) {
exportedThread = await flowClient.readThread(started.threadId, { includeTurns: true });
threadJsonPath = path.isAbsolute(exportThreadJson)
? exportThreadJson
: path.resolve(startOptions.cwd ?? context.flow.root, exportThreadJson);
await mkdir(path.dirname(threadJsonPath), { recursive: true });
await writeFile(threadJsonPath, `${JSON.stringify(exportedThread, null, 2)}\n`);
}
return {
...started,
...(threadJsonPath ? { threadJsonPath } : {}),
...(exportedThread ? { exportedThread } : {}),
artifacts: {
threadId: started.threadId,
turnId: started.turnId,
...(started.completedTurn?.status ? { turnStatus: started.completedTurn.status } : {}),
...(threadJsonPath ? { threadJsonPath } : {}),
},
};
} finally {
if (shouldClose) {
flowClient.close();
}
}
}
export function toCodexUserInput(
input: string | CodexFlowInput | undefined,
): v2.UserInput[] {
if (!input) {
return [];
}
if (typeof input === "string") {
return [{ type: "text", text: input, text_elements: [] }];
}
const items = Array.isArray(input) ? input : [input];
return items.map((item) => {
if (item.type === "text") {
return {
type: "text",
text: item.text,
text_elements: item.text_elements ?? [],
};
}
return item;
}) as v2.UserInput[];
}
export function isTerminalTurn(turn: v2.Turn): boolean {
return turn.status !== "inProgress";
}
function clientIdentityOptions(
options: CodexFlowClientOptions,
): Pick<
CodexAppServerClientOptions,
"clientName" | "clientTitle" | "clientVersion"
> {
return compactUndefined({
clientName: options.clientName ?? "peezy.tech-codex-flows",
clientTitle: options.clientTitle ?? "Codex Flows SDK",
clientVersion: options.clientVersion ?? "0.1.0",
});
}
function threadStartParams(params: StartCodexFlowParams): v2.ThreadStartParams {
const thread = params.thread ?? {};
return compactUndefined({
...thread,
model: params.model ?? thread.model,
modelProvider: params.modelProvider ?? thread.modelProvider,
serviceTier: params.serviceTier ?? thread.serviceTier,
cwd: params.cwd ?? thread.cwd,
approvalPolicy: params.approvalPolicy ?? thread.approvalPolicy,
approvalsReviewer: params.approvalsReviewer ?? thread.approvalsReviewer,
sandbox: params.sandbox ?? thread.sandbox,
permissions: params.permissions ?? thread.permissions,
config: params.config ?? thread.config,
baseInstructions: params.baseInstructions ?? thread.baseInstructions,
developerInstructions:
params.developerInstructions ?? thread.developerInstructions,
personality: params.personality ?? thread.personality,
experimentalRawEvents: thread.experimentalRawEvents ?? false,
persistExtendedHistory: thread.persistExtendedHistory ?? false,
});
}
function threadResumeParams(
threadId: string,
params: StartCodexFlowParams,
): v2.ThreadResumeParams {
const resume =
params.resume === undefined || params.resume === false ? {} : params.resume;
return compactUndefined({
...resume,
threadId,
model: params.model ?? resume.model,
modelProvider: params.modelProvider ?? resume.modelProvider,
serviceTier: params.serviceTier ?? resume.serviceTier,
cwd: params.cwd ?? resume.cwd,
approvalPolicy: params.approvalPolicy ?? resume.approvalPolicy,
approvalsReviewer: params.approvalsReviewer ?? resume.approvalsReviewer,
sandbox: params.sandbox ?? resume.sandbox,
permissions: params.permissions ?? resume.permissions,
config: params.config ?? resume.config,
baseInstructions: params.baseInstructions ?? resume.baseInstructions,
developerInstructions:
params.developerInstructions ?? resume.developerInstructions,
personality: params.personality ?? resume.personality,
excludeTurns: resume.excludeTurns ?? true,
persistExtendedHistory: resume.persistExtendedHistory ?? false,
});
}
function turnStartParams(
threadId: string,
input: v2.UserInput[],
params: StartCodexFlowParams,
): v2.TurnStartParams {
const turn = params.turn ?? {};
return compactUndefined({
...turn,
threadId,
input,
cwd: params.cwd ?? turn.cwd,
approvalPolicy: params.approvalPolicy ?? turn.approvalPolicy,
approvalsReviewer: params.approvalsReviewer ?? turn.approvalsReviewer,
permissions: params.permissions ?? turn.permissions,
model: params.model ?? turn.model,
serviceTier: params.serviceTier ?? turn.serviceTier,
personality: params.personality ?? turn.personality,
outputSchema: params.outputSchema ?? turn.outputSchema,
});
}
function normalizeWait(
wait: StartCodexFlowParams["wait"],
): CodexFlowWaitOptions | undefined {
if (wait === true) {
return {};
}
if (!wait) {
return undefined;
}
return wait;
}
function completedTurnFromNotification(
message: JsonRpcNotification,
threadId: string,
turnId: string,
): v2.Turn | undefined {
if (message.method !== "turn/completed" || !isRecord(message.params)) {
return undefined;
}
if (message.params.threadId !== threadId || !isRecord(message.params.turn)) {
return undefined;
}
const turn = message.params.turn as Partial<v2.Turn>;
return turn.id === turnId && typeof turn.status === "string"
? (turn as v2.Turn)
: undefined;
}
function maybeThrowForFailedTurn(
threadId: string,
turn: v2.Turn,
throwOnFailure: boolean,
): v2.Turn {
if (throwOnFailure && turn.status === "failed") {
throw new CodexFlowTurnFailedError(threadId, turn);
}
return turn;
}
function createCodexFlowClientForRunContext(
context: CodexFlowRunContextLike,
options: RunCodexAgentTurnFromFlowOptions,
): CodexFlowClient {
const workspaceBackendUrl = context.runtime?.workspaceBackendUrl;
const client = options.client ??
(workspaceBackendUrl
? new CodexWorkspaceBackendClient({
webSocketTransportOptions: {
url: workspaceBackendUrl,
requestTimeoutMs: options.requestTimeoutMs,
},
clientName: "codex-flow-agent-turn",
clientTitle: `Codex Flow ${context.flow.name}/${context.flow.step}`,
})
: undefined);
return createCodexFlowClient({
client,
closeInjectedClient: client ? true : undefined,
appServerUrl: client ? undefined : options.appServerUrl,
requestTimeoutMs: options.requestTimeoutMs,
clientName: "codex-flow-agent-turn",
clientTitle: `Codex Flow ${context.flow.name}/${context.flow.step}`,
});
}
function isRetryableThreadReadError(error: unknown): boolean {
const message = errorMessage(error).toLowerCase();
return message.includes("thread") &&
(message.includes("not found") ||
message.includes("unknown") ||
message.includes("not materialized") ||
message.includes("no such"));
}
function compactUndefined<T extends Record<string, unknown>>(value: T): T {
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
if (entry !== undefined) {
result[key] = entry;
}
}
return result as T;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -1,352 +0,0 @@
import { expect, test } from "vite-plus/test";
import type { v2 } from "../src/app-server/generated/index.ts";
import {
CodexFlowClient,
CodexFlowTimeoutError,
CodexFlowTurnFailedError,
toCodexUserInput,
type CodexFlowAppServerClient,
} from "../src/app-server/flows.ts";
test("normalizes text and structured input", () => {
expect(toCodexUserInput("hello")).toEqual([
{ type: "text", text: "hello", text_elements: [] },
]);
expect(
toCodexUserInput([
{ type: "text", text: "one" },
{ type: "localImage", path: "/tmp/image.png" },
]),
).toEqual([
{ type: "text", text: "one", text_elements: [] },
{ type: "localImage", path: "/tmp/image.png" },
]);
});
test("starts a new thread and turn with safe high-level options", async () => {
const fake = new FakeAppServerClient();
const flows = new CodexFlowClient({ client: fake });
const result = await flows.startFlow({
cwd: "/workspace/game",
prompt: "Prepare the run",
input: [{ type: "text", text: "extra input" }],
approvalPolicy: "never",
sandbox: "danger-full-access",
outputSchema: { type: "object" },
});
expect(result.threadId).toBe("thread-1");
expect(result.turnId).toBe("turn-1");
expect(fake.startThreadCalls).toEqual([
expect.objectContaining({
cwd: "/workspace/game",
approvalPolicy: "never",
sandbox: "danger-full-access",
experimentalRawEvents: false,
persistExtendedHistory: false,
}),
]);
expect(fake.startTurnCalls).toEqual([
expect.objectContaining({
threadId: "thread-1",
cwd: "/workspace/game",
approvalPolicy: "never",
outputSchema: { type: "object" },
input: [
{ type: "text", text: "Prepare the run", text_elements: [] },
{ type: "text", text: "extra input", text_elements: [] },
],
}),
]);
});
test("resumes an existing thread before starting a turn", async () => {
const fake = new FakeAppServerClient();
const flows = new CodexFlowClient({ client: fake });
await flows.startFlow({
threadId: "existing",
prompt: "continue",
cwd: "/workspace/game",
resume: { excludeTurns: false },
});
expect(fake.resumeThreadCalls).toEqual([
expect.objectContaining({
threadId: "existing",
cwd: "/workspace/game",
excludeTurns: false,
persistExtendedHistory: false,
}),
]);
expect(fake.startThreadCalls).toEqual([]);
expect(fake.startTurnCalls[0]?.threadId).toBe("existing");
});
test("waits for a turn/completed notification", async () => {
const fake = new FakeAppServerClient();
const flows = new CodexFlowClient({ client: fake });
const pending = flows.startFlow({
prompt: "wait for completion",
wait: { timeoutMs: 500, pollIntervalMs: 0 },
});
await eventually(() => {
expect(fake.notificationListenerCount()).toBe(1);
});
fake.emit("notification", {
method: "turn/completed",
params: {
threadId: "thread-1",
turn: turn("turn-1", "completed"),
},
});
const result = await pending;
expect(result.completedTurn?.status).toBe("completed");
});
test("waits by polling when completion notification was missed", async () => {
const fake = new FakeAppServerClient();
const flows = new CodexFlowClient({ client: fake });
const pending = flows.startFlow({
prompt: "wait by poll",
wait: { timeoutMs: 500, pollIntervalMs: 10 },
});
await eventually(() => {
expect(fake.startTurnCalls.length).toBe(1);
});
fake.setThreadTurns("thread-1", [turn("turn-1", "completed")]);
const result = await pending;
expect(result.completedTurn?.id).toBe("turn-1");
});
test("wait polling tolerates temporary thread materialization failures", async () => {
const fake = new FakeAppServerClient();
fake.enqueueReadThreadError("Thread not materialized yet");
const flows = new CodexFlowClient({ client: fake });
const pending = flows.startFlow({
prompt: "wait through materialization",
wait: { timeoutMs: 500, pollIntervalMs: 10 },
});
await eventually(() => {
expect(fake.startTurnCalls.length).toBe(1);
});
fake.setThreadTurns("thread-1", [turn("turn-1", "completed")]);
const result = await pending;
expect(result.completedTurn?.status).toBe("completed");
expect(fake.readThreadCalls.length).toBeGreaterThanOrEqual(2);
});
test("can throw when a waited turn fails", async () => {
const fake = new FakeAppServerClient();
const flows = new CodexFlowClient({ client: fake });
const pending = flows.startFlow({
prompt: "fail",
wait: { timeoutMs: 500, pollIntervalMs: 0, throwOnFailure: true },
});
await eventually(() => {
expect(fake.notificationListenerCount()).toBe(1);
});
fake.emit("notification", {
method: "turn/completed",
params: {
threadId: "thread-1",
turn: turn("turn-1", "failed", "bad turn"),
},
});
await expect(pending).rejects.toBeInstanceOf(CodexFlowTurnFailedError);
});
test("times out while waiting for a turn", async () => {
const fake = new FakeAppServerClient();
const flows = new CodexFlowClient({ client: fake });
await expect(
flows.startFlow({
prompt: "never completes",
wait: { timeoutMs: 10, pollIntervalMs: 0 },
}),
).rejects.toBeInstanceOf(CodexFlowTimeoutError);
});
class FakeAppServerClient implements CodexFlowAppServerClient {
startThreadCalls: v2.ThreadStartParams[] = [];
resumeThreadCalls: v2.ThreadResumeParams[] = [];
startTurnCalls: v2.TurnStartParams[] = [];
readThreadCalls: v2.ThreadReadParams[] = [];
#listeners = new Map<string, Set<(...args: unknown[]) => void>>();
#threads = new Map<string, v2.Thread>();
#readThreadErrors: string[] = [];
#nextThread = 1;
#nextTurn = 1;
async connect(): Promise<void> {}
close(): void {}
on(event: string, listener: (...args: any[]) => void): void {
const listeners = this.#listeners.get(event) ?? new Set();
listeners.add(listener as (...args: unknown[]) => void);
this.#listeners.set(event, listeners);
}
off(event: string, listener: (...args: any[]) => void): void {
this.#listeners.get(event)?.delete(listener as (...args: unknown[]) => void);
}
emit(event: string, ...args: unknown[]): void {
for (const listener of this.#listeners.get(event) ?? []) {
listener(...args);
}
}
notificationListenerCount(): number {
return this.#listeners.get("notification")?.size ?? 0;
}
enqueueReadThreadError(message: string): void {
this.#readThreadErrors.push(message);
}
async startThread(
params: v2.ThreadStartParams,
): Promise<v2.ThreadStartResponse> {
this.startThreadCalls.push(params);
const id = `thread-${this.#nextThread++}`;
const created = thread(id);
this.#threads.set(id, created);
return {
thread: created,
model: params.model ?? "gpt-test",
modelProvider: params.modelProvider ?? "openai",
serviceTier: params.serviceTier ?? null,
cwd: params.cwd ?? "",
runtimeWorkspaceRoots: [],
instructionSources: [],
approvalPolicy: params.approvalPolicy ?? "on-request",
approvalsReviewer: params.approvalsReviewer ?? "user",
sandbox: { type: "dangerFullAccess" },
activePermissionProfile: null,
reasoningEffort: null,
};
}
async resumeThread(
params: v2.ThreadResumeParams,
): Promise<v2.ThreadResumeResponse> {
this.resumeThreadCalls.push(params);
const resumed = this.#threads.get(params.threadId) ?? thread(params.threadId);
this.#threads.set(params.threadId, resumed);
return {
thread: resumed,
model: params.model ?? "gpt-test",
modelProvider: params.modelProvider ?? "openai",
serviceTier: params.serviceTier ?? null,
cwd: params.cwd ?? "",
runtimeWorkspaceRoots: [],
instructionSources: [],
approvalPolicy: params.approvalPolicy ?? "on-request",
approvalsReviewer: params.approvalsReviewer ?? "user",
sandbox: { type: "dangerFullAccess" },
activePermissionProfile: null,
reasoningEffort: null,
};
}
async readThread(params: v2.ThreadReadParams): Promise<v2.ThreadReadResponse> {
this.readThreadCalls.push(params);
const message = this.#readThreadErrors.shift();
if (message) {
throw new Error(message);
}
return {
thread: this.#threads.get(params.threadId) ?? thread(params.threadId),
};
}
async startTurn(params: v2.TurnStartParams): Promise<v2.TurnStartResponse> {
this.startTurnCalls.push(params);
const id = `turn-${this.#nextTurn++}`;
const started = turn(id, "inProgress");
const current = this.#threads.get(params.threadId) ?? thread(params.threadId);
this.#threads.set(params.threadId, {
...current,
turns: [...current.turns, started],
});
return { turn: started };
}
setThreadTurns(threadId: string, turns: v2.Turn[]): void {
const current = this.#threads.get(threadId) ?? thread(threadId);
this.#threads.set(threadId, { ...current, turns });
}
}
function thread(id: string, turns: v2.Turn[] = []): v2.Thread {
return {
id,
sessionId: id,
forkedFromId: null,
preview: "",
ephemeral: false,
modelProvider: "openai",
createdAt: 0,
updatedAt: 0,
status: { type: "idle" },
path: null,
cwd: "",
cliVersion: "test",
source: "appServer",
threadSource: null,
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns,
};
}
function turn(
id: string,
status: v2.TurnStatus,
message?: string,
): v2.Turn {
return {
id,
items: [],
itemsView: "full",
status,
error: message
? { message, codexErrorInfo: null, additionalDetails: null }
: null,
startedAt: 0,
completedAt: status === "inProgress" ? null : 1,
durationMs: status === "inProgress" ? null : 1,
};
}
async function eventually(assertion: () => void): Promise<void> {
const started = Date.now();
let lastError: unknown;
while (Date.now() - started < 500) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
throw lastError;
}

View file

@ -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_workspace", "list_flow_runs", "completed"),
item: dynamicToolItem("tool-1", "codex_workspace", "list_delegations", "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_workspace.list_flow_runs",
label: "codex_workspace.list_delegations",
status: "completed",
}),
]),

View file

@ -1,49 +0,0 @@
# @peezy.tech/flow-backend-convex
Reusable Convex backend primitives for `codex-flows`.
This package is the extracted version of the backend shape proven in
`2d-codex-pet-game`: Convex stores generic flow events, matching runs, run
attempts, leases, results, and compact output events. Process-heavy execution
still happens in an external worker that claims runs and executes `flow.toml`
steps through `@peezy.tech/codex-flows/flow-runtime`.
## Component Boundary
The component owns generic flow state only:
- synced flow manifests
- accepted `FlowEvent` records
- queued/running/completed/failed/canceled run records
- leased run attempts
- structured output events
- final result payloads
Installing apps own authentication and domain state. An app should expose
service-authenticated wrapper functions for external workers, then call this
component from those wrappers. Domain-specific completion, such as generated
asset registration or minting, should stay in app code.
## Component Install
```ts
// convex/convex.config.ts
import flowBackend from "@peezy.tech/flow-backend-convex/convex.config.js";
import { defineApp } from "convex/server";
const app = defineApp();
app.use(flowBackend);
export default app;
```
The app wrapper functions can call the installed component functions through
`components.flowBackend`. The worker-facing API should stay app-owned so each
deployment can enforce its own service secret, identity, or ACL.
## Current Transcript Strategy
The first component stores output chunks in `flowOutputEvents`. A future version
can add `@convex-dev/persistent-text-streaming` as a child component and map
each run attempt to a durable transcript stream. The canonical control state
should remain in this component's tables either way.

View file

@ -1,62 +0,0 @@
{
"name": "@peezy.tech/flow-backend-convex",
"version": "0.132.5",
"description": "Reusable Convex component for durable codex-flow event, run, lease, and result state.",
"type": "module",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/peezy-tech/codex-flows.git",
"directory": "packages/flow-backend-convex"
},
"keywords": [
"codex",
"flows",
"convex",
"convex-component"
],
"sideEffects": false,
"types": "./dist/index.d.ts",
"files": [
"dist",
"README.md"
],
"publishConfig": {
"access": "public"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./convex.config": {
"types": "./dist/component/convex.config.d.ts",
"import": "./dist/component/convex.config.js"
},
"./convex.config.js": {
"types": "./dist/component/convex.config.d.ts",
"import": "./dist/component/convex.config.js"
},
"./_generated/component.js": {
"types": "./dist/component/_generated/component.d.ts",
"import": "./dist/component/_generated/component.js"
}
},
"scripts": {
"build": "vp run --filter @peezy.tech/codex-flows build && vp run clean && tsc -p tsconfig.build.json",
"check:types": "tsc --noEmit",
"clean": "rm -rf dist",
"pack:dry-run": "npm pack --dry-run --json",
"prepack": "vp run build",
"release:check": "vp run test && vp run check:types && vp run build && vp run pack:dry-run",
"test": "vp test run --root ../.. packages/flow-backend-convex/test"
},
"dependencies": {
"@peezy.tech/codex-flows": "workspace:*",
"convex": "^1.38.0"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

View file

@ -1,91 +0,0 @@
import type { FlowEvent } from "@peezy.tech/codex-flows/flow-runtime";
import type { DispatchConvexFlowEventResult, SyncedFlowManifest } from "./types.ts";
export type StoredFlowRunInput = {
eventId: string;
flowName: string;
stepName: string;
replayNonce?: string;
};
export function flowRunId(input: StoredFlowRunInput): string {
return [
"run",
safeId(input.eventId),
safeId(input.flowName),
safeId(input.stepName),
...(input.replayNonce ? [safeId(input.replayNonce), "replay"] : []),
].join(":");
}
export function normalizeFlowEvent(value: unknown): FlowEvent {
if (!isRecord(value) || typeof value.id !== "string" || typeof value.type !== "string") {
throw new Error("FlowEvent requires string id and type");
}
return {
receivedAt: typeof value.receivedAt === "string" ? value.receivedAt : new Date().toISOString(),
payload: "payload" in value ? value.payload : {},
...value,
} as FlowEvent;
}
export function matchingManifestSteps(
manifests: SyncedFlowManifest[],
event: FlowEvent,
): Array<{ manifest: SyncedFlowManifest; step: SyncedFlowManifest["steps"][number] }> {
const matches: Array<{ manifest: SyncedFlowManifest; step: SyncedFlowManifest["steps"][number] }> = [];
for (const manifest of manifests) {
for (const step of manifest.steps) {
if (step.trigger?.type === event.type) {
matches.push({ manifest, step });
}
}
}
return matches;
}
export function duplicateDispatchResult(eventId: string, runIds: string[]): DispatchConvexFlowEventResult {
return {
status: "duplicate",
eventId,
runIds,
matched: 0,
};
}
export function acceptedDispatchResult(
eventId: string,
runIds: string[],
matched: number,
): DispatchConvexFlowEventResult {
return {
status: "accepted",
eventId,
runIds,
matched,
};
}
export function clampLimit(value: number | undefined): number {
if (!value || !Number.isFinite(value)) {
return 50;
}
return Math.max(1, Math.min(500, Math.trunc(value)));
}
export function leaseMs(value: number | undefined): number {
return Math.max(10_000, Math.min(value ?? 120_000, 30 * 60_000));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function safeId(value: string): string {
return (
value
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "") || "item"
);
}

View file

@ -1,50 +0,0 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as backend from "../backend.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
import { anyApi, componentsGeneric } from "convex/server";
const fullApi: ApiFromModules<{
backend: typeof backend;
}> = anyApi as any;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
> = anyApi as any;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
> = anyApi as any;
export const components = componentsGeneric() as unknown as {};

View file

@ -1,151 +0,0 @@
/* eslint-disable */
/**
* Generated `ComponentApi` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type { FunctionReference } from "convex/server";
/**
* A utility for referencing a Convex component's exposed API.
*
* Useful when expecting a parameter like `components.myComponent`.
* Usage:
* ```ts
* async function myFunction(ctx: QueryCtx, component: ComponentApi) {
* return ctx.runQuery(component.someFile.someQuery, { ...args });
* }
* ```
*/
export type ComponentApi<Name extends string | undefined = string | undefined> =
{
backend: {
appendRunOutput: FunctionReference<
"mutation",
"internal",
{
attemptId: string;
kind: "system" | "stdout" | "stderr" | "agent";
leaseToken: string;
text: string;
},
any,
Name
>;
cancelRun: FunctionReference<
"mutation",
"internal",
{ runId: string },
any,
Name
>;
claimRun: FunctionReference<
"mutation",
"internal",
{ leaseMs?: number; workerId: string },
any,
Name
>;
completeRun: FunctionReference<
"mutation",
"internal",
{ attemptId: string; leaseToken: string; result: any },
any,
Name
>;
dispatchEvent: FunctionReference<
"mutation",
"internal",
{
event: {
id: string;
occurredAt?: string;
payload: any;
receivedAt?: string;
source?: string;
type: string;
};
},
any,
Name
>;
failRun: FunctionReference<
"mutation",
"internal",
{ attemptId: string; error: string; leaseToken: string },
any,
Name
>;
getEvent: FunctionReference<
"query",
"internal",
{ eventId: string },
any,
Name
>;
getRun: FunctionReference<
"query",
"internal",
{ runId: string },
any,
Name
>;
heartbeatRun: FunctionReference<
"mutation",
"internal",
{ attemptId: string; leaseMs?: number; leaseToken: string },
any,
Name
>;
listEvents: FunctionReference<
"query",
"internal",
{ limit?: number; type?: string },
any,
Name
>;
listRuns: FunctionReference<
"query",
"internal",
{
eventId?: string;
limit?: number;
status?: "queued" | "running" | "completed" | "failed" | "canceled";
},
any,
Name
>;
replayEvent: FunctionReference<
"mutation",
"internal",
{ eventId: string },
any,
Name
>;
syncFlowManifest: FunctionReference<
"mutation",
"internal",
{
config?: any;
description?: string;
name: string;
root?: string;
steps: Array<{
cwd?: string;
name: string;
runner: "node";
script: string;
timeoutMs: number;
trigger?: { schema?: string; schemaJson?: any; type: string };
}>;
version: number;
},
any,
Name
>;
};
};

View file

@ -1,60 +0,0 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

View file

@ -1,156 +0,0 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query: QueryBuilder<DataModel, "public"> = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery: QueryBuilder<DataModel, "internal"> =
internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation: MutationBuilder<DataModel, "public"> = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation: MutationBuilder<DataModel, "internal"> =
internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action: ActionBuilder<DataModel, "public"> = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction: ActionBuilder<DataModel, "internal"> =
internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction: HttpActionBuilder = httpActionGeneric;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* If you're using code generation, use the `QueryCtx` type in `convex/_generated/server.d.ts` instead.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*
* If you're using code generation, use the `MutationCtx` type in `convex/_generated/server.d.ts` instead.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View file

@ -1,508 +0,0 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server.js";
import { flowEventArg, flowStepArg } from "./schema.js";
const runStatusArg = v.union(
v.literal("queued"),
v.literal("running"),
v.literal("completed"),
v.literal("failed"),
v.literal("canceled"),
);
export const syncFlowManifest = mutation({
args: {
name: v.string(),
version: v.number(),
description: v.optional(v.string()),
root: v.optional(v.string()),
config: v.optional(v.any()),
steps: v.array(flowStepArg),
},
handler: async (ctx, args) => {
const now = Date.now();
const existing = await ctx.db
.query("flowManifests")
.withIndex("by_name", (q) => q.eq("name", args.name))
.unique();
const manifest = {
name: args.name,
version: args.version,
description: args.description,
root: args.root,
config: args.config,
steps: args.steps,
syncedAt: now,
updatedAt: now,
};
if (existing) {
await ctx.db.patch(existing._id, manifest);
return { manifestId: existing._id, status: "updated" };
}
return {
manifestId: await ctx.db.insert("flowManifests", manifest),
status: "created",
};
},
});
export const dispatchEvent = mutation({
args: {
event: flowEventArg,
},
handler: async (ctx, args) => {
return dispatchFlowEvent(ctx, { event: args.event });
},
});
export const replayEvent = mutation({
args: {
eventId: v.string(),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("flowEvents")
.withIndex("by_event_id", (q) => q.eq("eventId", args.eventId))
.unique();
if (!existing) {
throw new Error(`Unknown flow event: ${args.eventId}`);
}
return dispatchFlowEvent(ctx, {
event: existing.raw,
replayNonce: String(Date.now()),
});
},
});
export const claimRun = mutation({
args: {
workerId: v.string(),
leaseMs: v.optional(v.number()),
},
handler: async (ctx, args) => {
const now = Date.now();
const expired = await ctx.db
.query("flowRunAttempts")
.withIndex("by_status_lease", (q) =>
q.eq("status", "running").lt("leaseExpiresAt", now),
)
.first();
if (expired) {
await ctx.db.patch(expired._id, {
status: "failed",
error: "Lease expired before worker heartbeat.",
updatedAt: now,
completedAt: now,
});
const expiredRun = await runById(ctx, expired.runId);
if (expiredRun && expiredRun.status === "running") {
return claimExistingRun(ctx, expiredRun, args.workerId, leaseMs(args.leaseMs));
}
}
const queued = await ctx.db
.query("flowRuns")
.withIndex("by_status_created", (q) => q.eq("status", "queued"))
.order("asc")
.first();
if (!queued) return null;
return claimExistingRun(ctx, queued, args.workerId, leaseMs(args.leaseMs));
},
});
export const heartbeatRun = mutation({
args: {
attemptId: v.string(),
leaseToken: v.string(),
leaseMs: v.optional(v.number()),
},
handler: async (ctx, args) => {
const attempt = await assertAttemptLease(ctx, args.attemptId, args.leaseToken);
const now = Date.now();
const nextLeaseExpiresAt = now + leaseMs(args.leaseMs);
await ctx.db.patch(attempt._id, {
leaseExpiresAt: nextLeaseExpiresAt,
lastHeartbeatAt: now,
updatedAt: now,
});
return { status: "running", leaseExpiresAt: nextLeaseExpiresAt };
},
});
export const appendRunOutput = mutation({
args: {
attemptId: v.string(),
leaseToken: v.string(),
kind: v.union(
v.literal("system"),
v.literal("stdout"),
v.literal("stderr"),
v.literal("agent"),
),
text: v.string(),
},
handler: async (ctx, args) => {
const attempt = await assertAttemptLease(ctx, args.attemptId, args.leaseToken);
return ctx.db.insert("flowOutputEvents", {
attemptId: args.attemptId,
runId: attempt.runId,
kind: args.kind,
text: args.text,
createdAt: Date.now(),
});
},
});
export const completeRun = mutation({
args: {
attemptId: v.string(),
leaseToken: v.string(),
result: v.any(),
},
handler: async (ctx, args) => {
const attempt = await assertAttemptLease(ctx, args.attemptId, args.leaseToken);
const run = await runById(ctx, attempt.runId);
if (!run) throw new Error(`Unknown flow run: ${attempt.runId}`);
const now = Date.now();
await ctx.db.patch(attempt._id, {
status: "completed",
result: args.result,
updatedAt: now,
completedAt: now,
});
await ctx.db.patch(run._id, {
status: "completed",
result: args.result,
updatedAt: now,
completedAt: now,
});
return { status: "completed", runId: attempt.runId };
},
});
export const failRun = mutation({
args: {
attemptId: v.string(),
leaseToken: v.string(),
error: v.string(),
},
handler: async (ctx, args) => {
const attempt = await assertAttemptLease(ctx, args.attemptId, args.leaseToken);
const run = await runById(ctx, attempt.runId);
if (!run) throw new Error(`Unknown flow run: ${attempt.runId}`);
const now = Date.now();
await ctx.db.patch(attempt._id, {
status: "failed",
error: args.error,
updatedAt: now,
completedAt: now,
});
await ctx.db.patch(run._id, {
status: "failed",
error: args.error,
updatedAt: now,
completedAt: now,
});
return { status: "failed", runId: attempt.runId };
},
});
export const cancelRun = mutation({
args: {
runId: v.string(),
},
handler: async (ctx, args) => {
const run = await runById(ctx, args.runId);
if (!run) throw new Error(`Unknown flow run: ${args.runId}`);
if (run.status === "completed") {
throw new Error(`Cannot cancel completed flow run: ${args.runId}`);
}
const now = Date.now();
await ctx.db.patch(run._id, {
status: "canceled",
updatedAt: now,
completedAt: now,
});
return { status: "canceled", runId: args.runId };
},
});
export const listEvents = query({
args: {
type: v.optional(v.string()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = clampLimit(args.limit);
const eventType = args.type;
if (eventType) {
return ctx.db
.query("flowEvents")
.withIndex("by_type_created", (q) => q.eq("type", eventType))
.order("desc")
.take(limit);
}
return ctx.db.query("flowEvents").order("desc").take(limit);
},
});
export const getEvent = query({
args: {
eventId: v.string(),
},
handler: async (ctx, args) => {
const event = await ctx.db
.query("flowEvents")
.withIndex("by_event_id", (q) => q.eq("eventId", args.eventId))
.unique();
if (!event) return null;
const runs = await ctx.db
.query("flowRuns")
.withIndex("by_event_id", (q) => q.eq("eventId", args.eventId))
.collect();
return { ...event, runs };
},
});
export const listRuns = query({
args: {
eventId: v.optional(v.string()),
status: v.optional(runStatusArg),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = clampLimit(args.limit);
const eventId = args.eventId;
if (eventId) {
return ctx.db
.query("flowRuns")
.withIndex("by_event_id", (q) => q.eq("eventId", eventId))
.order("desc")
.take(limit);
}
const status = args.status;
if (status) {
return ctx.db
.query("flowRuns")
.withIndex("by_status_created", (q) => q.eq("status", status))
.order("desc")
.take(limit);
}
return ctx.db.query("flowRuns").order("desc").take(limit);
},
});
export const getRun = query({
args: {
runId: v.string(),
},
handler: async (ctx, args) => {
const run = await runById(ctx, args.runId);
if (!run) return null;
const attempts = await ctx.db
.query("flowRunAttempts")
.withIndex("by_run_id", (q) => q.eq("runId", args.runId))
.collect();
const output = await ctx.db
.query("flowOutputEvents")
.withIndex("by_run", (q) => q.eq("runId", args.runId))
.order("asc")
.take(500);
return { ...run, attempts, output };
},
});
async function dispatchFlowEvent(
ctx: any,
args: {
event: {
id: string;
type: string;
source?: string;
occurredAt?: string;
receivedAt?: string;
payload: any;
};
replayNonce?: string;
},
) {
const now = Date.now();
const event = {
...args.event,
receivedAt: args.event.receivedAt ?? new Date(now).toISOString(),
};
const existing = await ctx.db
.query("flowEvents")
.withIndex("by_event_id", (q: any) => q.eq("eventId", event.id))
.unique();
if (existing && !args.replayNonce) {
const runs = await ctx.db
.query("flowRuns")
.withIndex("by_event_id", (q: any) => q.eq("eventId", event.id))
.collect();
return {
status: "duplicate",
eventId: event.id,
runIds: runs.map((run: any) => run.runId),
matched: 0,
};
}
if (!existing) {
await ctx.db.insert("flowEvents", {
eventId: event.id,
type: event.type,
source: event.source,
occurredAt: event.occurredAt,
receivedAt: event.receivedAt,
payload: event.payload,
raw: event,
createdAt: now,
});
}
const manifests = await ctx.db.query("flowManifests").collect();
const matches = [];
for (const manifest of manifests) {
for (const step of manifest.steps) {
if (step.trigger?.type === event.type) {
matches.push({ manifest, step });
}
}
}
const runIds: string[] = [];
for (const match of matches) {
const runId = flowRunId(event.id, match.manifest.name, match.step.name, args.replayNonce);
const existingRun = await runById(ctx, runId);
if (existingRun) {
runIds.push(existingRun.runId);
continue;
}
await ctx.db.insert("flowRuns", {
runId,
eventId: event.id,
flowName: match.manifest.name,
flowVersion: match.manifest.version,
stepName: match.step.name,
runner: match.step.runner,
status: "queued",
attemptCount: 0,
createdAt: now,
updatedAt: now,
});
runIds.push(runId);
}
return {
status: "accepted",
eventId: event.id,
runIds,
matched: matches.length,
};
}
async function claimExistingRun(
ctx: any,
run: any,
workerId: string,
leaseDurationMs: number,
) {
const now = Date.now();
const attemptNumber = run.attemptCount + 1;
const attemptId = `${run.runId}:attempt:${attemptNumber}`;
const leaseToken = `${attemptId}:${workerId}:${now}`;
await ctx.db.insert("flowRunAttempts", {
attemptId,
runId: run.runId,
eventId: run.eventId,
flowName: run.flowName,
stepName: run.stepName,
attemptNumber,
status: "running",
workerId,
leaseToken,
leaseExpiresAt: now + leaseDurationMs,
lastHeartbeatAt: now,
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(run._id, {
status: "running",
attemptCount: attemptNumber,
latestAttemptId: attemptId,
startedAt: run.startedAt ?? now,
updatedAt: now,
});
const event = await ctx.db
.query("flowEvents")
.withIndex("by_event_id", (q: any) => q.eq("eventId", run.eventId))
.unique();
return {
runId: run.runId,
attemptId,
leaseToken,
leaseExpiresAt: now + leaseDurationMs,
flowName: run.flowName,
stepName: run.stepName,
runner: run.runner,
event: event?.raw,
};
}
async function assertAttemptLease(ctx: any, attemptId: string, leaseToken: string) {
const attempt = await ctx.db
.query("flowRunAttempts")
.withIndex("by_attempt_id", (q: any) => q.eq("attemptId", attemptId))
.unique();
if (!attempt) throw new Error(`Unknown flow run attempt: ${attemptId}`);
if (attempt.status !== "running" || attempt.leaseToken !== leaseToken) {
throw new Error("Flow run attempt is not leased by this worker.");
}
if (attempt.leaseExpiresAt < Date.now()) {
throw new Error("Flow run attempt lease expired.");
}
return attempt;
}
async function runById(ctx: any, runId: string) {
return ctx.db
.query("flowRuns")
.withIndex("by_run_id", (q: any) => q.eq("runId", runId))
.unique();
}
function flowRunId(
eventId: string,
flowName: string,
stepName: string,
replayNonce?: string,
): string {
return [
"run",
safeId(eventId),
safeId(flowName),
safeId(stepName),
...(replayNonce ? [safeId(replayNonce), "replay"] : []),
].join(":");
}
function safeId(value: string): string {
return (
value
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
|| "item"
);
}
function leaseMs(value: number | undefined): number {
return Math.max(10_000, Math.min(value ?? 120_000, 30 * 60_000));
}
function clampLimit(value: number | undefined): number {
if (!value || !Number.isFinite(value)) return 50;
return Math.max(1, Math.min(Math.trunc(value), 500));
}

View file

@ -1,5 +0,0 @@
import { defineComponent } from "convex/server";
const component = defineComponent("flowBackend");
export default component;

View file

@ -1,124 +0,0 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
const flowStep = v.object({
name: v.string(),
runner: v.literal("node"),
script: v.string(),
timeoutMs: v.number(),
cwd: v.optional(v.string()),
trigger: v.optional(
v.object({
type: v.string(),
schema: v.optional(v.string()),
schemaJson: v.optional(v.any()),
}),
),
});
export const flowEventArg = v.object({
id: v.string(),
type: v.string(),
source: v.optional(v.string()),
occurredAt: v.optional(v.string()),
receivedAt: v.optional(v.string()),
payload: v.any(),
});
export const flowStepArg = flowStep;
export default defineSchema({
flowManifests: defineTable({
name: v.string(),
version: v.number(),
description: v.optional(v.string()),
root: v.optional(v.string()),
config: v.optional(v.any()),
steps: v.array(flowStep),
syncedAt: v.number(),
updatedAt: v.number(),
}).index("by_name", ["name"]),
flowEvents: defineTable({
eventId: v.string(),
type: v.string(),
source: v.optional(v.string()),
occurredAt: v.optional(v.string()),
receivedAt: v.string(),
payload: v.any(),
raw: v.any(),
createdAt: v.number(),
})
.index("by_event_id", ["eventId"])
.index("by_type_created", ["type", "createdAt"]),
flowRuns: defineTable({
runId: v.string(),
eventId: v.string(),
flowName: v.string(),
flowVersion: v.number(),
stepName: v.string(),
runner: v.literal("node"),
status: v.union(
v.literal("queued"),
v.literal("running"),
v.literal("completed"),
v.literal("failed"),
v.literal("canceled"),
),
attemptCount: v.number(),
latestAttemptId: v.optional(v.string()),
result: v.optional(v.any()),
error: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
startedAt: v.optional(v.number()),
completedAt: v.optional(v.number()),
})
.index("by_run_id", ["runId"])
.index("by_event_id", ["eventId"])
.index("by_status_created", ["status", "createdAt"]),
flowRunAttempts: defineTable({
attemptId: v.string(),
runId: v.string(),
eventId: v.string(),
flowName: v.string(),
stepName: v.string(),
attemptNumber: v.number(),
status: v.union(
v.literal("running"),
v.literal("completed"),
v.literal("failed"),
v.literal("canceled"),
),
workerId: v.string(),
leaseToken: v.string(),
leaseExpiresAt: v.number(),
lastHeartbeatAt: v.number(),
transcriptStreamId: v.optional(v.string()),
result: v.optional(v.any()),
error: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
completedAt: v.optional(v.number()),
})
.index("by_attempt_id", ["attemptId"])
.index("by_run_id", ["runId"])
.index("by_status_lease", ["status", "leaseExpiresAt"]),
flowOutputEvents: defineTable({
attemptId: v.string(),
runId: v.string(),
kind: v.union(
v.literal("system"),
v.literal("stdout"),
v.literal("stderr"),
v.literal("agent"),
),
text: v.string(),
createdAt: v.number(),
})
.index("by_attempt", ["attemptId", "createdAt"])
.index("by_run", ["runId", "createdAt"]),
});

View file

@ -1,19 +0,0 @@
export {
acceptedDispatchResult,
clampLimit,
duplicateDispatchResult,
flowRunId,
leaseMs,
matchingManifestSteps,
normalizeFlowEvent,
} from "./backend-model.ts";
export type {
ClaimedConvexFlowRun,
CompleteConvexFlowRunInput,
ConvexFlowAttemptStatus,
ConvexFlowOutputKind,
ConvexFlowRunStatus,
DispatchConvexFlowEventResult,
SyncedFlowManifest,
SyncedFlowStep,
} from "./types.ts";

View file

@ -1,51 +0,0 @@
import type { FlowEvent, FlowResult, FlowStep } from "@peezy.tech/codex-flows/flow-runtime";
export type ConvexFlowRunStatus =
| "queued"
| "running"
| "completed"
| "failed"
| "canceled";
export type ConvexFlowAttemptStatus = Exclude<ConvexFlowRunStatus, "queued">;
export type ConvexFlowOutputKind = "system" | "stdout" | "stderr" | "agent";
export type SyncedFlowStep = FlowStep & {
trigger?: FlowStep["trigger"] & {
schemaJson?: unknown;
};
};
export type SyncedFlowManifest = {
name: string;
version: number;
description?: string;
root?: string;
config?: Record<string, unknown>;
steps: SyncedFlowStep[];
};
export type ClaimedConvexFlowRun<TPayload = unknown> = {
runId: string;
attemptId: string;
leaseToken: string;
leaseExpiresAt: number;
flowName: string;
stepName: string;
runner: FlowStep["runner"];
event: FlowEvent<TPayload>;
};
export type DispatchConvexFlowEventResult = {
status: "accepted" | "duplicate";
eventId: string;
runIds: string[];
matched: number;
};
export type CompleteConvexFlowRunInput = {
attemptId: string;
leaseToken: string;
result: FlowResult;
};

View file

@ -1,87 +0,0 @@
import { expect, test } from "vite-plus/test";
import {
acceptedDispatchResult,
duplicateDispatchResult,
flowRunId,
matchingManifestSteps,
normalizeFlowEvent,
} from "../src/backend-model.ts";
test("normalizes generic flow events", () => {
const event = normalizeFlowEvent({
id: "event-1",
type: "pet-game.player_asset_generation.requested",
payload: { requestId: "request-1" },
});
expect(event).toMatchObject({
id: "event-1",
type: "pet-game.player_asset_generation.requested",
payload: { requestId: "request-1" },
});
expect(typeof event.receivedAt).toBe("string");
});
test("matches synced manifest steps by event type", () => {
const matches = matchingManifestSteps(
[
{
name: "player-character-asset",
version: 1,
steps: [
{
name: "generate",
runner: "node",
script: "exec/generate.ts",
timeoutMs: 1000,
trigger: { type: "pet-game.player_asset_generation.requested" },
},
],
},
],
{
id: "event-1",
type: "pet-game.player_asset_generation.requested",
receivedAt: "2026-05-13T00:00:00.000Z",
payload: {},
},
);
expect(matches.map((match) => `${match.manifest.name}/${match.step.name}`)).toEqual([
"player-character-asset/generate",
]);
});
test("builds stable run ids and dispatch result shapes", () => {
const first = flowRunId({
eventId: "event-1",
flowName: "player-character-asset",
stepName: "generate",
});
const second = flowRunId({
eventId: "event-1",
flowName: "player-character-asset",
stepName: "generate",
});
const replay = flowRunId({
eventId: "event-1",
flowName: "player-character-asset",
stepName: "generate",
replayNonce: "1",
});
expect(first).toBe(second);
expect(replay).not.toBe(first);
expect(acceptedDispatchResult("event-1", [first], 1)).toEqual({
status: "accepted",
eventId: "event-1",
runIds: [first],
matched: 1,
});
expect(duplicateDispatchResult("event-1", [first])).toEqual({
status: "duplicate",
eventId: "event-1",
runIds: [first],
matched: 0,
});
});

View file

@ -1,17 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": false,
"noEmit": false,
"noEmitOnError": true,
"outDir": "dist",
"paths": {},
"rootDir": "src",
"sourceMap": true,
"rewriteRelativeImportExtensions": true
},
"include": ["src/**/*.ts"],
"exclude": ["test/**/*.ts", "dist"]
}

View file

@ -1,28 +0,0 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"target": "ES2022",
"lib": ["ESNext", "DOM"],
"strict": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"types": ["node"],
"baseUrl": ".",
"paths": {
"@peezy.tech/codex-flows": ["../codex-client/src/index.ts"],
"@peezy.tech/codex-flows/flows": ["../codex-client/src/app-server/flows.ts"],
"@peezy.tech/codex-flows/flow-runtime": ["../flow-runtime/src/index.ts"],
"@peezy.tech/codex-flows/flow-runtime/*": ["../flow-runtime/src/*"],
"@peezy.tech/codex-flows/workspace-backend": ["../codex-client/src/workspace-backend/index.ts"]
}
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}

View file

@ -1,113 +0,0 @@
# @peezy.tech/flow-runtime
Compatibility package for the standalone flow runtime. New code should prefer
the consolidated core export at `@peezy.tech/codex-flows/flow-runtime`.
Generic runtime primitives for Codex flow packages.
This package loads `flow.toml` manifests, matches generic events to flow steps,
validates JSON-schema payloads, and runs steps with the Node runner.
```ts
import { discoverFlows, matchingSteps, runFlowStep } from "@peezy.tech/flow-runtime";
```
## Node Step Helpers
`runner = "node"` still supports raw scripts that read JSON from stdin and print
`FLOW_RESULT`. The recommended authoring shape is a module default export:
```ts
import {
defineNodeFlow,
createCodexFlowClientFromContext,
} from "@peezy.tech/flow-runtime/node";
export default defineNodeFlow(async (ctx) => {
const codex = createCodexFlowClientFromContext(ctx);
try {
const turn = await codex.startFlow({
threadId: typeof ctx.flow.event.payload.threadId === "string"
? ctx.flow.event.payload.threadId
: undefined,
prompt: "Continue this workspace task.",
wait: false,
});
return {
status: "needs_intervention",
artifacts: { threadId: turn.threadId, turnId: turn.turnId },
};
} finally {
codex.close();
}
});
```
The helper uses `ctx.runtime.workspaceBackendUrl` or
`CODEX_WORKSPACE_BACKEND_WS_URL` so the step calls the same workspace backend
that launched the run.
## Flow Client
`@peezy.tech/flow-runtime/client` exposes a small flow-native client factory for
product code that should not care whether flows run locally or through an HTTP
backend:
```ts
import { createFlowClient } from "@peezy.tech/flow-runtime/client";
const flows = createFlowClient({
mode: "local",
cwd: process.cwd(),
});
await flows.dispatchEvent({
id: "patch:upstream.release:openai/codex:rust-v1.2.3",
type: "upstream.release",
source: "patch",
receivedAt: new Date().toISOString(),
payload: { repo: "openai/codex", tag: "rust-v1.2.3" },
});
```
Use `mode: "http"` to wrap the existing backend HTTP client:
```ts
const flows = createFlowClient({
mode: "http",
baseUrl: "http://127.0.0.1:7345",
hmacSecret: process.env.PATCH_FLOW_DISPATCH_SECRET,
});
```
`@peezy.tech/flow-runtime/local-client` runs matching steps synchronously in the
selected workspace and keeps in-memory run/event state by default. Set
`state: { kind: "file" }` to persist local run/event state under
`.codex/flow-client`. It preserves the generic `FlowEvent` and `FLOW_RESULT`
contracts; callers still provide deterministic event ids when idempotency
matters.
## Backend Client
`@peezy.tech/flow-runtime/backend-client` exposes backend-native inspection and
control for generic flow state. It is intentionally separate from app-server
thread commands: runs, events, attempts, replay, cancel, output, and
`FLOW_RESULT` payloads belong to flow backends.
```ts
import { createFlowBackendHttpClient } from "@peezy.tech/flow-runtime/backend-client";
const backend = createFlowBackendHttpClient({
baseUrl: "http://127.0.0.1:7345",
bearerToken: process.env.CODEX_FLOW_BACKEND_TOKEN,
});
const { runs } = await backend.listRuns({ status: "completed", limit: 20 });
```
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
`needs_intervention` are read from `FLOW_RESULT` payloads when the backend
stores them separately from process status.

View file

@ -1,67 +0,0 @@
{
"name": "@peezy.tech/flow-runtime",
"version": "0.132.5",
"description": "Generic flow package loader and runner primitives.",
"type": "module",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/peezy-tech/codex-flows.git",
"directory": "packages/flow-runtime"
},
"keywords": [
"codex",
"flows",
"automation",
"flow-runtime"
],
"sideEffects": false,
"types": "./dist/index.d.ts",
"files": [
"dist",
"README.md"
],
"publishConfig": {
"access": "public"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./backend-client": {
"types": "./dist/backend-client.d.ts",
"import": "./dist/backend-client.js"
},
"./local-client": {
"types": "./dist/local-client.d.ts",
"import": "./dist/local-client.js"
},
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.js"
},
"./node": {
"types": "./dist/node.d.ts",
"import": "./dist/node.js"
}
},
"scripts": {
"build": "vp run --filter @peezy.tech/codex-flows build && vp run clean && tsc -p tsconfig.build.json",
"check:types": "tsc --noEmit",
"clean": "rm -rf dist",
"pack:dry-run": "npm pack --dry-run --json",
"prepack": "vp run build",
"release:check": "vp run test && vp run check:types && vp run build && vp run pack:dry-run",
"test": "vp run --filter @peezy.tech/codex-flows build && vp test run --root ../.. packages/flow-runtime/test"
},
"dependencies": {
"@peezy.tech/codex-flows": "workspace:*",
"smol-toml": "catalog:",
"tsx": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

View file

@ -1,399 +0,0 @@
import { createHmac } from "node:crypto";
import type {
FlowAttemptView,
FlowCancelResult,
FlowClient,
FlowDispatchOptions,
FlowDispatchResult,
FlowEffectiveStatus,
FlowEventList,
FlowEventView,
FlowListEventsOptions,
FlowListRunsOptions,
FlowOutputView,
FlowProcessStatus,
FlowReplayOptions,
FlowReplayResult,
FlowRunList,
FlowRunView,
} from "./client-types.ts";
import type { FlowEvent, FlowResultStatus } from "./types.ts";
export type FlowBackendProcessStatus = FlowProcessStatus;
export type FlowBackendEffectiveStatus = FlowEffectiveStatus;
export type FlowBackendListRunsOptions = FlowListRunsOptions;
export type FlowBackendListEventsOptions = FlowListEventsOptions;
export type FlowBackendDispatchOptions = FlowDispatchOptions;
export type FlowBackendReplayOptions = FlowReplayOptions;
export type FlowBackendHttpHeaders =
| Headers
| Record<string, string>
| Array<[string, string]>;
export type FlowBackendFetch = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
export type FlowBackendOutputView = FlowOutputView;
export type FlowBackendAttemptView = FlowAttemptView;
export type FlowBackendRunView = FlowRunView;
export type FlowBackendEventView = FlowEventView;
export type FlowBackendRunList = FlowRunList;
export type FlowBackendEventList = FlowEventList;
export type FlowBackendDispatchResult = FlowDispatchResult;
export type FlowBackendReplayResult = FlowReplayResult;
export type FlowBackendCancelResult = FlowCancelResult;
export type FlowBackendClient = FlowClient;
export type FlowBackendHttpClientOptions = {
baseUrl: string;
fetch?: FlowBackendFetch;
headers?:
| FlowBackendHttpHeaders
| (() => FlowBackendHttpHeaders | Promise<FlowBackendHttpHeaders>);
bearerToken?: string;
apiKey?: string;
hmacSecret?: string;
};
const resultStatuses = new Set<FlowResultStatus>([
"skipped",
"completed",
"changed",
"needs_intervention",
"blocked",
"failed",
]);
const attentionStatuses = new Set(["blocked", "needs_intervention"]);
export class FlowBackendHttpClient implements FlowBackendClient {
#baseUrl: string;
#fetch: FlowBackendFetch;
#headers: FlowBackendHttpClientOptions["headers"];
#bearerToken: string | undefined;
#apiKey: string | undefined;
#hmacSecret: string | undefined;
constructor(options: FlowBackendHttpClientOptions) {
this.#baseUrl = options.baseUrl;
this.#fetch = options.fetch ?? fetch;
this.#headers = options.headers;
this.#bearerToken = options.bearerToken;
this.#apiKey = options.apiKey;
this.#hmacSecret = options.hmacSecret;
}
async listRuns(
options: FlowBackendListRunsOptions = {},
): Promise<FlowBackendRunList> {
const raw = await this.#request("GET", "/runs", undefined, options);
return normalizeRunList(raw);
}
async getRun(runId: string): Promise<FlowBackendRunView> {
const raw = await this.#request("GET", `/runs/${encodeURIComponent(runId)}`);
return normalizeRun(record(raw).run ?? raw);
}
async listEvents(
options: FlowBackendListEventsOptions = {},
): Promise<FlowBackendEventList> {
const raw = await this.#request("GET", "/events", undefined, options);
return normalizeEventList(raw);
}
async getEvent(eventId: string): Promise<FlowBackendEventView> {
const raw = await this.#request("GET", `/events/${encodeURIComponent(eventId)}`);
return normalizeEvent(record(raw).event ?? raw, record(raw).runs);
}
async dispatchEvent(
event: FlowEvent,
options: FlowBackendDispatchOptions = {},
): Promise<FlowBackendDispatchResult> {
const raw = await this.#request("POST", "/events", event, options);
return normalizeDispatchResult(raw);
}
async replayEvent(
eventId: string,
options: FlowBackendReplayOptions = {},
): Promise<FlowBackendReplayResult> {
const raw = await this.#request(
"POST",
`/events/${encodeURIComponent(eventId)}/replay`,
options,
);
return normalizeDispatchResult(raw);
}
async cancelRun(runId: string): Promise<FlowBackendCancelResult> {
const raw = await this.#request(
"POST",
`/runs/${encodeURIComponent(runId)}/cancel`,
{},
);
return {
run: normalizeRun(record(raw).run ?? raw),
raw,
};
}
async #request(
method: string,
pathname: string,
body?: unknown,
query: Record<string, unknown> = {},
): Promise<unknown> {
const url = new URL(
pathname.replace(/^\/+/, ""),
this.#baseUrl.endsWith("/") ? this.#baseUrl : `${this.#baseUrl}/`,
);
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
}
const bodyText = body === undefined ? undefined : JSON.stringify(body);
const headers = await this.#requestHeaders(bodyText);
const response = await this.#fetch(url, {
method,
headers,
...(bodyText === undefined ? {} : { body: bodyText }),
});
if (!response.ok) {
throw new Error(`Flow backend ${method} ${url.pathname} failed with ${response.status}`);
}
return response.json();
}
async #requestHeaders(bodyText: string | undefined): Promise<Headers> {
const headers = new Headers(
typeof this.#headers === "function"
? await this.#headers()
: this.#headers,
);
if (this.#bearerToken) {
headers.set("authorization", `Bearer ${this.#bearerToken}`);
}
if (this.#apiKey) {
headers.set("x-api-key", this.#apiKey);
}
if (bodyText !== undefined) {
headers.set("content-type", "application/json");
}
if (this.#hmacSecret && bodyText !== undefined) {
headers.set("x-flow-signature-256", hmacSignature(this.#hmacSecret, bodyText));
}
return headers;
}
}
export function createFlowBackendHttpClient(
options: FlowBackendHttpClientOptions,
): FlowBackendHttpClient {
return new FlowBackendHttpClient(options);
}
export function normalizeRunList(raw: unknown): FlowBackendRunList {
const value = record(raw);
return {
runs: arrayValue(value.runs).map(normalizeRun),
...(typeof value.eventId === "string" ? { eventId: value.eventId } : {}),
raw,
};
}
export function normalizeEventList(raw: unknown): FlowBackendEventList {
const value = record(raw);
return {
events: arrayValue(value.events).map((event) => normalizeEvent(event)),
raw,
};
}
export function normalizeDispatchResult(raw: unknown): FlowBackendDispatchResult {
const value = record(raw);
const runIds = arrayValue(value.runIds).map(String);
const runs = arrayValue(value.runs).map(normalizeRun);
const event = value.event ? normalizeEvent(value.event) : undefined;
return {
...(typeof value.status === "string" ? { status: value.status } : {}),
...(typeof value.eventId === "string" ? { eventId: value.eventId } : {}),
runIds: runIds.length > 0 ? runIds : runs.map((run) => run.id),
...(typeof value.matched === "number" ? { matched: value.matched } : {}),
...(typeof value.idempotent === "boolean" ? { idempotent: value.idempotent } : {}),
...(event ? { event } : {}),
runs,
raw,
};
}
export function normalizeEvent(raw: unknown, runsInput?: unknown): FlowBackendEventView {
const value = record(raw);
const rawRuns = Array.isArray(runsInput)
? runsInput
: Array.isArray(value.runs)
? value.runs
: [];
const runs = rawRuns.map(normalizeRun);
const id = stringValue(value.id) ?? stringValue(value.eventId) ?? "";
return {
id,
...(stringValue(value.type) ? { type: stringValue(value.type) } : {}),
...(stringValue(value.source) ? { source: stringValue(value.source) } : {}),
...(stringValue(value.occurredAt) ? { occurredAt: stringValue(value.occurredAt) } : {}),
...(stringValue(value.receivedAt) ? { receivedAt: stringValue(value.receivedAt) } : {}),
...("payload" in value ? { payload: value.payload } : {}),
runIds: arrayValue(value.runIds).map(String).concat(runs.map((run) => run.id)).filter(unique),
runs,
...(stringValue(value.createdAt) ? { createdAt: stringValue(value.createdAt) } : {}),
raw,
};
}
export function normalizeRun(raw: unknown): FlowBackendRunView {
const value = record(raw);
const id = stringValue(value.id) ?? stringValue(value.runId) ?? "";
const processStatus = stringValue(value.status);
const resultPayload = parseResultPayload(value);
const resultStatus = resultStatusFrom(resultPayload) ??
resultStatusFromStatus(processStatus);
const effectiveStatus = resultStatus ?? processStatus ?? "unknown";
const attempts = arrayValue(value.attempts).map(normalizeAttempt);
const output = normalizeOutput(value);
const attemptCount = numberValue(value.attemptCount) ??
numberValue(value.attempts) ??
attempts.length;
return {
id,
...(stringValue(value.eventId) ? { eventId: stringValue(value.eventId) } : {}),
...(stringValue(value.flowName) ? { flowName: stringValue(value.flowName) } : {}),
...(numberValue(value.flowVersion) !== undefined ? { flowVersion: numberValue(value.flowVersion) } : {}),
...(stringValue(value.stepName) ? { stepName: stringValue(value.stepName) } : {}),
...(stringValue(value.runner) ? { runner: stringValue(value.runner) } : {}),
...(stringValue(value.backend) ? { backend: stringValue(value.backend) } : {}),
...(processStatus ? { processStatus } : {}),
...(resultStatus ? { resultStatus } : {}),
status: effectiveStatus,
effectiveStatus,
needsAttention: attentionStatuses.has(effectiveStatus),
attemptCount,
attempts,
output,
...(output.at(-1) ? { latestOutput: output.at(-1) } : {}),
...(resultPayload !== undefined ? { resultPayload } : {}),
...(stringValue(value.error) ? { error: stringValue(value.error) } : {}),
...(stringValue(value.createdAt) ? { createdAt: stringValue(value.createdAt) } : {}),
...(stringValue(value.startedAt) ? { startedAt: stringValue(value.startedAt) } : {}),
...(stringValue(value.completedAt) ? { completedAt: stringValue(value.completedAt) } : {}),
...(stringValue(value.finishedAt) ? { completedAt: stringValue(value.finishedAt) } : {}),
...(stringValue(value.updatedAt) ? { updatedAt: stringValue(value.updatedAt) } : {}),
raw,
};
}
function normalizeAttempt(raw: unknown): FlowBackendAttemptView {
const value = record(raw);
return {
id: stringValue(value.id) ?? stringValue(value.attemptId) ?? "",
...(stringValue(value.status) ? { status: stringValue(value.status) } : {}),
...(numberValue(value.attemptNumber) !== undefined ? { attemptNumber: numberValue(value.attemptNumber) } : {}),
...(stringValue(value.workerId) ? { workerId: stringValue(value.workerId) } : {}),
...(numberValue(value.leaseExpiresAt) !== undefined ? { leaseExpiresAt: numberValue(value.leaseExpiresAt) } : {}),
...(stringValue(value.startedAt) ? { startedAt: stringValue(value.startedAt) } : {}),
...(stringValue(value.completedAt) ? { completedAt: stringValue(value.completedAt) } : {}),
...(stringValue(value.error) ? { error: stringValue(value.error) } : {}),
raw,
};
}
function normalizeOutput(value: Record<string, unknown>): FlowBackendOutputView[] {
if (Array.isArray(value.output)) {
return value.output.map((entry) => {
const output = record(entry);
return {
kind: stringValue(output.kind) ?? "output",
text: stringValue(output.text) ?? "",
...(stringValue(output.createdAt) ? { createdAt: stringValue(output.createdAt) } : {}),
raw: entry,
};
});
}
const output: FlowBackendOutputView[] = [];
for (const key of ["stdout", "stderr"] as const) {
const text = stringValue(value[key]);
if (text) {
output.push({
kind: key,
text,
raw: { kind: key, text },
});
}
}
return output;
}
function parseResultPayload(value: Record<string, unknown>): unknown {
if ("result" in value) {
return value.result;
}
if ("resultJson" in value) {
const resultJson = value.resultJson;
if (isRecord(resultJson)) {
return resultJson;
}
if (typeof resultJson === "string" && resultJson.trim()) {
try {
return JSON.parse(resultJson);
} catch {
return resultJson;
}
}
}
return undefined;
}
function resultStatusFrom(value: unknown): FlowResultStatus | undefined {
const status = isRecord(value) ? value.status : undefined;
return resultStatusFromStatus(status);
}
function resultStatusFromStatus(value: unknown): FlowResultStatus | undefined {
return typeof value === "string" && resultStatuses.has(value as FlowResultStatus)
? value as FlowResultStatus
: undefined;
}
function hmacSignature(secret: string, body: string): string {
return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function numberValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function unique<T>(value: T, index: number, values: T[]): boolean {
return values.indexOf(value) === index;
}

View file

@ -1,141 +0,0 @@
import type { FlowEvent, FlowResultStatus } from "./types.ts";
export type FlowProcessStatus =
| "queued"
| "running"
| "completed"
| "failed"
| "canceled"
| string;
export type FlowEffectiveStatus = FlowProcessStatus | FlowResultStatus;
export type FlowListRunsOptions = {
eventId?: string;
status?: string;
limit?: number;
};
export type FlowListEventsOptions = {
type?: string;
limit?: number;
};
export type FlowDispatchOptions = {
wait?: boolean;
};
export type FlowReplayOptions = {
wait?: boolean;
};
export type FlowProgressEvent = {
kind: "run_start" | "run_complete" | "stderr" | "stdout";
createdAt: string;
eventId?: string;
runId?: string;
flowName?: string;
stepName?: string;
runner?: string;
status?: FlowEffectiveStatus;
text?: string;
};
export type FlowProgressSink = (event: FlowProgressEvent) => void;
export type FlowOutputView = {
kind: string;
text: string;
createdAt?: string;
raw: unknown;
};
export type FlowAttemptView = {
id: string;
status?: string;
attemptNumber?: number;
workerId?: string;
leaseExpiresAt?: number;
startedAt?: string;
completedAt?: string;
error?: string;
raw: unknown;
};
export type FlowRunView = {
id: string;
eventId?: string;
flowName?: string;
flowVersion?: number;
stepName?: string;
runner?: string;
backend?: string;
processStatus?: FlowProcessStatus;
resultStatus?: FlowResultStatus;
status: FlowEffectiveStatus;
effectiveStatus: FlowEffectiveStatus;
needsAttention: boolean;
attemptCount: number;
attempts: FlowAttemptView[];
output: FlowOutputView[];
latestOutput?: FlowOutputView;
resultPayload?: unknown;
error?: string;
createdAt?: string;
startedAt?: string;
completedAt?: string;
updatedAt?: string;
raw: unknown;
};
export type FlowEventView = {
id: string;
type?: string;
source?: string;
occurredAt?: string;
receivedAt?: string;
payload?: unknown;
runIds: string[];
runs: FlowRunView[];
createdAt?: string;
raw: unknown;
};
export type FlowRunList = {
runs: FlowRunView[];
eventId?: string;
raw: unknown;
};
export type FlowEventList = {
events: FlowEventView[];
raw: unknown;
};
export type FlowDispatchResult = {
status?: string;
eventId?: string;
runIds: string[];
matched?: number;
idempotent?: boolean;
event?: FlowEventView;
runs: FlowRunView[];
raw: unknown;
};
export type FlowReplayResult = FlowDispatchResult;
export type FlowCancelResult = {
run: FlowRunView;
raw: unknown;
};
export type FlowClient = {
listRuns(options?: FlowListRunsOptions): Promise<FlowRunList>;
getRun(runId: string): Promise<FlowRunView>;
listEvents(options?: FlowListEventsOptions): Promise<FlowEventList>;
getEvent(eventId: string): Promise<FlowEventView>;
dispatchEvent(event: FlowEvent, options?: FlowDispatchOptions): Promise<FlowDispatchResult>;
replayEvent(eventId: string, options?: FlowReplayOptions): Promise<FlowReplayResult>;
cancelRun(runId: string): Promise<FlowCancelResult>;
};

View file

@ -1,42 +0,0 @@
import {
createFlowBackendHttpClient,
type FlowBackendHttpClientOptions,
} from "./backend-client.ts";
import {
createLocalFlowClient,
type LocalFlowClientOptions,
} from "./local-client.ts";
import type { FlowClient } from "./client-types.ts";
export type {
FlowAttemptView,
FlowCancelResult,
FlowClient,
FlowDispatchOptions,
FlowDispatchResult,
FlowEffectiveStatus,
FlowEventList,
FlowEventView,
FlowListEventsOptions,
FlowListRunsOptions,
FlowOutputView,
FlowProcessStatus,
FlowProgressEvent,
FlowProgressSink,
FlowReplayOptions,
FlowReplayResult,
FlowRunList,
FlowRunView,
} from "./client-types.ts";
export type FlowClientOptions =
| ({ mode: "local" } & LocalFlowClientOptions)
| ({ mode: "http" } & FlowBackendHttpClientOptions);
export function createFlowClient(options: FlowClientOptions): FlowClient {
if (options.mode === "local") {
const { mode: _mode, ...localOptions } = options;
return createLocalFlowClient(localOptions);
}
const { mode: _mode, ...httpOptions } = options;
return createFlowBackendHttpClient(httpOptions);
}

View file

@ -1,59 +0,0 @@
export { discoverFlows, loadFlow, stepSchemaPath, stepScriptPath } from "./manifest.ts";
export { parseFlowResult, stringifyFlowResult } from "./result.ts";
export { runFlowStep } from "./run.ts";
export { runNodeStep } from "./runners/node.ts";
export { readJsonSchema, validateJsonSchema } from "./schema.ts";
export { matchingSteps, stepMatchesEvent } from "./triggers.ts";
export { createFlowClient } from "./client.ts";
export { createLocalFlowClient, LocalFlowClient } from "./local-client.ts";
export {
createCodexFlowClientFromContext,
createWorkspaceBackendClientFromContext,
defineNodeFlow,
readFlowContext,
workspaceBackendUrlFromContext,
} from "./node.ts";
export type {
FlowAttemptView,
FlowCancelResult,
FlowClient,
FlowDispatchOptions,
FlowDispatchResult,
FlowEffectiveStatus,
FlowEventList,
FlowEventView,
FlowListEventsOptions,
FlowListRunsOptions,
FlowOutputView,
FlowProcessStatus,
FlowProgressEvent,
FlowProgressSink,
FlowReplayOptions,
FlowReplayResult,
FlowRunList,
FlowRunView,
} from "./client-types.ts";
export type {
FlowClientOptions,
} from "./client.ts";
export type {
CodexFlowClientFromContextOptions,
NodeFlowHandler,
WorkspaceBackendClientFromContextOptions,
} from "./node.ts";
export type {
LocalFlowClientOptions,
} from "./local-client.ts";
export type {
FlowEvent,
FlowManifest,
FlowResult,
FlowResultStatus,
FlowRunContext,
FlowRunRuntimeContext,
FlowRunRuntimeInput,
FlowStep,
FlowStepRunner,
FlowStepTrigger,
LoadedFlow,
} from "./types.ts";

View file

@ -1,680 +0,0 @@
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { discoverFlows } from "./manifest.ts";
import { runFlowStep } from "./run.ts";
import { matchingSteps } from "./triggers.ts";
import type {
FlowAttemptView,
FlowCancelResult,
FlowClient,
FlowDispatchOptions,
FlowDispatchResult,
FlowEffectiveStatus,
FlowEventList,
FlowEventView,
FlowListEventsOptions,
FlowListRunsOptions,
FlowOutputView,
FlowProcessStatus,
FlowProgressEvent,
FlowProgressSink,
FlowReplayOptions,
FlowReplayResult,
FlowRunList,
FlowRunView,
} from "./client-types.ts";
import type {
FlowEvent,
FlowResult,
FlowResultStatus,
FlowStep,
LoadedFlow,
} from "./types.ts";
export type LocalFlowClientOptions = {
cwd: string;
roots?: string[];
env?: Record<string, string | undefined>;
state?: false | "memory" | {
kind: "file";
dataDir?: string;
};
progress?: FlowProgressSink;
};
type StoredEvent = {
event: FlowEvent;
createdAt: string;
runIds: string[];
};
type LocalFlowStateSnapshot = {
events: StoredEvent[];
runs: FlowRunView[];
};
const resultStatuses = new Set<FlowResultStatus>([
"skipped",
"completed",
"changed",
"needs_intervention",
"blocked",
"failed",
]);
const attentionStatuses = new Set(["blocked", "needs_intervention"]);
export class LocalFlowClientUnsupportedStateError extends Error {
constructor(operation: string) {
super(`Local flow client ${operation} requires local state`);
this.name = "LocalFlowClientUnsupportedStateError";
}
}
export class LocalFlowClient implements FlowClient {
#cwd: string;
#roots: string[] | undefined;
#env: Record<string, string | undefined>;
#progress: FlowProgressSink | undefined;
#state: LocalFlowMemoryState | undefined;
constructor(options: LocalFlowClientOptions) {
this.#cwd = path.resolve(options.cwd);
this.#roots = options.roots?.map((root) =>
path.isAbsolute(root) ? root : path.resolve(this.#cwd, root),
);
this.#env = options.env ?? process.env;
this.#progress = options.progress;
this.#state = localState(options.state, this.#cwd);
}
async listRuns(options: FlowListRunsOptions = {}): Promise<FlowRunList> {
return this.#requireState("listRuns").listRuns(options);
}
async getRun(runId: string): Promise<FlowRunView> {
return this.#requireState("getRun").getRun(runId);
}
async listEvents(options: FlowListEventsOptions = {}): Promise<FlowEventList> {
return this.#requireState("listEvents").listEvents(options);
}
async getEvent(eventId: string): Promise<FlowEventView> {
return this.#requireState("getEvent").getEvent(eventId);
}
async dispatchEvent(
input: FlowEvent,
options: FlowDispatchOptions = {},
): Promise<FlowDispatchResult> {
ensureSynchronousLocalDispatch(options);
const event = normalizeFlowEvent(input);
const duplicate = this.#state?.duplicateDispatch(event.id);
if (duplicate) {
return duplicate;
}
const createdAt = new Date().toISOString();
const flows = await discoverFlows({
cwd: this.#cwd,
...(this.#roots ? { roots: this.#roots } : {}),
});
const matches = await matchingSteps(flows, event);
const runs: FlowRunView[] = [];
for (const match of matches) {
const run = await this.#executeMatch({
event,
flow: match.flow,
step: match.step,
});
runs.push(run);
}
const result = dispatchResult({
status: "accepted",
event,
runs,
matched: matches.length,
raw: {
status: "accepted",
eventId: event.id,
runIds: runs.map((run) => run.id),
matched: matches.length,
},
});
this.#state?.recordDispatch(event, createdAt, runs);
return result;
}
async replayEvent(
eventId: string,
options: FlowReplayOptions = {},
): Promise<FlowReplayResult> {
ensureSynchronousLocalDispatch(options);
const state = this.#requireState("replayEvent");
const event = state.rawEvent(eventId);
const replayNonce = `${Date.now()}:${Math.random()}`;
const flows = await discoverFlows({
cwd: this.#cwd,
...(this.#roots ? { roots: this.#roots } : {}),
});
const matches = await matchingSteps(flows, event);
const runs: FlowRunView[] = [];
for (const match of matches) {
const run = await this.#executeMatch({
event,
flow: match.flow,
step: match.step,
replayNonce,
});
runs.push(run);
}
state.recordReplay(event.id, runs);
return dispatchResult({
status: "accepted",
event,
runs,
matched: matches.length,
raw: {
status: "accepted",
eventId: event.id,
runIds: runs.map((run) => run.id),
matched: matches.length,
replay: true,
},
});
}
async cancelRun(_runId: string): Promise<FlowCancelResult> {
throw new LocalFlowClientUnsupportedStateError("cancelRun");
}
async #executeMatch(options: {
event: FlowEvent;
flow: LoadedFlow;
step: FlowStep;
replayNonce?: string;
}): Promise<FlowRunView> {
const runId = localRunId(
options.event.id,
options.flow.manifest.name,
options.step.name,
options.replayNonce,
);
const startedAt = new Date().toISOString();
const progressBase = {
eventId: options.event.id,
runId,
flowName: options.flow.manifest.name,
stepName: options.step.name,
runner: options.step.runner,
};
this.#emitProgress({
kind: "run_start",
...progressBase,
});
try {
const result = await runFlowStep({
flow: options.flow,
step: options.step,
event: options.event,
env: this.#env,
runtime: {
runId,
eventId: options.event.id,
attemptId: runId,
replay: options.replayNonce !== undefined,
workspaceBackendUrl: this.#env.CODEX_WORKSPACE_BACKEND_WS_URL,
launchedBy: "flow-runtime-local-client",
},
progress: (event) => this.#emitProgress({
...progressBase,
...event,
}),
});
const completedAt = new Date().toISOString();
this.#emitProgress({
kind: "run_complete",
...progressBase,
status: result.status,
});
return localRunView({
runId,
event: options.event,
flow: options.flow,
step: options.step,
processStatus: "completed",
result,
startedAt,
completedAt,
});
} catch (error) {
const completedAt = new Date().toISOString();
this.#emitProgress({
kind: "run_complete",
...progressBase,
status: "failed",
text: error instanceof Error ? error.message : String(error),
});
return localRunView({
runId,
event: options.event,
flow: options.flow,
step: options.step,
processStatus: "failed",
error: error instanceof Error ? error.message : String(error),
startedAt,
completedAt,
});
}
}
#emitProgress(event: Omit<FlowProgressEvent, "createdAt"> & { createdAt?: string }): void {
this.#progress?.({
...event,
createdAt: event.createdAt ?? new Date().toISOString(),
});
}
#requireState(operation: string): LocalFlowMemoryState {
if (!this.#state) {
throw new LocalFlowClientUnsupportedStateError(operation);
}
return this.#state;
}
}
export function createLocalFlowClient(options: LocalFlowClientOptions): LocalFlowClient {
return new LocalFlowClient(options);
}
class LocalFlowMemoryState {
#events = new Map<string, StoredEvent>();
#runs = new Map<string, FlowRunView>();
constructor(snapshot?: LocalFlowStateSnapshot) {
for (const event of snapshot?.events ?? []) {
this.#events.set(event.event.id, event);
}
for (const run of snapshot?.runs ?? []) {
this.#runs.set(run.id, run);
}
}
duplicateDispatch(eventId: string): FlowDispatchResult | undefined {
const stored = this.#events.get(eventId);
if (!stored) {
return undefined;
}
const runs = stored.runIds.map((runId) => this.#runs.get(runId)).filter(isDefined);
return {
status: "duplicate",
eventId,
runIds: stored.runIds,
matched: 0,
idempotent: true,
event: this.eventView(eventId),
runs,
raw: {
status: "duplicate",
eventId,
runIds: stored.runIds,
matched: 0,
idempotent: true,
},
};
}
recordDispatch(event: FlowEvent, createdAt: string, runs: FlowRunView[]): void {
for (const run of runs) {
this.#runs.set(run.id, run);
}
this.#events.set(event.id, {
event,
createdAt,
runIds: runs.map((run) => run.id),
});
}
recordReplay(eventId: string, runs: FlowRunView[]): void {
const stored = this.#events.get(eventId);
if (!stored) {
throw new Error(`Unknown event: ${eventId}`);
}
for (const run of runs) {
this.#runs.set(run.id, run);
stored.runIds.push(run.id);
}
}
listRuns(options: FlowListRunsOptions): FlowRunList {
let runs = Array.from(this.#runs.values());
if (options.eventId) {
runs = runs.filter((run) => run.eventId === options.eventId);
}
if (options.status) {
runs = runs.filter((run) =>
run.processStatus === options.status || run.effectiveStatus === options.status,
);
}
runs = runs.slice(-clampLimit(options.limit)).reverse();
return {
runs,
...(options.eventId ? { eventId: options.eventId } : {}),
raw: { runs },
};
}
getRun(runId: string): FlowRunView {
const run = this.#runs.get(runId);
if (!run) {
throw new Error(`Unknown run: ${runId}`);
}
return run;
}
listEvents(options: FlowListEventsOptions): FlowEventList {
let events = Array.from(this.#events.values());
if (options.type) {
events = events.filter((event) => event.event.type === options.type);
}
events = events.slice(-clampLimit(options.limit)).reverse();
const views = events.map((event) => this.eventView(event.event.id));
return {
events: views,
raw: { events: views },
};
}
getEvent(eventId: string): FlowEventView {
return this.eventView(eventId);
}
rawEvent(eventId: string): FlowEvent {
const stored = this.#events.get(eventId);
if (!stored) {
throw new Error(`Unknown event: ${eventId}`);
}
return stored.event;
}
eventView(eventId: string): FlowEventView {
const stored = this.#events.get(eventId);
if (!stored) {
throw new Error(`Unknown event: ${eventId}`);
}
const runs = stored.runIds.map((runId) => this.#runs.get(runId)).filter(isDefined);
return {
id: stored.event.id,
type: stored.event.type,
...(stored.event.source ? { source: stored.event.source } : {}),
...(stored.event.occurredAt ? { occurredAt: stored.event.occurredAt } : {}),
receivedAt: stored.event.receivedAt,
payload: stored.event.payload,
runIds: stored.runIds,
runs,
createdAt: stored.createdAt,
raw: {
kind: "local-event",
event: stored.event,
createdAt: stored.createdAt,
runIds: stored.runIds,
},
};
}
snapshot(): LocalFlowStateSnapshot {
return {
events: Array.from(this.#events.values()),
runs: Array.from(this.#runs.values()),
};
}
}
class LocalFlowFileState extends LocalFlowMemoryState {
#statePath: string;
constructor(dataDir: string) {
const statePath = path.join(dataDir, "state.json");
super(readStateSnapshot(statePath));
this.#statePath = statePath;
mkdirSync(path.dirname(this.#statePath), { recursive: true });
}
override recordDispatch(event: FlowEvent, createdAt: string, runs: FlowRunView[]): void {
super.recordDispatch(event, createdAt, runs);
this.#save();
}
override recordReplay(eventId: string, runs: FlowRunView[]): void {
super.recordReplay(eventId, runs);
this.#save();
}
#save(): void {
writeFileSync(this.#statePath, JSON.stringify(this.snapshot(), null, 2));
}
}
function dispatchResult(options: {
status: string;
event: FlowEvent;
runs: FlowRunView[];
matched: number;
raw: unknown;
}): FlowDispatchResult {
const runIds = options.runs.map((run) => run.id);
return {
status: options.status,
eventId: options.event.id,
runIds,
matched: options.matched,
event: {
id: options.event.id,
type: options.event.type,
...(options.event.source ? { source: options.event.source } : {}),
...(options.event.occurredAt ? { occurredAt: options.event.occurredAt } : {}),
receivedAt: options.event.receivedAt,
payload: options.event.payload,
runIds,
runs: options.runs,
raw: { kind: "local-event", event: options.event, runIds },
},
runs: options.runs,
raw: options.raw,
};
}
function localRunView(options: {
runId: string;
event: FlowEvent;
flow: LoadedFlow;
step: FlowStep;
processStatus: FlowProcessStatus;
result?: FlowResult;
error?: string;
startedAt: string;
completedAt: string;
}): FlowRunView {
const resultStatus = resultStatusFrom(options.result);
const effectiveStatus: FlowEffectiveStatus =
resultStatus ?? options.processStatus;
const attempt = localAttemptView({
runId: options.runId,
status: options.processStatus,
startedAt: options.startedAt,
completedAt: options.completedAt,
error: options.error,
});
const output: FlowOutputView[] = [];
return {
id: options.runId,
eventId: options.event.id,
flowName: options.flow.manifest.name,
flowVersion: options.flow.manifest.version,
stepName: options.step.name,
runner: options.step.runner,
backend: "local",
processStatus: options.processStatus,
...(resultStatus ? { resultStatus } : {}),
status: effectiveStatus,
effectiveStatus,
needsAttention: attentionStatuses.has(effectiveStatus),
attemptCount: 1,
attempts: [attempt],
output,
...(options.result ? { resultPayload: options.result } : {}),
...(options.error ? { error: options.error } : {}),
createdAt: options.startedAt,
startedAt: options.startedAt,
completedAt: options.completedAt,
updatedAt: options.completedAt,
raw: {
kind: "local-run",
event: options.event,
flowRoot: options.flow.root,
flowName: options.flow.manifest.name,
stepName: options.step.name,
result: options.result,
error: options.error,
},
};
}
function localAttemptView(options: {
runId: string;
status: FlowProcessStatus;
startedAt: string;
completedAt: string;
error?: string;
}): FlowAttemptView {
return {
id: `${options.runId}:attempt:1`,
status: options.status,
attemptNumber: 1,
startedAt: options.startedAt,
completedAt: options.completedAt,
...(options.error ? { error: options.error } : {}),
raw: {
kind: "local-attempt",
runId: options.runId,
},
};
}
function normalizeFlowEvent(value: unknown): FlowEvent {
const record = isRecord(value) ? value : {};
if (typeof record.id !== "string" || typeof record.type !== "string") {
throw new Error("FlowEvent requires string id and type");
}
return {
...record,
id: record.id,
type: record.type,
receivedAt: typeof record.receivedAt === "string" && record.receivedAt
? record.receivedAt
: new Date().toISOString(),
payload: "payload" in record ? record.payload : {},
} as FlowEvent;
}
function localState(
state: LocalFlowClientOptions["state"],
cwd: string,
): LocalFlowMemoryState | undefined {
if (state === false) {
return undefined;
}
if (typeof state === "object") {
return new LocalFlowFileState(state.dataDir ?? path.join(cwd, ".codex", "flow-client"));
}
return new LocalFlowMemoryState();
}
function readStateSnapshot(statePath: string): LocalFlowStateSnapshot | undefined {
try {
const parsed = JSON.parse(readFileSync(statePath, "utf8")) as unknown;
if (!isRecord(parsed) || !Array.isArray(parsed.events) || !Array.isArray(parsed.runs)) {
return undefined;
}
return {
events: parsed.events.filter(isStoredEvent),
runs: parsed.runs.filter(isFlowRunView),
};
} catch (error) {
if (isErrno(error, "ENOENT")) {
return undefined;
}
throw error;
}
}
function resultStatusFrom(result: FlowResult | undefined): FlowResultStatus | undefined {
return result && resultStatuses.has(result.status) ? result.status : undefined;
}
function localRunId(
eventId: string,
flowName: string,
stepName: string,
replayNonce?: string,
): string {
const hash = createHash("sha256")
.update(`${eventId}\0${flowName}\0${stepName}${replayNonce ? `\0${replayNonce}` : ""}`)
.digest("hex")
.slice(0, 12);
return replayNonce ? `run_${hash}_replay` : `run_${hash}`;
}
function ensureSynchronousLocalDispatch(
options: FlowDispatchOptions | FlowReplayOptions,
): void {
if (options.wait === false) {
throw new Error("Local flow dispatch does not support wait: false without a worker loop");
}
}
function clampLimit(value: number | undefined): number {
if (!value || !Number.isFinite(value)) {
return 50;
}
return Math.max(1, Math.min(500, Math.trunc(value)));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isStoredEvent(value: unknown): value is StoredEvent {
if (!isRecord(value) || !isRecord(value.event)) {
return false;
}
return typeof value.event.id === "string" &&
typeof value.event.type === "string" &&
typeof value.event.receivedAt === "string" &&
Array.isArray(value.runIds) &&
value.runIds.every((runId) => typeof runId === "string") &&
typeof value.createdAt === "string";
}
function isFlowRunView(value: unknown): value is FlowRunView {
return isRecord(value) &&
typeof value.id === "string" &&
typeof value.status === "string" &&
typeof value.effectiveStatus === "string" &&
typeof value.needsAttention === "boolean" &&
typeof value.attemptCount === "number" &&
Array.isArray(value.attempts) &&
Array.isArray(value.output) &&
"raw" in value;
}
function isErrno(error: unknown, code: string): boolean {
return isRecord(error) && error.code === code;
}
function isDefined<T>(value: T | undefined): value is T {
return value !== undefined;
}

View file

@ -1,150 +0,0 @@
import { access, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { parse as parseToml } from "smol-toml";
import type { FlowManifest, FlowStep, LoadedFlow } from "./types.ts";
export type DiscoverFlowsOptions = {
cwd: string;
roots?: string[];
};
export async function loadFlow(root: string): Promise<LoadedFlow> {
const manifestPath = path.join(root, "flow.toml");
const parsed = parseToml(await readFile(manifestPath, "utf8")) as unknown;
const manifest = normalizeManifest(parsed, manifestPath);
return { root, manifestPath, manifest };
}
export async function discoverFlows(options: DiscoverFlowsOptions): Promise<LoadedFlow[]> {
const roots = options.roots ?? [
path.join(options.cwd, ".codex", "flows"),
path.join(options.cwd, "flows"),
];
const flows: LoadedFlow[] = [];
const seen = new Set<string>();
for (const root of roots) {
for (const directory of await childDirectories(root)) {
const manifestPath = path.join(directory, "flow.toml");
if (!(await exists(manifestPath))) {
continue;
}
const flow = await loadFlow(directory);
if (seen.has(flow.manifest.name)) {
continue;
}
seen.add(flow.manifest.name);
flows.push(flow);
}
}
return flows;
}
export function stepScriptPath(flow: LoadedFlow, step: FlowStep): string {
return path.resolve(flow.root, step.script);
}
export function stepSchemaPath(flow: LoadedFlow, step: FlowStep): string | undefined {
return step.trigger?.schema ? path.resolve(flow.root, step.trigger.schema) : undefined;
}
async function childDirectories(root: string): Promise<string[]> {
try {
const entries = await readdir(root, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(root, entry.name))
.sort();
} catch (error) {
if (isErrno(error, "ENOENT")) {
return [];
}
throw error;
}
}
function normalizeManifest(value: unknown, manifestPath: string): FlowManifest {
if (!isRecord(value)) {
throw new Error(`flow.toml must contain a table: ${manifestPath}`);
}
const name = requiredString(value.name, "name", manifestPath);
const version = requiredNumber(value.version, "version", manifestPath);
const rawSteps = Array.isArray(value.steps) ? value.steps : undefined;
if (!rawSteps || rawSteps.length === 0) {
throw new Error(`flow.toml requires at least one [[steps]] entry: ${manifestPath}`);
}
return {
name,
version,
...(typeof value.description === "string" ? { description: value.description } : {}),
...(isRecord(value.config) ? { config: value.config } : {}),
...(isRecord(value.guidance) ? { guidance: normalizeGuidance(value.guidance) } : {}),
steps: rawSteps.map((step, index) => normalizeStep(step, index, manifestPath)),
};
}
function normalizeGuidance(value: Record<string, unknown>): FlowManifest["guidance"] {
return {
...(Array.isArray(value.skills)
? { skills: value.skills.filter((entry): entry is string => typeof entry === "string") }
: {}),
};
}
function normalizeStep(value: unknown, index: number, manifestPath: string): FlowStep {
if (!isRecord(value)) {
throw new Error(`steps[${index}] must be a table: ${manifestPath}`);
}
const runner = requiredString(value.runner, `steps[${index}].runner`, manifestPath);
if (runner !== "node") {
throw new Error(`steps[${index}].runner must be node: ${manifestPath}`);
}
return {
name: requiredString(value.name, `steps[${index}].name`, manifestPath),
runner,
script: requiredString(value.script, `steps[${index}].script`, manifestPath),
timeoutMs: typeof value.timeout_ms === "number" ? value.timeout_ms : 300_000,
...(typeof value.cwd === "string" ? { cwd: value.cwd } : {}),
...(isRecord(value.trigger) ? { trigger: normalizeTrigger(value.trigger, index, manifestPath) } : {}),
};
}
function normalizeTrigger(value: Record<string, unknown>, index: number, manifestPath: string): FlowStep["trigger"] {
return {
type: requiredString(value.type, `steps[${index}].trigger.type`, manifestPath),
...(typeof value.schema === "string" ? { schema: value.schema } : {}),
};
}
function requiredString(value: unknown, name: string, pathValue: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`flow.toml requires ${name}: ${pathValue}`);
}
return value;
}
function requiredNumber(value: unknown, name: string, pathValue: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`flow.toml requires numeric ${name}: ${pathValue}`);
}
return value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isErrno(error: unknown, code: string): boolean {
return isRecord(error) && error.code === code;
}
async function exists(pathValue: string): Promise<boolean> {
try {
await access(pathValue);
return true;
} catch (error) {
if (isErrno(error, "ENOENT")) {
return false;
}
throw error;
}
}

View file

@ -1,125 +0,0 @@
import {
CodexWorkspaceBackendClient,
type CodexWorkspaceBackendClientOptions,
} from "@peezy.tech/codex-flows/workspace-backend";
import {
createCodexFlowClient,
type CodexFlowClient,
type CodexFlowClientOptions,
} from "@peezy.tech/codex-flows/flows";
import type { FlowResult, FlowRunContext } from "./types.ts";
export type NodeFlowHandler<TContext extends FlowRunContext = FlowRunContext> = (
context: TContext,
) => FlowResult | Promise<FlowResult>;
export function defineNodeFlow<TContext extends FlowRunContext = FlowRunContext>(
handler: NodeFlowHandler<TContext>,
): NodeFlowHandler<TContext> {
return handler;
}
export async function readFlowContext(input?: string | Uint8Array): Promise<FlowRunContext> {
const text = input === undefined
? await readStdinText()
: typeof input === "string"
? input
: new TextDecoder().decode(input);
const parsed = JSON.parse(text) as unknown;
if (!isRecord(parsed) || !isRecord(parsed.flow)) {
throw new Error("Flow context must be a JSON object with a flow object");
}
return parsed as FlowRunContext;
}
export type WorkspaceBackendClientFromContextOptions =
Omit<CodexWorkspaceBackendClientOptions, "webSocketTransportOptions"> & {
url?: string;
requestTimeoutMs?: number;
env?: Record<string, string | undefined>;
};
export function workspaceBackendUrlFromContext(
context: FlowRunContext,
env: Record<string, string | undefined> = process.env,
): string | undefined {
return context.runtime.workspaceBackendUrl ?? env.CODEX_WORKSPACE_BACKEND_WS_URL;
}
export function createWorkspaceBackendClientFromContext(
context: FlowRunContext,
options: WorkspaceBackendClientFromContextOptions = {},
): CodexWorkspaceBackendClient {
const { url, requestTimeoutMs, env: optionEnv, ...clientOptions } = options;
const workspaceBackendUrl = url ?? workspaceBackendUrlFromContext(context, optionEnv ?? process.env);
return new CodexWorkspaceBackendClient({
...clientOptions,
clientName: clientOptions.clientName ?? "codex-flow-node-step",
clientTitle: clientOptions.clientTitle ?? `Node Flow ${context.flow.name}/${context.flow.step}`,
webSocketTransportOptions: clientOptions.transport
? undefined
: {
url: requireWorkspaceBackendUrl(workspaceBackendUrl),
requestTimeoutMs,
},
});
}
export type CodexFlowClientFromContextOptions =
Omit<CodexFlowClientOptions, "appServerUrl" | "client" | "closeInjectedClient"> & {
workspaceClient?: CodexWorkspaceBackendClient;
workspaceBackendUrl?: string;
requestTimeoutMs?: number;
env?: Record<string, string | undefined>;
closeWorkspaceClient?: boolean;
};
export function createCodexFlowClientFromContext(
context: FlowRunContext,
options: CodexFlowClientFromContextOptions = {},
): CodexFlowClient {
const {
workspaceClient,
workspaceBackendUrl,
requestTimeoutMs,
env,
closeWorkspaceClient,
...codexOptions
} = options;
const client = workspaceClient ?? createWorkspaceBackendClientFromContext(context, {
url: workspaceBackendUrl,
requestTimeoutMs,
env,
clientName: codexOptions.clientName ?? "codex-flow-node-step",
clientTitle: codexOptions.clientTitle ?? `Node Flow ${context.flow.name}/${context.flow.step}`,
clientVersion: codexOptions.clientVersion,
});
return createCodexFlowClient({
...codexOptions,
client,
closeInjectedClient: workspaceClient
? closeWorkspaceClient === true
: closeWorkspaceClient !== false,
});
}
function requireWorkspaceBackendUrl(value: string | undefined): string {
if (!value) {
throw new Error(
"CODEX_WORKSPACE_BACKEND_WS_URL or context.runtime.workspaceBackendUrl is required",
);
}
return value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readStdinText(): Promise<string> {
const chunks: Uint8Array[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString("utf8");
}

View file

@ -1,37 +0,0 @@
import type { FlowResult, FlowResultStatus } from "./types.ts";
const validStatuses = new Set<FlowResultStatus>([
"skipped",
"completed",
"changed",
"needs_intervention",
"blocked",
"failed",
]);
export function parseFlowResult(stdout: string): FlowResult {
for (const line of stdout.split(/\r?\n/).reverse()) {
const index = line.indexOf("FLOW_RESULT ");
if (index === -1) {
continue;
}
const text = line.slice(index + "FLOW_RESULT ".length).trim();
const parsed = JSON.parse(text) as unknown;
if (!isRecord(parsed)) {
throw new Error("FLOW_RESULT must be a JSON object");
}
if (typeof parsed.status !== "string" || !validStatuses.has(parsed.status as FlowResultStatus)) {
throw new Error("FLOW_RESULT status is invalid");
}
return parsed as FlowResult;
}
throw new Error("Step did not emit FLOW_RESULT");
}
export function stringifyFlowResult(value: FlowResult): string {
return `FLOW_RESULT ${JSON.stringify(value)}\n`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -1,22 +0,0 @@
import { runNodeStep } from "./runners/node.ts";
import type { FlowProgressSink } from "./client-types.ts";
import type {
FlowEvent,
FlowResult,
FlowRunRuntimeInput,
FlowStep,
LoadedFlow,
} from "./types.ts";
export type RunFlowStepOptions = {
flow: LoadedFlow;
step: FlowStep;
event: FlowEvent;
env?: Record<string, string | undefined>;
runtime?: FlowRunRuntimeInput;
progress?: FlowProgressSink;
};
export async function runFlowStep(options: RunFlowStepOptions): Promise<FlowResult> {
return runNodeStep(options);
}

View file

@ -1,45 +0,0 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { stringifyFlowResult } from "../result.ts";
import type { FlowResult, FlowRunContext } from "../types.ts";
const scriptPath = process.argv[2];
try {
if (!scriptPath) {
throw new Error("Node module runner requires a script path");
}
const context = JSON.parse(await readStdinText()) as FlowRunContext;
const moduleUrl = pathToFileURL(path.resolve(scriptPath));
moduleUrl.searchParams.set("flowRun", context.runtime.runId ?? `${Date.now()}`);
const module = await import(moduleUrl.href) as { default?: unknown };
if (typeof module.default !== "function") {
throw new Error("Node module flow step must export a default handler function");
}
const result = await module.default(context) as unknown;
if (!isFlowResult(result)) {
throw new Error("Node module flow step must return a FlowResult object");
}
process.stdout.write(stringifyFlowResult(result));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
process.stderr.write(`${stack ?? message}\n`);
process.stdout.write(stringifyFlowResult({ status: "failed", message }));
}
function isFlowResult(value: unknown): value is FlowResult {
return isRecord(value) && typeof value.status === "string";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readStdinText(): Promise<string> {
const chunks: Uint8Array[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString("utf8");
}

View file

@ -1,166 +0,0 @@
import { spawn } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { FlowProgressSink } from "../client-types.ts";
import { stepScriptPath } from "../manifest.ts";
import { parseFlowResult } from "../result.ts";
import type {
FlowEvent,
FlowResult,
FlowRunContext,
FlowRunRuntimeInput,
FlowStep,
LoadedFlow,
} from "../types.ts";
export type RunNodeStepOptions = {
flow: LoadedFlow;
step: FlowStep;
event: FlowEvent;
env?: Record<string, string | undefined>;
runtime?: FlowRunRuntimeInput;
progress?: FlowProgressSink;
};
export async function runNodeStep(options: RunNodeStepOptions): Promise<FlowResult> {
const scriptPath = stepScriptPath(options.flow, options.step);
const cwd = options.step.cwd
? path.resolve(options.flow.root, options.step.cwd)
: options.flow.root;
const context = runContext(options);
const commandPath = await nodeCommandPath(scriptPath);
const subprocess = spawn(commandPath[0] ?? process.execPath, commandPath.slice(1), {
cwd,
env: {
...process.env,
...options.env,
...runtimeEnv(context),
},
stdio: ["pipe", "pipe", "pipe"],
});
subprocess.stdin.end(`${JSON.stringify(context, null, 2)}\n`);
const timer = setTimeout(() => subprocess.kill("SIGTERM"), options.step.timeoutMs);
const [stdout, stderr, exitCode] = await Promise.all([
collectText(subprocess.stdout),
collectText(subprocess.stderr, (text) => {
options.progress?.({
kind: "stderr",
createdAt: new Date().toISOString(),
eventId: options.event.id,
runId: options.runtime?.runId,
flowName: options.flow.manifest.name,
stepName: options.step.name,
runner: options.step.runner,
text,
});
}),
exitCodeFor(subprocess),
]).finally(() => clearTimeout(timer));
if (exitCode !== 0) {
throw new Error(`Node flow step ${options.flow.manifest.name}/${options.step.name} failed:\n${stderr || stdout}`);
}
return parseFlowResult(stdout);
}
async function collectText(
stream: NodeJS.ReadableStream | null,
onText?: (text: string) => void,
): Promise<string> {
let output = "";
if (!stream) {
return output;
}
for await (const chunk of stream) {
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
output += text;
onText?.(text);
}
return output;
}
async function nodeCommandPath(scriptPath: string): Promise<string[]> {
const tsxLoader = import.meta.resolve("tsx");
if (await isModuleStyleScript(scriptPath)) {
return [process.execPath, "--import", tsxLoader, siblingRuntimePath("node-module-runner"), scriptPath];
}
return [process.execPath, "--import", tsxLoader, scriptPath];
}
async function isModuleStyleScript(scriptPath: string): Promise<boolean> {
const source = await readFile(scriptPath, "utf8");
return /\bexport\s+default\b/.test(source) ||
/\bas\s+default\b/.test(source) ||
/\bdefineNodeFlow\s*\(/.test(source);
}
function siblingRuntimePath(basename: string): string {
const currentPath = fileURLToPath(import.meta.url);
const extension = path.extname(currentPath) || ".ts";
return path.join(path.dirname(currentPath), `${basename}${extension}`);
}
function runContext(options: RunNodeStepOptions): FlowRunContext {
const env = options.env ?? process.env;
const runtime = {
eventId: options.runtime?.eventId ?? env.CODEX_FLOW_EVENT_ID ?? options.event.id,
runId: options.runtime?.runId ?? env.CODEX_FLOW_RUN_ID,
attemptId: options.runtime?.attemptId ?? env.CODEX_FLOW_ATTEMPT_ID,
replay: options.runtime?.replay ?? booleanEnv(env.CODEX_FLOW_REPLAY),
workspaceBackendUrl: options.runtime?.workspaceBackendUrl ?? env.CODEX_WORKSPACE_BACKEND_WS_URL,
launchedBy: options.runtime?.launchedBy ?? env.CODEX_FLOW_LAUNCHED_BY,
};
return {
flow: {
name: options.flow.manifest.name,
version: options.flow.manifest.version,
root: options.flow.root,
step: options.step.name,
...(options.flow.manifest.config ? { config: options.flow.manifest.config } : {}),
event: options.event,
},
runtime: compactUndefined(runtime),
};
}
function runtimeEnv(context: FlowRunContext): Record<string, string> {
return compactStringEnv({
CODEX_FLOW_EVENT_ID: context.runtime.eventId,
CODEX_FLOW_RUN_ID: context.runtime.runId,
CODEX_FLOW_ATTEMPT_ID: context.runtime.attemptId,
CODEX_FLOW_REPLAY: context.runtime.replay ? "1" : "0",
CODEX_WORKSPACE_BACKEND_WS_URL: context.runtime.workspaceBackendUrl,
CODEX_FLOW_LAUNCHED_BY: context.runtime.launchedBy,
});
}
function booleanEnv(value: string | undefined): boolean {
return value === "1" || value === "true" || value === "yes";
}
function compactUndefined<T extends Record<string, unknown>>(value: T): T {
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
if (entry !== undefined) {
result[key] = entry;
}
}
return result as T;
}
function compactStringEnv(value: Record<string, string | undefined>): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, entry] of Object.entries(value)) {
if (entry !== undefined) {
result[key] = entry;
}
}
return result;
}
function exitCodeFor(subprocess: ReturnType<typeof spawn>): Promise<number | null> {
return new Promise((resolve, reject) => {
subprocess.once("error", reject);
subprocess.once("exit", (code) => resolve(code));
});
}

View file

@ -1,83 +0,0 @@
import { readFile } from "node:fs/promises";
type JsonSchema = {
type?: string | string[];
required?: string[];
properties?: Record<string, JsonSchema>;
enum?: unknown[];
};
export type SchemaValidationResult =
| { ok: true }
| { ok: false; errors: string[] };
export async function readJsonSchema(path: string): Promise<JsonSchema> {
const parsed = JSON.parse(await readFile(path, "utf8")) as unknown;
if (!isRecord(parsed)) {
throw new Error(`Schema must be a JSON object: ${path}`);
}
return parsed as JsonSchema;
}
export function validateJsonSchema(value: unknown, schema: JsonSchema): SchemaValidationResult {
const errors: string[] = [];
validateValue(value, schema, "$", errors);
return errors.length === 0 ? { ok: true } : { ok: false, errors };
}
function validateValue(
value: unknown,
schema: JsonSchema,
path: string,
errors: string[],
): void {
if (schema.enum && !schema.enum.some((entry) => Object.is(entry, value))) {
errors.push(`${path} must be one of ${schema.enum.map(String).join(", ")}`);
return;
}
if (schema.type && !typeMatches(value, schema.type)) {
errors.push(`${path} must be ${Array.isArray(schema.type) ? schema.type.join(" or ") : schema.type}`);
return;
}
if (schema.type === "object" || (schema.properties && isRecord(value))) {
if (!isRecord(value)) {
errors.push(`${path} must be object`);
return;
}
for (const key of schema.required ?? []) {
if (!(key in value)) {
errors.push(`${path}.${key} is required`);
}
}
for (const [key, childSchema] of Object.entries(schema.properties ?? {})) {
if (key in value) {
validateValue(value[key], childSchema, `${path}.${key}`, errors);
}
}
}
}
function typeMatches(value: unknown, type: string | string[]): boolean {
const types = Array.isArray(type) ? type : [type];
return types.some((entry) => {
if (entry === "array") {
return Array.isArray(value);
}
if (entry === "null") {
return value === null;
}
if (entry === "integer") {
return Number.isInteger(value);
}
if (entry === "object") {
return isRecord(value);
}
return typeof value === entry;
});
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -1,41 +0,0 @@
import { stepSchemaPath } from "./manifest.ts";
import { readJsonSchema, validateJsonSchema } from "./schema.ts";
import type { FlowEvent, FlowStep, LoadedFlow } from "./types.ts";
export type TriggerMatch =
| { ok: true }
| { ok: false; reason: string };
export async function stepMatchesEvent(
flow: LoadedFlow,
step: FlowStep,
event: FlowEvent,
): Promise<TriggerMatch> {
if (!step.trigger) {
return { ok: false, reason: "step has no trigger" };
}
if (step.trigger.type !== event.type) {
return { ok: false, reason: `event type ${event.type} does not match ${step.trigger.type}` };
}
const schemaPath = stepSchemaPath(flow, step);
if (!schemaPath) {
return { ok: true };
}
const result = validateJsonSchema(event.payload, await readJsonSchema(schemaPath));
return result.ok ? { ok: true } : { ok: false, reason: result.errors.join("; ") };
}
export async function matchingSteps(
flows: LoadedFlow[],
event: FlowEvent,
): Promise<Array<{ flow: LoadedFlow; step: FlowStep }>> {
const matches: Array<{ flow: LoadedFlow; step: FlowStep }> = [];
for (const flow of flows) {
for (const step of flow.manifest.steps) {
if ((await stepMatchesEvent(flow, step, event)).ok) {
matches.push({ flow, step });
}
}
}
return matches;
}

View file

@ -1,80 +0,0 @@
export type FlowEvent<TPayload = unknown> = {
id: string;
type: string;
source?: string;
occurredAt?: string;
receivedAt: string;
payload: TPayload;
};
export type FlowResultStatus =
| "skipped"
| "completed"
| "changed"
| "needs_intervention"
| "blocked"
| "failed";
export type FlowResult = {
status: FlowResultStatus;
message?: string;
artifacts?: Record<string, unknown>;
next?: Array<FlowEvent<Record<string, unknown>>>;
[key: string]: unknown;
};
export type FlowStepRunner = "node";
export type FlowStepTrigger = {
type: string;
schema?: string;
};
export type FlowStep = {
name: string;
runner: FlowStepRunner;
script: string;
timeoutMs: number;
cwd?: string;
trigger?: FlowStepTrigger;
};
export type FlowManifest = {
name: string;
version: number;
description?: string;
config?: Record<string, unknown>;
guidance?: {
skills?: string[];
};
steps: FlowStep[];
};
export type LoadedFlow = {
root: string;
manifestPath: string;
manifest: FlowManifest;
};
export type FlowRunRuntimeContext = {
eventId: string;
runId?: string;
attemptId?: string;
replay: boolean;
workspaceBackendUrl?: string;
launchedBy?: string;
};
export type FlowRunRuntimeInput = Partial<FlowRunRuntimeContext>;
export type FlowRunContext = {
flow: {
name: string;
version: number;
root: string;
step: string;
config?: Record<string, unknown>;
event: FlowEvent;
};
runtime: FlowRunRuntimeContext;
};

View file

@ -1,209 +0,0 @@
import { expect, test } from "vite-plus/test";
import {
FlowBackendHttpClient,
normalizeRun,
} from "../src/backend-client.ts";
test("lists and reads runs/events from HTTP responses", async () => {
const fetches: Request[] = [];
const client = new FlowBackendHttpClient({
baseUrl: "http://flow-backend.test/base/",
fetch: async (request, init) => {
const normalized = request instanceof Request
? request
: new Request(String(request), init);
fetches.push(normalized);
const url = new URL(normalized.url);
if (url.pathname === "/base/runs") {
expect(url.searchParams.get("eventId")).toBe("event-1");
return json({
eventId: "event-1",
runs: [
{
id: "run-1",
eventId: "event-1",
flowName: "release",
stepName: "check",
status: "completed",
resultJson: JSON.stringify({ status: "changed", message: "updated" }),
stdout: "FLOW_RESULT ...",
createdAt: "2026-05-15T00:00:00.000Z",
},
],
});
}
if (url.pathname === "/base/runs/run-1") {
return json({
run: {
runId: "run-1",
eventId: "event-1",
flowName: "release",
stepName: "check",
status: "completed",
result: { status: "completed", artifacts: { ok: true } },
output: [{ kind: "stdout", text: "done", createdAt: "2026-05-15T00:00:01.000Z" }],
},
});
}
if (url.pathname === "/base/events") {
return json({
events: [
{
id: "event-1",
type: "upstream.release",
receivedAt: "2026-05-15T00:00:00.000Z",
payload: { tag: "v1" },
runIds: ["run-1"],
},
],
});
}
if (url.pathname === "/base/events/event-1") {
return json({
event: {
id: "event-1",
type: "upstream.release",
payload: { tag: "v1" },
},
runs: [{ id: "run-1", status: "running" }],
});
}
return json({ error: "not found" }, 404);
},
});
const runs = await client.listRuns({ eventId: "event-1", limit: 10 });
expect(runs).toMatchObject({
eventId: "event-1",
runs: [
{
id: "run-1",
eventId: "event-1",
flowName: "release",
stepName: "check",
processStatus: "completed",
resultStatus: "changed",
effectiveStatus: "changed",
needsAttention: false,
},
],
});
expect(runs.runs[0]?.latestOutput).toMatchObject({
kind: "stdout",
text: "FLOW_RESULT ...",
});
const run = await client.getRun("run-1");
expect(run).toMatchObject({
id: "run-1",
resultStatus: "completed",
effectiveStatus: "completed",
latestOutput: { kind: "stdout", text: "done" },
});
expect(run.resultPayload).toEqual({ status: "completed", artifacts: { ok: true } });
const events = await client.listEvents({ type: "upstream.release" });
expect(events.events[0]).toMatchObject({
id: "event-1",
type: "upstream.release",
runIds: ["run-1"],
});
const event = await client.getEvent("event-1");
expect(event).toMatchObject({
id: "event-1",
runs: [{ id: "run-1", effectiveStatus: "running" }],
runIds: ["run-1"],
});
expect(fetches.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual([
"GET /base/runs",
"GET /base/runs/run-1",
"GET /base/events",
"GET /base/events/event-1",
]);
});
test("normalizes process status plus semantic result status and attention state", () => {
expect(normalizeRun({
id: "run-blocked",
status: "completed",
resultJson: JSON.stringify({ status: "blocked", message: "local changes" }),
})).toMatchObject({
processStatus: "completed",
resultStatus: "blocked",
effectiveStatus: "blocked",
needsAttention: true,
});
expect(normalizeRun({
runId: "run-intervention",
status: "needs_intervention",
result: { status: "needs_intervention" },
attempts: [{ attemptId: "attempt-1", status: "running", workerId: "worker-1" }],
})).toMatchObject({
id: "run-intervention",
resultStatus: "needs_intervention",
effectiveStatus: "needs_intervention",
needsAttention: true,
attemptCount: 1,
attempts: [{ id: "attempt-1", status: "running", workerId: "worker-1" }],
});
});
test("constructs dispatch, replay, and cancel requests with auth headers", async () => {
const requests: Array<{ url: string; method: string; headers: Headers; body: string }> = [];
const client = new FlowBackendHttpClient({
baseUrl: "http://flow-backend.test",
bearerToken: "bearer-secret",
apiKey: "api-key",
hmacSecret: "hmac-secret",
headers: { "x-extra": "yes" },
fetch: async (request, init) => {
const normalized = request instanceof Request
? request
: new Request(String(request), init);
requests.push({
url: normalized.url,
method: normalized.method,
headers: normalized.headers,
body: await normalized.text(),
});
if (normalized.url.endsWith("/runs/run-1/cancel")) {
return json({ run: { id: "run-1", status: "canceled" } });
}
return json({
status: "accepted",
eventId: "event-1",
runIds: ["run-1"],
matched: 1,
}, 202);
},
});
await client.dispatchEvent({
id: "event-1",
type: "demo.event",
receivedAt: "2026-05-15T00:00:00.000Z",
payload: { value: 1 },
});
await client.replayEvent("event-1", { wait: true });
await client.cancelRun("run-1");
expect(requests.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual([
"POST /events",
"POST /events/event-1/replay",
"POST /runs/run-1/cancel",
]);
for (const request of requests) {
expect(request.headers.get("authorization")).toBe("Bearer bearer-secret");
expect(request.headers.get("x-api-key")).toBe("api-key");
expect(request.headers.get("x-extra")).toBe("yes");
expect(request.headers.get("content-type")).toBe("application/json");
expect(request.headers.get("x-flow-signature-256")?.startsWith("sha256=")).toBe(true);
expect(request.body.length).toBeGreaterThan(0);
}
expect(JSON.parse(requests[1]?.body ?? "{}")).toEqual({ wait: true });
});
function json(value: unknown, status = 200): Response {
return Response.json(value, { status });
}

View file

@ -1,48 +0,0 @@
import { expect, test } from "vite-plus/test";
import { createFlowClient } from "../src/client.ts";
test("client factory creates an HTTP backend client with auth handling", async () => {
const requests: Request[] = [];
const client = createFlowClient({
mode: "http",
baseUrl: "https://flow.example",
hmacSecret: "secret",
fetch: async (request, init) => {
const normalized = request instanceof Request
? request
: new Request(String(request), init);
requests.push(normalized);
return Response.json({
status: "accepted",
eventId: "event-1",
runIds: ["run-1"],
matched: 1,
});
},
});
const result = await client.dispatchEvent({
id: "event-1",
type: "demo.event",
receivedAt: "2026-05-15T00:00:00.000Z",
payload: {},
});
expect(result).toMatchObject({
status: "accepted",
eventId: "event-1",
runIds: ["run-1"],
matched: 1,
});
expect(requests[0]?.headers.get("x-flow-signature-256")?.startsWith("sha256=")).toBe(true);
});
test("client factory creates a local client", async () => {
const client = createFlowClient({
mode: "local",
cwd: process.cwd(),
state: false,
});
await expect(client.listEvents()).rejects.toThrow("requires local state");
});

View file

@ -1,490 +0,0 @@
import { expect, test } from "vite-plus/test";
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
discoverFlows,
createCodexFlowClientFromContext,
createWorkspaceBackendClientFromContext,
matchingSteps,
readFlowContext,
runNodeStep,
runFlowStep,
validateJsonSchema,
workspaceBackendUrlFromContext,
} from "../src/index.ts";
import type { FlowEvent } from "../src/index.ts";
type CodexForkFlowHelpers = {
parseRemoteTagRef(output: string, tagName: string): { objectSha: string; commitSha: string } | undefined;
upstreamReleaseTagRef(tagName: string): string;
};
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, "..", "..", "..");
test("discovers installed flows before source flows", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "flow-runtime-"));
try {
await writeFlow(directory, ".codex/flows/demo", "installed");
await writeFlow(directory, "flows/demo", "source");
const flows = await discoverFlows({ cwd: directory });
expect(flows.map((flow) => flow.manifest.name)).toEqual(["demo"]);
expect(flows[0]?.manifest.description).toBe("installed");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("matches flow steps by event type and payload schema", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "flow-runtime-"));
try {
await writeFlow(directory, "flows/demo", "source");
const flows = await discoverFlows({ cwd: directory });
const event: FlowEvent = {
id: "event-1",
type: "demo.event",
receivedAt: "2026-05-13T00:00:00.000Z",
payload: { name: "Ada" },
};
expect((await matchingSteps(flows, event)).map(({ step }) => step.name)).toEqual([
"hello",
]);
expect(await matchingSteps(flows, { ...event, payload: {} })).toEqual([]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("bundled Codex release flows match one generic upstream release event", async () => {
const root = repoRoot;
const flows = await discoverFlows({ cwd: root });
const event: FlowEvent = {
id: "event-1",
type: "upstream.release",
receivedAt: "2026-05-13T00:00:00.000Z",
payload: { repo: "openai/codex", tag: "rust-v1.2.3" },
};
const matches = await matchingSteps(flows, event);
expect(matches.map(({ flow, step }) => `${flow.manifest.name}/${step.name}`)).toEqual([
"openai-codex-bindings/regenerate-bindings",
"peezy-codex-fork/release-cycle",
]);
});
test("bundled Codex fork flow matches upstream main branch updates", async () => {
const root = repoRoot;
const flows = await discoverFlows({ cwd: root });
const event: FlowEvent = {
id: "event-branch",
type: "upstream.branch_update",
receivedAt: "2026-05-13T00:00:00.000Z",
payload: {
repo: "openai/codex",
ref: "refs/heads/main",
sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
};
const matches = await matchingSteps(flows, event);
expect(matches.map(({ flow, step }) => `${flow.manifest.name}/${step.name}`)).toEqual([
"peezy-codex-fork/main-branch-update",
]);
});
test("bundled codex-flows fork flow matches downstream Peezy releases", async () => {
const root = repoRoot;
const flows = await discoverFlows({ cwd: root });
const codexRelease: FlowEvent = {
id: "event-codex",
type: "downstream.release",
receivedAt: "2026-05-17T00:00:00.000Z",
payload: {
packageName: "@peezy.tech/codex",
version: "0.130.0",
repo: "peezy-tech/codex",
},
};
const codexFlowsRelease: FlowEvent = {
id: "event-codex-flows",
type: "downstream.release",
receivedAt: "2026-05-17T00:00:00.000Z",
payload: {
packageName: "@peezy.tech/codex-flows",
version: "0.4.0",
repo: "peezy-tech/codex-flows",
},
};
expect((await matchingSteps(flows, codexRelease)).map(({ flow, step }) => `${flow.manifest.name}/${step.name}`)).toEqual([
"peezy-codex-flows-fork/release-fork",
]);
expect((await matchingSteps(flows, codexFlowsRelease)).map(({ flow, step }) => `${flow.manifest.name}/${step.name}`)).toEqual([
"peezy-codex-flows-fork/release-fork",
]);
expect(await matchingSteps(flows, {
...codexRelease,
payload: { packageName: "@example/other", version: "1.0.0" },
})).toEqual([]);
});
test("bundled Codex fork flow uses the deterministic Node runner", async () => {
const root = repoRoot;
const flows = await discoverFlows({ cwd: root });
const flow = flows.find((entry) => entry.manifest.name === "peezy-codex-fork");
const step = flow?.manifest.steps.find((entry) => entry.name === "release-cycle");
if (!flow || !step) {
throw new Error("expected bundled peezy-codex-fork flow");
}
expect(step.runner).toBe("node");
expect(step.script).toBe("exec/update-fork.ts");
});
test("bundled Codex fork Node step returns FLOW_RESULT through the Node runner", async () => {
const root = repoRoot;
const flows = await discoverFlows({ cwd: root });
const flow = flows.find((entry) => entry.manifest.name === "peezy-codex-fork");
const step = flow?.manifest.steps.find((entry) => entry.name === "release-cycle");
if (!flow || !step) {
throw new Error("expected bundled peezy-codex-fork flow");
}
await expect(runFlowStep({
flow,
step,
event: {
id: "event-1",
type: "upstream.release",
receivedAt: "2026-05-13T00:00:00.000Z",
payload: { repo: "example/other", tag: "rust-v1.2.3" },
},
env: {},
})).resolves.toMatchObject({
status: "skipped",
message: "Ignoring upstream event for example/other.",
});
});
test("Codex fork flow stores upstream release tags outside local release tags", async () => {
const { upstreamReleaseTagRef } = await codexForkFlowHelpers();
expect(upstreamReleaseTagRef("rust-v1.2.3")).toBe("refs/codex-flow/upstream-release-tags/rust-v1.2.3");
});
test("Codex fork flow parses remote release tag refs", async () => {
const { parseRemoteTagRef } = await codexForkFlowHelpers();
expect(parseRemoteTagRef([
"1111111111111111111111111111111111111111\trefs/tags/rust-v1.2.3",
"2222222222222222222222222222222222222222\trefs/tags/rust-v1.2.3^{}",
].join("\n"), "rust-v1.2.3")).toEqual({
objectSha: "1111111111111111111111111111111111111111",
commitSha: "2222222222222222222222222222222222222222",
});
expect(parseRemoteTagRef(
"3333333333333333333333333333333333333333\trefs/tags/rust-v1.2.3\n",
"rust-v1.2.3",
)).toEqual({
objectSha: "3333333333333333333333333333333333333333",
commitSha: "3333333333333333333333333333333333333333",
});
});
async function codexForkFlowHelpers(): Promise<CodexForkFlowHelpers> {
const modulePath = pathToFileURL(path.resolve(
testDir,
"..",
"..",
"..",
"flows",
"peezy-codex-fork",
"exec",
"update-fork.ts",
)).href;
return await import(modulePath) as CodexForkFlowHelpers;
}
test("validates simple JSON schema constraints", () => {
const schema = {
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
kind: { enum: ["demo"] },
},
};
expect(validateJsonSchema({ name: "Ada", kind: "demo" }, schema)).toEqual({ ok: true });
expect(validateJsonSchema({ kind: "other" }, schema)).toEqual({
ok: false,
errors: ["$.name is required", "$.kind must be one of demo"],
});
});
test("runs Node flow steps and parses FLOW_RESULT", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "flow-runtime-"));
try {
await writeFlow(directory, "flows/demo", "source");
const [flow] = await discoverFlows({ cwd: directory });
const step = flow?.manifest.steps[0];
if (!flow || !step) {
throw new Error("expected fixture flow");
}
const result = await runNodeStep({
flow,
step,
event: {
id: "event-1",
type: "demo.event",
receivedAt: "2026-05-13T00:00:00.000Z",
payload: { name: "Ada" },
},
});
expect(result).toEqual({
status: "completed",
message: "hello Ada",
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("runs module-style Node flow steps and passes runtime metadata", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "flow-runtime-"));
try {
await writeFlow(directory, "flows/demo", "source", [
"export default async function step(context) {",
" return {",
" status: 'completed',",
" message: `${context.runtime.runId}:${context.runtime.attemptId}:${context.runtime.replay}` ,",
" artifacts: {",
" eventId: context.runtime.eventId,",
" workspaceBackendUrl: context.runtime.workspaceBackendUrl,",
" envRunId: process.env.CODEX_FLOW_RUN_ID,",
" envEventId: process.env.CODEX_FLOW_EVENT_ID,",
" envReplay: process.env.CODEX_FLOW_REPLAY,",
" envWorkspaceBackendUrl: process.env.CODEX_WORKSPACE_BACKEND_WS_URL,",
" },",
" };",
"}",
"",
].join("\n"));
const [flow] = await discoverFlows({ cwd: directory });
const step = flow?.manifest.steps[0];
if (!flow || !step) {
throw new Error("expected fixture flow");
}
const result = await runNodeStep({
flow,
step,
event: {
id: "event-1",
type: "demo.event",
receivedAt: "2026-05-13T00:00:00.000Z",
payload: { name: "Ada" },
},
runtime: {
runId: "run_123",
attemptId: "attempt_1",
replay: true,
workspaceBackendUrl: "ws://127.0.0.1:3586",
},
});
expect(result).toEqual({
status: "completed",
message: "run_123:attempt_1:true",
artifacts: {
eventId: "event-1",
workspaceBackendUrl: "ws://127.0.0.1:3586",
envRunId: "run_123",
envEventId: "event-1",
envReplay: "1",
envWorkspaceBackendUrl: "ws://127.0.0.1:3586",
},
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("runs defineNodeFlow module-style Node flow steps", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "flow-runtime-"));
try {
const helperUrl = pathToFileURL(path.resolve(testDir, "../src/node.ts")).href;
await writeFlow(directory, "flows/demo", "source", [
`import { defineNodeFlow } from ${JSON.stringify(helperUrl)};`,
"export default defineNodeFlow(async (context) => ({",
" status: 'completed',",
" message: `hello ${context.flow.event.payload.name}`",
"}));",
"",
].join("\n"));
const [flow] = await discoverFlows({ cwd: directory });
const step = flow?.manifest.steps[0];
if (!flow || !step) {
throw new Error("expected fixture flow");
}
const result = await runNodeStep({
flow,
step,
event: {
id: "event-1",
type: "demo.event",
receivedAt: "2026-05-13T00:00:00.000Z",
payload: { name: "Ada" },
},
});
expect(result).toEqual({
status: "completed",
message: "hello Ada",
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("Node flow helpers read context and create workspace-backed Codex clients", async () => {
const context = await readFlowContext(JSON.stringify({
flow: {
name: "demo",
version: 1,
root: "/tmp/demo",
step: "hello",
event: {
id: "event-1",
type: "demo.event",
receivedAt: "2026-05-13T00:00:00.000Z",
payload: {},
},
},
runtime: {
eventId: "event-1",
runId: "run_123",
replay: false,
workspaceBackendUrl: "ws://127.0.0.1:3586",
},
}));
const fakeTransport = {
requestTimeoutMs: 1000,
calls: [] as Array<{ method: string; params?: unknown }>,
start() {},
close() {},
async request(method: string, params?: unknown) {
this.calls.push({ method, params });
if (method === "workspace.initialize") {
return {};
}
if (method === "appServer.call" && isRecord(params)) {
if (params.method === "thread/start" || params.method === "thread/resume") {
return { thread: { id: "thread-1" } };
}
if (params.method === "turn/start") {
return { turn: { id: "turn-1", status: "running" } };
}
}
throw new Error(`unexpected request ${method}`);
},
notify() {},
on() {},
off() {},
};
expect(workspaceBackendUrlFromContext(context)).toBe("ws://127.0.0.1:3586");
const workspaceClient = createWorkspaceBackendClientFromContext(context, {
transport: fakeTransport as never,
});
const codex = createCodexFlowClientFromContext(context, { workspaceClient });
expect(codex.client).toBe(workspaceClient);
await expect(codex.startFlow({
prompt: "continue",
threadId: "existing-thread",
wait: false,
})).resolves.toMatchObject({
threadId: "thread-1",
turnId: "turn-1",
});
expect(fakeTransport.calls.map((call) => call.method)).toEqual([
"workspace.initialize",
"appServer.call",
"appServer.call",
]);
expect(fakeTransport.calls[1]?.params).toMatchObject({
method: "thread/resume",
params: { threadId: "existing-thread" },
});
codex.close();
});
async function writeFlow(
root: string,
relative: string,
description: string,
script?: string,
): Promise<void> {
const flowRoot = path.join(root, relative);
await mkdir(path.join(flowRoot, "exec"), { recursive: true });
await mkdir(path.join(flowRoot, "schemas"), { recursive: true });
await writeFile(
path.join(flowRoot, "flow.toml"),
[
'name = "demo"',
"version = 1",
`description = "${description}"`,
"",
"[[steps]]",
'name = "hello"',
'runner = "node"',
'script = "exec/hello.ts"',
"timeout_ms = 30000",
"",
"[steps.trigger]",
'type = "demo.event"',
'schema = "schemas/demo-event.schema.json"',
"",
].join("\n"),
);
await writeFile(
path.join(flowRoot, "schemas/demo-event.schema.json"),
JSON.stringify({
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
},
}),
);
await writeFile(
path.join(flowRoot, "exec/hello.ts"),
script ?? [
"async function main() {",
" const chunks = [];",
" for await (const chunk of process.stdin) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);",
" const context = JSON.parse(Buffer.concat(chunks).toString('utf8'));",
" const name = context.flow.event.payload.name;",
" console.log(`FLOW_RESULT ${JSON.stringify({ status: 'completed', message: `hello ${name}` })}`);",
"}",
"void main();",
"",
].join("\n"),
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -1,291 +0,0 @@
import { expect, test } from "vite-plus/test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createLocalFlowClient } from "../src/local-client.ts";
import type { FlowEvent } from "../src/index.ts";
test("local client dispatches matching steps and returns normalized views", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "flow-local-client-"));
try {
await writeFlow(directory, "flows/demo", "source");
await writeFlow(directory, ".codex/flows/demo", "installed");
const client = createLocalFlowClient({ cwd: directory, env: {} });
const event: FlowEvent = {
id: "event-1",
type: "demo.event",
receivedAt: "2026-05-15T00:00:00.000Z",
payload: { name: "Ada" },
};
const result = await client.dispatchEvent(event);
expect(result).toMatchObject({
status: "accepted",
eventId: "event-1",
matched: 1,
runs: [
{
eventId: "event-1",
flowName: "demo",
stepName: "hello",
backend: "local",
processStatus: "completed",
resultStatus: "completed",
effectiveStatus: "completed",
needsAttention: false,
resultPayload: {
status: "completed",
message: "installed Ada",
},
},
],
});
const run = result.runs[0];
if (!run) {
throw new Error("expected one local run");
}
expect(result.runIds).toEqual([run.id]);
const eventView = await client.getEvent("event-1");
expect(eventView).toMatchObject({
id: "event-1",
type: "demo.event",
runIds: result.runIds,
runs: [{ id: result.runIds[0] }],
});
const runs = await client.listRuns({ eventId: "event-1" });
expect(runs.runs.map((run) => run.id)).toEqual(result.runIds);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("local memory state dedupes normal dispatch and replays new attempts", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "flow-local-client-"));
try {
await writeFlow(directory, "flows/demo", "demo");
const client = createLocalFlowClient({ cwd: directory, env: {} });
const event: FlowEvent = {
id: "event-1",
type: "demo.event",
receivedAt: "2026-05-15T00:00:00.000Z",
payload: { name: "Ada" },
};
const first = await client.dispatchEvent(event);
const duplicate = await client.dispatchEvent(event);
expect(duplicate).toMatchObject({
status: "duplicate",
eventId: "event-1",
matched: 0,
idempotent: true,
runIds: first.runIds,
});
const replay = await client.replayEvent("event-1");
expect(replay.status).toBe("accepted");
expect(replay.runIds).toHaveLength(1);
expect(replay.runIds[0]).not.toBe(first.runIds[0]);
expect(replay.runIds[0]?.endsWith("_replay")).toBe(true);
const eventView = await client.getEvent("event-1");
expect(eventView.runIds).toEqual([...first.runIds, ...replay.runIds]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("local file state persists events and runs across client instances", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "flow-local-client-"));
try {
await writeFlow(directory, "flows/demo", "demo");
const dataDir = path.join(directory, ".codex", "flow-client");
const event: FlowEvent = {
id: "event-1",
type: "demo.event",
receivedAt: "2026-05-15T00:00:00.000Z",
payload: { name: "Ada" },
};
const firstClient = createLocalFlowClient({
cwd: directory,
env: {},
state: { kind: "file", dataDir },
});
const first = await firstClient.dispatchEvent(event);
const secondClient = createLocalFlowClient({
cwd: directory,
env: {},
state: { kind: "file", dataDir },
});
const eventView = await secondClient.getEvent("event-1");
expect(eventView.runIds).toEqual(first.runIds);
const duplicate = await secondClient.dispatchEvent(event);
expect(duplicate).toMatchObject({
status: "duplicate",
idempotent: true,
runIds: first.runIds,
});
const replay = await secondClient.replayEvent("event-1");
expect(replay.runIds[0]?.endsWith("_replay")).toBe(true);
const thirdClient = createLocalFlowClient({
cwd: directory,
env: {},
state: { kind: "file", dataDir },
});
expect((await thirdClient.getEvent("event-1")).runIds).toEqual([
...first.runIds,
...replay.runIds,
]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("local client marks semantic attention statuses from FLOW_RESULT", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "flow-local-client-"));
try {
await writeFlow(directory, "flows/demo", "demo");
const client = createLocalFlowClient({ cwd: directory, env: {} });
const result = await client.dispatchEvent({
id: "event-blocked",
type: "demo.event",
receivedAt: "2026-05-15T00:00:00.000Z",
payload: { name: "Ada", status: "blocked" },
});
expect(result.runs[0]).toMatchObject({
processStatus: "completed",
resultStatus: "blocked",
effectiveStatus: "blocked",
needsAttention: true,
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("local client emits run progress and streams Node stderr", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "flow-local-client-"));
try {
await writeFlow(directory, "flows/demo", "demo");
const progress: Array<{ kind: string; text?: string; status?: string; runId?: string }> = [];
const client = createLocalFlowClient({
cwd: directory,
env: {},
progress: (event) => progress.push(event),
});
const result = await client.dispatchEvent({
id: "event-progress",
type: "demo.event",
receivedAt: "2026-05-15T00:00:00.000Z",
payload: { name: "Ada", stderr: "working\\n" },
});
expect(result.runs[0]?.effectiveStatus).toBe("completed");
expect(progress.map((event) => event.kind)).toEqual(["run_start", "stderr", "run_complete"]);
expect(progress[0]).toMatchObject({
kind: "run_start",
runId: result.runIds[0],
});
expect(progress[1]).toMatchObject({
kind: "stderr",
runId: result.runIds[0],
text: "working\\n",
});
expect(progress[2]).toMatchObject({
kind: "run_complete",
runId: result.runIds[0],
status: "completed",
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("local client reports unsupported operations when state is disabled", async () => {
const client = createLocalFlowClient({
cwd: await mkdtemp(path.join(os.tmpdir(), "flow-local-client-")),
state: false,
});
await expect(client.listEvents()).rejects.toThrow("requires local state");
await expect(client.getRun("missing")).rejects.toThrow("requires local state");
await expect(client.replayEvent("missing")).rejects.toThrow("requires local state");
});
test("local memory state rejects unknown events and runs", async () => {
const client = createLocalFlowClient({
cwd: await mkdtemp(path.join(os.tmpdir(), "flow-local-client-")),
});
await expect(client.getEvent("missing")).rejects.toThrow("Unknown event");
await expect(client.getRun("missing")).rejects.toThrow("Unknown run");
await expect(client.replayEvent("missing")).rejects.toThrow("Unknown event");
});
async function writeFlow(
root: string,
relative: string,
label: string,
): Promise<void> {
const flowRoot = path.join(root, relative);
await mkdir(path.join(flowRoot, "exec"), { recursive: true });
await mkdir(path.join(flowRoot, "schemas"), { recursive: true });
await writeFile(
path.join(flowRoot, "flow.toml"),
[
'name = "demo"',
"version = 1",
`description = ${JSON.stringify(label)}`,
"",
"[[steps]]",
'name = "hello"',
'runner = "node"',
'script = "exec/hello.ts"',
"timeout_ms = 30000",
"",
"[steps.trigger]",
'type = "demo.event"',
'schema = "schemas/demo-event.schema.json"',
"",
].join("\n"),
);
await writeFile(
path.join(flowRoot, "schemas/demo-event.schema.json"),
JSON.stringify({
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
stderr: { type: "string" },
status: { enum: ["completed", "changed", "blocked", "needs_intervention", "failed"] },
},
}),
);
await writeFile(
path.join(flowRoot, "exec/hello.ts"),
[
"async function main() {",
" const chunks = [];",
" for await (const chunk of process.stdin) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);",
" const context = JSON.parse(Buffer.concat(chunks).toString('utf8'));",
" const payload = context.flow.event.payload;",
` const label = ${JSON.stringify(label)};`,
" const status = payload.status ?? 'completed';",
" if (payload.stderr) process.stderr.write(payload.stderr);",
" console.log(`FLOW_RESULT ${JSON.stringify({ status, message: `${label} ${payload.name}` })}`);",
"}",
"void main();",
"",
].join("\n"),
);
}

View file

@ -1,17 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": false,
"noEmit": false,
"noEmitOnError": true,
"outDir": "dist",
"paths": {},
"rootDir": "src",
"sourceMap": true,
"rewriteRelativeImportExtensions": true
},
"include": ["src/**/*.ts"],
"exclude": ["test/**/*.ts", "dist"]
}

View file

@ -1,26 +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"],
"baseUrl": ".",
"paths": {
"@peezy.tech/codex-flows": ["../codex-client/src/index.ts"],
"@peezy.tech/codex-flows/flows": ["../codex-client/src/app-server/flows.ts"],
"@peezy.tech/codex-flows/workspace-backend": ["../codex-client/src/workspace-backend/index.ts"]
}
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}

367
pnpm-lock.yaml generated
View file

@ -125,19 +125,6 @@ importers:
specifier: 'catalog:'
version: 5.9.3
apps/flow-runner:
dependencies:
'@peezy.tech/codex-flows':
specifier: workspace:*
version: link:../../packages/codex-client
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 24.12.4
typescript:
specifier: 'catalog:'
version: 5.9.3
apps/web:
dependencies:
'@peezy.tech/codex-flows':
@ -261,41 +248,6 @@ importers:
packages/codex-opencode-go-router: {}
packages/flow-backend-convex:
dependencies:
'@peezy.tech/codex-flows':
specifier: workspace:*
version: link:../codex-client
convex:
specifier: ^1.38.0
version: 1.39.1(react@19.2.6)
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 24.12.4
typescript:
specifier: 'catalog:'
version: 5.9.3
packages/flow-runtime:
dependencies:
'@peezy.tech/codex-flows':
specifier: workspace:*
version: link:../codex-client
smol-toml:
specifier: 'catalog:'
version: 1.6.1
tsx:
specifier: 'catalog:'
version: 4.22.3
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 24.12.4
typescript:
specifier: 'catalog:'
version: 5.9.3
packages/ui:
dependencies:
'@base-ui/react':
@ -623,312 +575,156 @@ packages:
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
'@esbuild/aix-ppc64@0.27.0':
resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/aix-ppc64@0.28.0':
resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.0':
resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm64@0.28.0':
resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.0':
resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-arm@0.28.0':
resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.0':
resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/android-x64@0.28.0':
resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.0':
resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-arm64@0.28.0':
resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.0':
resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/darwin-x64@0.28.0':
resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.0':
resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-arm64@0.28.0':
resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.0':
resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.0':
resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.0':
resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm64@0.28.0':
resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.0':
resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-arm@0.28.0':
resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.0':
resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-ia32@0.28.0':
resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.0':
resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-loong64@0.28.0':
resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.0':
resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-mips64el@0.28.0':
resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.0':
resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-ppc64@0.28.0':
resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.0':
resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-riscv64@0.28.0':
resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.0':
resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-s390x@0.28.0':
resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.0':
resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.28.0':
resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.0':
resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-arm64@0.28.0':
resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.0':
resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.0':
resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.0':
resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-arm64@0.28.0':
resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.0':
resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.0':
resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.0':
resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/openharmony-arm64@0.28.0':
resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.0':
resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/sunos-x64@0.28.0':
resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.0':
resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-arm64@0.28.0':
resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.0':
resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-ia32@0.28.0':
resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.0':
resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.28.0':
resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==}
engines: {node: '>=18'}
@ -2339,25 +2135,6 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
convex@1.39.1:
resolution: {integrity: sha512-W+gVXA7BpRF1xLlS1kGTtKVaqd5yonqbGESKiPtIUXjV744GdDz8IG7RVsSY5KzHbgxuJBHKaJYk+92OIHTskQ==}
engines: {node: '>=18.0.0', npm: '>=7.0.0'}
hasBin: true
peerDependencies:
'@auth0/auth0-react': ^2.0.1
'@clerk/clerk-react': ^4.12.8 || ^5.0.0
'@clerk/react': ^6.4.3
react: ^18.0.0 || ^19.0.0-0 || ^19.0.0
peerDependenciesMeta:
'@auth0/auth0-react':
optional: true
'@clerk/clerk-react':
optional: true
'@clerk/react':
optional: true
react:
optional: true
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@ -2543,11 +2320,6 @@ packages:
esast-util-from-js@2.0.1:
resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==}
esbuild@0.27.0:
resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==}
engines: {node: '>=18'}
hasBin: true
esbuild@0.28.0:
resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==}
engines: {node: '>=18'}
@ -3565,11 +3337,6 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
prettier@3.8.3:
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
engines: {node: '>=14'}
hasBin: true
pretty-ms@9.3.0:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
@ -4172,18 +3939,6 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
ws@8.20.1:
resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==}
engines: {node: '>=10.0.0'}
@ -4629,159 +4384,81 @@ snapshots:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.0':
optional: true
'@esbuild/aix-ppc64@0.28.0':
optional: true
'@esbuild/android-arm64@0.27.0':
optional: true
'@esbuild/android-arm64@0.28.0':
optional: true
'@esbuild/android-arm@0.27.0':
optional: true
'@esbuild/android-arm@0.28.0':
optional: true
'@esbuild/android-x64@0.27.0':
optional: true
'@esbuild/android-x64@0.28.0':
optional: true
'@esbuild/darwin-arm64@0.27.0':
optional: true
'@esbuild/darwin-arm64@0.28.0':
optional: true
'@esbuild/darwin-x64@0.27.0':
optional: true
'@esbuild/darwin-x64@0.28.0':
optional: true
'@esbuild/freebsd-arm64@0.27.0':
optional: true
'@esbuild/freebsd-arm64@0.28.0':
optional: true
'@esbuild/freebsd-x64@0.27.0':
optional: true
'@esbuild/freebsd-x64@0.28.0':
optional: true
'@esbuild/linux-arm64@0.27.0':
optional: true
'@esbuild/linux-arm64@0.28.0':
optional: true
'@esbuild/linux-arm@0.27.0':
optional: true
'@esbuild/linux-arm@0.28.0':
optional: true
'@esbuild/linux-ia32@0.27.0':
optional: true
'@esbuild/linux-ia32@0.28.0':
optional: true
'@esbuild/linux-loong64@0.27.0':
optional: true
'@esbuild/linux-loong64@0.28.0':
optional: true
'@esbuild/linux-mips64el@0.27.0':
optional: true
'@esbuild/linux-mips64el@0.28.0':
optional: true
'@esbuild/linux-ppc64@0.27.0':
optional: true
'@esbuild/linux-ppc64@0.28.0':
optional: true
'@esbuild/linux-riscv64@0.27.0':
optional: true
'@esbuild/linux-riscv64@0.28.0':
optional: true
'@esbuild/linux-s390x@0.27.0':
optional: true
'@esbuild/linux-s390x@0.28.0':
optional: true
'@esbuild/linux-x64@0.27.0':
optional: true
'@esbuild/linux-x64@0.28.0':
optional: true
'@esbuild/netbsd-arm64@0.27.0':
optional: true
'@esbuild/netbsd-arm64@0.28.0':
optional: true
'@esbuild/netbsd-x64@0.27.0':
optional: true
'@esbuild/netbsd-x64@0.28.0':
optional: true
'@esbuild/openbsd-arm64@0.27.0':
optional: true
'@esbuild/openbsd-arm64@0.28.0':
optional: true
'@esbuild/openbsd-x64@0.27.0':
optional: true
'@esbuild/openbsd-x64@0.28.0':
optional: true
'@esbuild/openharmony-arm64@0.27.0':
optional: true
'@esbuild/openharmony-arm64@0.28.0':
optional: true
'@esbuild/sunos-x64@0.27.0':
optional: true
'@esbuild/sunos-x64@0.28.0':
optional: true
'@esbuild/win32-arm64@0.27.0':
optional: true
'@esbuild/win32-arm64@0.28.0':
optional: true
'@esbuild/win32-ia32@0.27.0':
optional: true
'@esbuild/win32-ia32@0.28.0':
optional: true
'@esbuild/win32-x64@0.27.0':
optional: true
'@esbuild/win32-x64@0.28.0':
optional: true
@ -5887,17 +5564,6 @@ snapshots:
convert-source-map@2.0.0: {}
convex@1.39.1(react@19.2.6):
dependencies:
esbuild: 0.27.0
prettier: 3.8.3
ws: 8.18.0
optionalDependencies:
react: 19.2.6
transitivePeerDependencies:
- bufferutil
- utf-8-validate
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
@ -6066,35 +5732,6 @@ snapshots:
esast-util-from-estree: 2.0.0
vfile-message: 4.0.3
esbuild@0.27.0:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.0
'@esbuild/android-arm': 0.27.0
'@esbuild/android-arm64': 0.27.0
'@esbuild/android-x64': 0.27.0
'@esbuild/darwin-arm64': 0.27.0
'@esbuild/darwin-x64': 0.27.0
'@esbuild/freebsd-arm64': 0.27.0
'@esbuild/freebsd-x64': 0.27.0
'@esbuild/linux-arm': 0.27.0
'@esbuild/linux-arm64': 0.27.0
'@esbuild/linux-ia32': 0.27.0
'@esbuild/linux-loong64': 0.27.0
'@esbuild/linux-mips64el': 0.27.0
'@esbuild/linux-ppc64': 0.27.0
'@esbuild/linux-riscv64': 0.27.0
'@esbuild/linux-s390x': 0.27.0
'@esbuild/linux-x64': 0.27.0
'@esbuild/netbsd-arm64': 0.27.0
'@esbuild/netbsd-x64': 0.27.0
'@esbuild/openbsd-arm64': 0.27.0
'@esbuild/openbsd-x64': 0.27.0
'@esbuild/openharmony-arm64': 0.27.0
'@esbuild/sunos-x64': 0.27.0
'@esbuild/win32-arm64': 0.27.0
'@esbuild/win32-ia32': 0.27.0
'@esbuild/win32-x64': 0.27.0
esbuild@0.28.0:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.0
@ -7504,8 +7141,6 @@ snapshots:
powershell-utils@0.1.0: {}
prettier@3.8.3: {}
pretty-ms@9.3.0:
dependencies:
parse-ms: 4.0.0
@ -8256,8 +7891,6 @@ snapshots:
wrappy@1.0.2: {}
ws@8.18.0: {}
ws@8.20.1: {}
wsl-utils@0.3.1:

View file

@ -4,27 +4,14 @@ import { defineConfig } from "vite-plus";
const root = path.dirname(fileURLToPath(import.meta.url));
const codexClientSrc = path.resolve(root, "packages/codex-client/src");
const flowRuntimeSrc = path.resolve(root, "packages/flow-runtime/src");
export default defineConfig({
resolve: {
alias: [
{
find: /^@peezy\.tech\/codex-flows\/flow-runtime$/,
replacement: path.join(flowRuntimeSrc, "index.ts"),
},
{
find: /^@peezy\.tech\/codex-flows\/flow-runtime\/(.+)$/,
replacement: path.join(flowRuntimeSrc, "$1.ts"),
},
{
find: /^@peezy\.tech\/codex-flows\/browser$/,
replacement: path.join(codexClientSrc, "browser.ts"),
},
{
find: /^@peezy\.tech\/codex-flows\/flows$/,
replacement: path.join(codexClientSrc, "app-server/flows.ts"),
},
{
find: /^@peezy\.tech\/codex-flows\/auth$/,
replacement: path.join(codexClientSrc, "auth.ts"),