Remove legacy flow runtime surface
This commit is contained in:
parent
9ca23a72e9
commit
391ebbc857
80 changed files with 81 additions and 10613 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export type DiscordBridgeConfig = {
|
|||
allowedChannelIds: Set<string>;
|
||||
statePath: string;
|
||||
workspace?: DiscordWorkspaceConfig;
|
||||
flowBackendUrl?: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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/*"],
|
||||
|
|
|
|||
|
|
@ -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/*"],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"),
|
||||
);
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -93,7 +93,6 @@ class FakeWorkspaceTransport extends CodexEventEmitter {
|
|||
capabilities: {
|
||||
appServerPassThrough: true,
|
||||
workspaceMethods: [],
|
||||
flowInspection: false,
|
||||
},
|
||||
} satisfies WorkspaceBackendInitializeResponse as T;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
|
@ -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"
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
}),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {};
|
||||
|
|
@ -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
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { defineComponent } from "convex/server";
|
||||
|
||||
const component = defineComponent("flowBackend");
|
||||
|
||||
export default component;
|
||||
|
|
@ -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"]),
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"),
|
||||
);
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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
367
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue