This commit is contained in:
parent
5241b634e2
commit
e68b8adfb9
35 changed files with 2957 additions and 5 deletions
24
packages/flow-runtime/package.json
Normal file
24
packages/flow-runtime/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
18
packages/flow-runtime/src/index.ts
Normal file
18
packages/flow-runtime/src/index.ts
Normal 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";
|
||||
137
packages/flow-runtime/src/manifest.ts
Normal file
137
packages/flow-runtime/src/manifest.ts
Normal 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;
|
||||
}
|
||||
37
packages/flow-runtime/src/result.ts
Normal file
37
packages/flow-runtime/src/result.ts
Normal 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);
|
||||
}
|
||||
33
packages/flow-runtime/src/run.ts
Normal file
33
packages/flow-runtime/src/run.ts
Normal 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";
|
||||
}
|
||||
54
packages/flow-runtime/src/runners/bun.ts
Normal file
54
packages/flow-runtime/src/runners/bun.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
176
packages/flow-runtime/src/runners/code-mode.ts
Normal file
176
packages/flow-runtime/src/runners/code-mode.ts
Normal 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);
|
||||
}
|
||||
81
packages/flow-runtime/src/schema.ts
Normal file
81
packages/flow-runtime/src/schema.ts
Normal 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);
|
||||
}
|
||||
41
packages/flow-runtime/src/triggers.ts
Normal file
41
packages/flow-runtime/src/triggers.ts
Normal 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;
|
||||
}
|
||||
68
packages/flow-runtime/src/types.ts
Normal file
68
packages/flow-runtime/src/types.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
209
packages/flow-runtime/test/flow-runtime.test.ts
Normal file
209
packages/flow-runtime/test/flow-runtime.test.ts
Normal 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"),
|
||||
);
|
||||
}
|
||||
21
packages/flow-runtime/tsconfig.json
Normal file
21
packages/flow-runtime/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue