Add flow runtime and Codex release flows
Some checks failed
ci / check (push) Failing after 16s

This commit is contained in:
matamune 2026-05-13 02:42:13 +00:00
parent 5241b634e2
commit e68b8adfb9
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
35 changed files with 2957 additions and 5 deletions

View file

@ -0,0 +1,24 @@
{
"name": "@peezy.tech/flow-runtime",
"version": "0.1.0",
"description": "Generic flow package loader and runner primitives.",
"type": "module",
"private": true,
"license": "Apache-2.0",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"check:types": "tsc --noEmit",
"test": "bun test test/*.test.ts"
},
"dependencies": {
"@peezy.tech/codex-flows": "workspace:*"
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

View file

@ -0,0 +1,18 @@
export { discoverFlows, loadFlow, stepSchemaPath, stepScriptPath } from "./manifest.ts";
export { parseFlowResult, stringifyFlowResult } from "./result.ts";
export { runFlowStep } from "./run.ts";
export { runBunStep } from "./runners/bun.ts";
export { runCodeModeStep } from "./runners/code-mode.ts";
export { readJsonSchema, validateJsonSchema } from "./schema.ts";
export { matchingSteps, stepMatchesEvent } from "./triggers.ts";
export type {
FlowEvent,
FlowManifest,
FlowResult,
FlowResultStatus,
FlowRunContext,
FlowStep,
FlowStepRunner,
FlowStepTrigger,
LoadedFlow,
} from "./types.ts";

View file

@ -0,0 +1,137 @@
import { readdir } from "node:fs/promises";
import path from "node:path";
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 = Bun.TOML.parse(await Bun.file(manifestPath).text()) 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 Bun.file(manifestPath).exists())) {
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 !== "bun" && runner !== "code-mode") {
throw new Error(`steps[${index}].runner must be bun or code-mode: ${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;
}

View file

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

View file

@ -0,0 +1,33 @@
import { runBunStep } from "./runners/bun.ts";
import { runCodeModeStep, type RunCodeModeStepOptions } from "./runners/code-mode.ts";
import type { FlowEvent, FlowResult, FlowStep, LoadedFlow } from "./types.ts";
export type RunFlowStepOptions = {
flow: LoadedFlow;
step: FlowStep;
event: FlowEvent;
env?: Record<string, string | undefined>;
codeMode?: Pick<RunCodeModeStepOptions, "codexCommand" | "codexHome" | "stream">;
};
export async function runFlowStep(options: RunFlowStepOptions): Promise<FlowResult> {
if (options.step.runner === "bun") {
return runBunStep(options);
}
if (!codeModeEnabled(options.env ?? process.env)) {
throw new Error(
`Code Mode flow step ${options.flow.manifest.name}/${options.step.name} requires CODEX_FLOWS_ENABLE_CODE_MODE=1`,
);
}
return runCodeModeStep({
flow: options.flow,
step: options.step,
event: options.event,
...options.codeMode,
});
}
export function codeModeEnabled(env: Record<string, string | undefined>): boolean {
const value = env.CODEX_FLOWS_ENABLE_CODE_MODE?.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes" || value === "on";
}

View file

@ -0,0 +1,54 @@
import path from "node:path";
import { stepScriptPath } from "../manifest.ts";
import { parseFlowResult } from "../result.ts";
import type { FlowEvent, FlowResult, FlowRunContext, FlowStep, LoadedFlow } from "../types.ts";
export type RunBunStepOptions = {
flow: LoadedFlow;
step: FlowStep;
event: FlowEvent;
env?: Record<string, string | undefined>;
};
export async function runBunStep(options: RunBunStepOptions): 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 subprocess = Bun.spawn({
cmd: [process.execPath, scriptPath],
cwd,
env: {
...process.env,
...options.env,
},
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
subprocess.stdin.write(`${JSON.stringify(runContext(options), null, 2)}\n`);
subprocess.stdin.end();
const timer = setTimeout(() => subprocess.kill("SIGTERM"), options.step.timeoutMs);
const [stdout, stderr, exitCode] = await Promise.all([
subprocess.stdout.text(),
subprocess.stderr.text(),
subprocess.exited,
]).finally(() => clearTimeout(timer));
if (exitCode !== 0) {
throw new Error(`Bun flow step ${options.flow.manifest.name}/${options.step.name} failed:\n${stderr || stdout}`);
}
return parseFlowResult(stdout);
}
function runContext(options: RunBunStepOptions): FlowRunContext {
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,
},
};
}

View file

@ -0,0 +1,176 @@
import path from "node:path";
import { CodexAppServerClient } from "@peezy.tech/codex-flows";
import { stepScriptPath } from "../manifest.ts";
import { parseFlowResult } from "../result.ts";
import type { FlowEvent, FlowResult, FlowStep, LoadedFlow } from "../types.ts";
export type RunCodeModeStepOptions = {
flow: LoadedFlow;
step: FlowStep;
event: FlowEvent;
codexCommand?: string;
codexHome?: string;
stream?: boolean;
};
export async function runCodeModeStep(options: RunCodeModeStepOptions): Promise<FlowResult> {
const source = await codeModeSource(options);
const client = new CodexAppServerClient({
transportOptions: {
codexCommand: options.codexCommand,
args: appServerArgs(),
env: options.codexHome ? { CODEX_HOME: path.resolve(options.codexHome) } : undefined,
requestTimeoutMs: options.step.timeoutMs,
},
clientName: "codex-flow-runner",
clientTitle: "Codex Flow Runner",
clientVersion: "0.1.0",
});
const output: string[] = [];
let threadId = "";
let resolveTurnCompleted: (value: unknown) => void = () => undefined;
const turnCompleted = new Promise((resolve) => {
resolveTurnCompleted = resolve;
});
client.on("request", (message) => {
client.respondError(message.id, -32603, "flow runner does not handle server requests");
});
client.on("notification", (message) => {
if (message.method === "item/commandExecution/outputDelta" || message.method === "item/agentMessage/delta") {
const delta = stringField(message.params, "delta");
if (delta) {
output.push(delta);
if (options.stream) {
process.stdout.write(delta);
}
}
}
if (
message.method === "turn/completed" &&
(!threadId || stringField(message.params, "threadId") === threadId)
) {
resolveTurnCompleted(message.params);
}
});
try {
await client.connect();
const started = await client.startThread({
cwd: options.step.cwd ? path.resolve(options.flow.root, options.step.cwd) : options.flow.root,
approvalPolicy: "never",
sandbox: "danger-full-access",
ephemeral: false,
experimentalRawEvents: false,
persistExtendedHistory: true,
});
threadId = started.thread.id;
await client.request("thread/codeMode/execute", {
threadId,
source,
});
await withTimeout(
turnCompleted,
options.step.timeoutMs,
`timed out waiting for Code Mode flow step ${options.flow.manifest.name}/${options.step.name}`,
);
const read = await client.request("thread/read", {
threadId,
includeTurns: true,
});
return parseFlowResult(allAgentMessageText(read).join("\n") || output.join(""));
} finally {
client.close();
}
}
async function codeModeSource(options: RunCodeModeStepOptions): Promise<string> {
const body = await Bun.file(stepScriptPath(options.flow, options.step)).text();
const 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,
};
return [
`const flow = ${JSON.stringify(flow, null, 2)};`,
"function result(value) {",
" text('\\nFLOW_RESULT ' + JSON.stringify(value) + '\\n');",
" exit();",
"}",
body,
].join("\n");
}
function appServerArgs(): string[] {
return [
"app-server",
"--listen",
"stdio://",
"--enable",
"apps",
"--enable",
"hooks",
"--enable",
"code_mode",
"--enable",
"code_mode_only",
];
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
function allAgentMessageText(value: unknown): string[] {
const thread = recordField(value, "thread");
const turns = Array.isArray(thread?.turns) ? thread.turns : [];
const texts: string[] = [];
for (const turn of turns) {
const turnRecord = isRecord(turn) ? turn : undefined;
const items = Array.isArray(turnRecord?.items) ? turnRecord.items : [];
for (const item of items) {
if (!isRecord(item) || stringField(item, "type") !== "agentMessage") {
continue;
}
const text = stringField(item, "text");
if (text !== undefined) {
texts.push(text);
}
}
}
return texts;
}
function recordField(value: unknown, field: string): Record<string, unknown> | undefined {
if (!isRecord(value)) {
return undefined;
}
return isRecord(value[field]) ? value[field] : undefined;
}
function stringField(value: unknown, field: string): string | undefined {
if (!isRecord(value)) {
return undefined;
}
const fieldValue = value[field];
return typeof fieldValue === "string" ? fieldValue : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

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

View file

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

View file

@ -0,0 +1,68 @@
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 = "bun" | "code-mode";
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 FlowRunContext = {
flow: {
name: string;
version: number;
root: string;
step: string;
config?: Record<string, unknown>;
event: FlowEvent;
};
};

View file

@ -0,0 +1,209 @@
import { expect, test } from "bun:test";
import { mkdtemp, rm, mkdir } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
discoverFlows,
matchingSteps,
runBunStep,
runFlowStep,
validateJsonSchema,
} from "../src/index.ts";
import type { FlowEvent } from "../src/index.ts";
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 = path.resolve(import.meta.dir, "..", "..", "..");
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/rebase-patch-stack",
]);
});
test("bundled Code Mode flow remains gated by the feature flag", async () => {
const root = path.resolve(import.meta.dir, "..", "..", "..");
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 === "rebase-patch-stack");
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: "openai/codex", tag: "rust-v1.2.3" },
},
env: {},
}),
).rejects.toThrow("requires CODEX_FLOWS_ENABLE_CODE_MODE=1");
});
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 Bun 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 runBunStep({
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("requires a feature flag before running Code Mode flow steps", 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");
}
await expect(
runFlowStep({
flow,
step: { ...step, runner: "code-mode" },
event: {
id: "event-1",
type: "demo.event",
receivedAt: "2026-05-13T00:00:00.000Z",
payload: { name: "Ada" },
},
env: {},
}),
).rejects.toThrow("requires CODEX_FLOWS_ENABLE_CODE_MODE=1");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
async function writeFlow(root: string, relative: string, description: 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 Bun.write(
path.join(flowRoot, "flow.toml"),
[
'name = "demo"',
"version = 1",
`description = "${description}"`,
"",
"[[steps]]",
'name = "hello"',
'runner = "bun"',
'script = "exec/hello.ts"',
"timeout_ms = 30000",
"",
"[steps.trigger]",
'type = "demo.event"',
'schema = "schemas/demo-event.schema.json"',
"",
].join("\n"),
);
await Bun.write(
path.join(flowRoot, "schemas/demo-event.schema.json"),
JSON.stringify({
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
},
}),
);
await Bun.write(
path.join(flowRoot, "exec/hello.ts"),
[
"const context = JSON.parse(await Bun.stdin.text());",
"const name = context.flow.event.payload.name;",
"console.log(`FLOW_RESULT ${JSON.stringify({ status: 'completed', message: `hello ${name}` })}`);",
"",
].join("\n"),
);
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"target": "ES2022",
"lib": ["ESNext"],
"strict": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"types": ["node", "bun"],
"rootDir": "."
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}