feat: Phase 2 — Thread lifecycle, execution engine, worker, CLI
- types.ts: START/END, RoleMeta, ThreadContext, Role, Moderator, WorkflowDefinition - engine.ts: executeThread with JSONL persistence + AbortSignal - worker.ts: per-bundle process, TCP IPC, kill individual threads - CLI: run/ps/kill/threads/thread/thread rm commands - 32 tests pass, biome clean 小橘 <xiaoju@shazhou.work>
This commit is contained in:
+18
-11
@@ -1,11 +1,9 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": ["**/dist/**", "**/node_modules/**"]
|
"includes": ["**", "!**/dist", "!**/node_modules"]
|
||||||
},
|
|
||||||
"organizeImports": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
},
|
||||||
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
@@ -19,7 +17,7 @@
|
|||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"include": ["**/__tests__/**"],
|
"includes": ["**/__tests__/**"],
|
||||||
"linter": {
|
"linter": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
@@ -30,6 +28,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": ["**/*.d.ts"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"noDefaultExport": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"linter": {
|
"linter": {
|
||||||
@@ -43,7 +51,6 @@
|
|||||||
"noParameterProperties": "error",
|
"noParameterProperties": "error",
|
||||||
"useImportType": "error",
|
"useImportType": "error",
|
||||||
"useShorthandFunctionType": "error",
|
"useShorthandFunctionType": "error",
|
||||||
"noVar": "error",
|
|
||||||
"useConst": "error",
|
"useConst": "error",
|
||||||
"useEnumInitializers": "error"
|
"useEnumInitializers": "error"
|
||||||
},
|
},
|
||||||
@@ -60,15 +67,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noExplicitAny": "error"
|
"noExplicitAny": "error",
|
||||||
|
"noVar": "error",
|
||||||
|
"noConsole": "error"
|
||||||
},
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"noUnusedVariables": "error",
|
"noUnusedVariables": "error",
|
||||||
"noUnusedImports": "error"
|
"noUnusedImports": "error"
|
||||||
},
|
},
|
||||||
"nursery": {
|
"nursery": {}
|
||||||
"noConsole": "error"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -1,9 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-monorepo",
|
"name": "@uncaged/workflow-monorepo",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": ["packages/*"],
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun run --filter '*' build",
|
"build": "bun run --filter '*' build",
|
||||||
|
"check": "biome check .",
|
||||||
|
"format": "biome format --write .",
|
||||||
"test": "bun run --filter '*' test"
|
"test": "bun run --filter '*' test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { cmdAdd } from "../src/cmd-add.js";
|
||||||
|
import { cmdList, formatListLines } from "../src/cmd-list.js";
|
||||||
|
import { cmdRemove } from "../src/cmd-remove.js";
|
||||||
|
import { cmdShow } from "../src/cmd-show.js";
|
||||||
|
|
||||||
|
describe("cli workflow commands", () => {
|
||||||
|
let prevEnv: string | undefined;
|
||||||
|
let storageRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-"));
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (prevEnv === undefined) {
|
||||||
|
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
} else {
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||||
|
}
|
||||||
|
await rm(storageRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add / list / show / remove roundtrip", async () => {
|
||||||
|
const bundleDir = join(storageRoot, "src");
|
||||||
|
await mkdir(bundleDir, { recursive: true });
|
||||||
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
|
await writeFile(
|
||||||
|
bundlePath,
|
||||||
|
`import fs from "node:fs";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "solve-issue",
|
||||||
|
roles: {
|
||||||
|
noop: async () => {
|
||||||
|
fs.existsSync(".");
|
||||||
|
return { content: "ok", meta: { done: true } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moderator(ctx) {
|
||||||
|
if (ctx.steps.length === 0) {
|
||||||
|
return "noop";
|
||||||
|
}
|
||||||
|
return "__end__";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const added = await cmdAdd(storageRoot, "solve-issue", bundlePath);
|
||||||
|
expect(added.ok).toBe(true);
|
||||||
|
|
||||||
|
const listed = await cmdList(storageRoot);
|
||||||
|
expect(listed.ok).toBe(true);
|
||||||
|
if (listed.ok) {
|
||||||
|
const lines = formatListLines(listed.value);
|
||||||
|
expect(lines.some((l) => l.startsWith("solve-issue\t"))).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = await cmdShow(storageRoot, "solve-issue");
|
||||||
|
expect(shown.ok).toBe(true);
|
||||||
|
if (!shown.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(shown.value.hash.length).toBe(13);
|
||||||
|
|
||||||
|
const bundleOnDisk = await readFile(
|
||||||
|
join(storageRoot, "bundles", `${shown.value.hash}.esm.js`),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(bundleOnDisk.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const removed = await cmdRemove(storageRoot, "solve-issue");
|
||||||
|
expect(removed.ok).toBe(true);
|
||||||
|
|
||||||
|
const listedAfter = await cmdList(storageRoot);
|
||||||
|
expect(listedAfter.ok).toBe(true);
|
||||||
|
if (listedAfter.ok) {
|
||||||
|
expect(formatListLines(listedAfter.value)[0]).toBe("(no workflows registered)");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add rejects invalid bundles", async () => {
|
||||||
|
const bundlePath = join(storageRoot, "bad.esm.js");
|
||||||
|
await writeFile(
|
||||||
|
bundlePath,
|
||||||
|
'import x from "./local";\nexport default async function run() { return { returnCode: 0, summary: "" }; }\n',
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const r = await cmdAdd(storageRoot, "solve-issue", bundlePath);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { cmdAdd } from "../src/cmd-add.js";
|
||||||
|
import { cmdKill } from "../src/cmd-kill.js";
|
||||||
|
import { cmdPs } from "../src/cmd-ps.js";
|
||||||
|
import { cmdRun } from "../src/cmd-run.js";
|
||||||
|
import { cmdThreadRemove, cmdThreadShow } from "../src/cmd-thread.js";
|
||||||
|
import { cmdThreads } from "../src/cmd-threads.js";
|
||||||
|
import { pathExists } from "../src/fs-utils.js";
|
||||||
|
|
||||||
|
const fastBundleSource = `export default {
|
||||||
|
name: "solve-issue",
|
||||||
|
roles: {
|
||||||
|
planner: async () => ({ content: "plan", meta: { plan: "x" } }),
|
||||||
|
coder: async () => ({ content: "code", meta: { diff: "y" } }),
|
||||||
|
},
|
||||||
|
moderator(ctx) {
|
||||||
|
if (ctx.steps.length === 0) return "planner";
|
||||||
|
if (ctx.steps.length === 1) return "coder";
|
||||||
|
return "__end__";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const slowPlannerBundleSource = `export default {
|
||||||
|
name: "solve-issue",
|
||||||
|
roles: {
|
||||||
|
planner: async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
return { content: "plan", meta: { plan: "x" } };
|
||||||
|
},
|
||||||
|
coder: async () => ({ content: "code", meta: { diff: "y" } }),
|
||||||
|
},
|
||||||
|
moderator(ctx) {
|
||||||
|
if (ctx.steps.length === 0) return "planner";
|
||||||
|
if (ctx.steps.length === 1) return "coder";
|
||||||
|
return "__end__";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||||
|
|
||||||
|
const abortablePlannerBundleSource = `export default {
|
||||||
|
name: "solve-issue",
|
||||||
|
roles: {
|
||||||
|
planner: async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
return { content: "plan", meta: { plan: "x" } };
|
||||||
|
},
|
||||||
|
coder: async () => ({ content: "code", meta: { diff: "y" } }),
|
||||||
|
},
|
||||||
|
moderator(ctx) {
|
||||||
|
if (ctx.steps.length === 0) return "planner";
|
||||||
|
if (ctx.steps.length === 1) return "coder";
|
||||||
|
return "__end__";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("cli thread commands", () => {
|
||||||
|
let prevEnv: string | undefined;
|
||||||
|
let storageRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-thread-"));
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (prevEnv === undefined) {
|
||||||
|
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
} else {
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||||
|
}
|
||||||
|
await rm(storageRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("run / threads / thread / thread rm", async () => {
|
||||||
|
const bundleDir = join(storageRoot, "src");
|
||||||
|
await mkdir(bundleDir, { recursive: true });
|
||||||
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
|
await writeFile(bundlePath, fastBundleSource, "utf8");
|
||||||
|
|
||||||
|
const added = await cmdAdd(storageRoot, "solve-issue", bundlePath);
|
||||||
|
expect(added.ok).toBe(true);
|
||||||
|
if (!added.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||||
|
expect(ran.ok).toBe(true);
|
||||||
|
if (!ran.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = ran.value.threadId;
|
||||||
|
|
||||||
|
let threads = await cmdThreads(storageRoot, []);
|
||||||
|
for (
|
||||||
|
let attempt = 0;
|
||||||
|
attempt < 50 && threads.ok && !threads.value.some((l) => l.includes(threadId));
|
||||||
|
attempt++
|
||||||
|
) {
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
threads = await cmdThreads(storageRoot, []);
|
||||||
|
}
|
||||||
|
expect(threads.ok).toBe(true);
|
||||||
|
if (!threads.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(threads.value.some((l) => l.includes(threadId))).toBe(true);
|
||||||
|
|
||||||
|
const shown = await cmdThreadShow(storageRoot, threadId);
|
||||||
|
expect(shown.ok).toBe(true);
|
||||||
|
if (!shown.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(shown.value.includes('"threadId"')).toBe(true);
|
||||||
|
|
||||||
|
const removed = await cmdThreadRemove(storageRoot, threadId);
|
||||||
|
expect(removed.ok).toBe(true);
|
||||||
|
|
||||||
|
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
|
||||||
|
expect(await pathExists(dataPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cli entrypoint dispatches threads / ps (spawn)", () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const threads = spawnSync(process.execPath, [cliEntryPath, "threads"], {
|
||||||
|
env,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
expect(threads.status).toBe(0);
|
||||||
|
|
||||||
|
const ps = spawnSync(process.execPath, [cliEntryPath, "ps"], { env, encoding: "utf8" });
|
||||||
|
expect(ps.status).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ps lists running threads while planner role is in-flight", async () => {
|
||||||
|
const bundleDir = join(storageRoot, "src");
|
||||||
|
await mkdir(bundleDir, { recursive: true });
|
||||||
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
|
await writeFile(bundlePath, slowPlannerBundleSource, "utf8");
|
||||||
|
|
||||||
|
const added = await cmdAdd(storageRoot, "solve-issue", bundlePath);
|
||||||
|
expect(added.ok).toBe(true);
|
||||||
|
if (!added.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||||
|
expect(ran.ok).toBe(true);
|
||||||
|
if (!ran.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = ran.value.threadId;
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
const psEarly = await cmdPs(storageRoot);
|
||||||
|
expect(psEarly.some((l) => l.includes(threadId))).toBe(true);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 900));
|
||||||
|
|
||||||
|
const psLate = await cmdPs(storageRoot);
|
||||||
|
expect(psLate).toEqual(["(no running threads)"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("kill stops thread after the in-flight role (before subsequent roles)", async () => {
|
||||||
|
const bundleDir = join(storageRoot, "src");
|
||||||
|
await mkdir(bundleDir, { recursive: true });
|
||||||
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
|
await writeFile(bundlePath, abortablePlannerBundleSource, "utf8");
|
||||||
|
|
||||||
|
const added = await cmdAdd(storageRoot, "solve-issue", bundlePath);
|
||||||
|
expect(added.ok).toBe(true);
|
||||||
|
if (!added.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||||
|
expect(ran.ok).toBe(true);
|
||||||
|
if (!ran.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = ran.value.threadId;
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
const killed = await cmdKill(storageRoot, threadId);
|
||||||
|
expect(killed.ok).toBe(true);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 900));
|
||||||
|
|
||||||
|
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
|
||||||
|
const text = await readFile(dataPath, "utf8");
|
||||||
|
const lines = text
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l !== "");
|
||||||
|
expect(lines.length).toBe(2);
|
||||||
|
|
||||||
|
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||||
|
expect(await pathExists(runningPath)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
"uncaged-workflow": "src/cli.ts"
|
"uncaged-workflow": "src/cli.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*"
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"yaml": "^2.8.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "echo 'TODO'",
|
"build": "echo 'TODO'",
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { copyFile, mkdir, readFile, stat } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
async function pathExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await stat(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeWorkflowBundleCopy(
|
||||||
|
storageRoot: string,
|
||||||
|
hash: string,
|
||||||
|
resolvedSourcePath: string,
|
||||||
|
sourceText: string,
|
||||||
|
): Promise<Result<void, string>> {
|
||||||
|
const bundlesDir = join(storageRoot, "bundles");
|
||||||
|
const destPath = join(bundlesDir, `${hash}.esm.js`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(bundlesDir, { recursive: true });
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return err(`failed to store bundle: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await pathExists(destPath))) {
|
||||||
|
try {
|
||||||
|
await copyFile(resolvedSourcePath, destPath);
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return err(`failed to store bundle: ${message}`);
|
||||||
|
}
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing: string;
|
||||||
|
try {
|
||||||
|
existing = await readFile(destPath, "utf8");
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return err(`failed to store bundle: ${message}`);
|
||||||
|
}
|
||||||
|
if (existing !== sourceText) {
|
||||||
|
return err(`bundle hash ${hash} already exists with different contents; refusing to overwrite`);
|
||||||
|
}
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { printCliError, printCliLine } from "./cli-output.js";
|
||||||
|
import { cmdAdd, formatAddSuccess } from "./cmd-add.js";
|
||||||
|
import { cmdKill } from "./cmd-kill.js";
|
||||||
|
import { cmdList, formatListLines } from "./cmd-list.js";
|
||||||
|
import { cmdPs } from "./cmd-ps.js";
|
||||||
|
import { cmdRemove } from "./cmd-remove.js";
|
||||||
|
import { cmdRun } from "./cmd-run.js";
|
||||||
|
import { cmdShow, formatShowYaml } from "./cmd-show.js";
|
||||||
|
import { cmdThreadRemove, cmdThreadShow } from "./cmd-thread.js";
|
||||||
|
import { cmdThreads } from "./cmd-threads.js";
|
||||||
|
import { parseRunArgv } from "./run-argv.js";
|
||||||
|
|
||||||
|
function usage(): string {
|
||||||
|
return [
|
||||||
|
"Usage:",
|
||||||
|
" uncaged-workflow add <name> <file>",
|
||||||
|
" uncaged-workflow list",
|
||||||
|
" uncaged-workflow show <name>",
|
||||||
|
" uncaged-workflow remove <name>",
|
||||||
|
" uncaged-workflow run <name> [--prompt <text>] [--dry-run] [--max-rounds N]",
|
||||||
|
" uncaged-workflow ps",
|
||||||
|
" uncaged-workflow kill <thread-id>",
|
||||||
|
" uncaged-workflow threads [name]",
|
||||||
|
" uncaged-workflow thread <id>",
|
||||||
|
" uncaged-workflow thread rm <id>",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const name = argv[0];
|
||||||
|
const file = argv[1];
|
||||||
|
if (name === undefined || file === undefined || argv.length > 2) {
|
||||||
|
printCliError(`${usage()}\n\nerror: add requires <name> <file>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdAdd(storageRoot, name, file);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(formatAddSuccess(name, file, result.value.hash));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchList(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
if (argv.length > 0) {
|
||||||
|
printCliError(`${usage()}\n\nerror: list takes no arguments`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdList(storageRoot);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (const line of formatListLines(result.value)) {
|
||||||
|
printCliLine(line);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const name = argv[0];
|
||||||
|
if (name === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usage()}\n\nerror: show requires <name>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdShow(storageRoot, name);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(formatShowYaml(name, result.value));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchRemove(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const name = argv[0];
|
||||||
|
if (name === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usage()}\n\nerror: remove requires <name>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdRemove(storageRoot, name);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(`removed workflow "${name}" from registry`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchRun(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const parsed = parseRunArgv(argv);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdRun(
|
||||||
|
storageRoot,
|
||||||
|
parsed.value.name,
|
||||||
|
parsed.value.prompt,
|
||||||
|
parsed.value.dryRun,
|
||||||
|
parsed.value.maxRounds,
|
||||||
|
);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
printCliLine(result.value.threadId);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchPs(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
if (argv.length > 0) {
|
||||||
|
printCliError(`${usage()}\n\nerror: ps takes no arguments`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (const line of await cmdPs(storageRoot)) {
|
||||||
|
printCliLine(line);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchKill(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const threadId = argv[0];
|
||||||
|
if (threadId === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usage()}\n\nerror: kill requires <thread-id>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdKill(storageRoot, threadId);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(`kill sent for thread ${threadId}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchThreads(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const result = await cmdThreads(storageRoot, argv);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (const line of result.value) {
|
||||||
|
printCliLine(line);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const id = argv[0];
|
||||||
|
if (id === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usage()}\n\nerror: thread requires <id>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdThreadShow(storageRoot, id);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(result.value);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const id = argv[0];
|
||||||
|
if (id === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usage()}\n\nerror: thread rm requires <id>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdThreadRemove(storageRoot, id);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(`removed thread ${id}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
if (argv.length === 0) {
|
||||||
|
printCliError(usage());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const command = argv[0];
|
||||||
|
if (command === undefined) {
|
||||||
|
printCliError(usage());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const rest = argv.slice(1);
|
||||||
|
|
||||||
|
if (command === "add") {
|
||||||
|
return dispatchAdd(storageRoot, rest);
|
||||||
|
}
|
||||||
|
if (command === "list") {
|
||||||
|
return dispatchList(storageRoot, rest);
|
||||||
|
}
|
||||||
|
if (command === "show") {
|
||||||
|
return dispatchShow(storageRoot, rest);
|
||||||
|
}
|
||||||
|
if (command === "remove") {
|
||||||
|
return dispatchRemove(storageRoot, rest);
|
||||||
|
}
|
||||||
|
if (command === "run") {
|
||||||
|
return dispatchRun(storageRoot, rest);
|
||||||
|
}
|
||||||
|
if (command === "ps") {
|
||||||
|
return dispatchPs(storageRoot, rest);
|
||||||
|
}
|
||||||
|
if (command === "kill") {
|
||||||
|
return dispatchKill(storageRoot, rest);
|
||||||
|
}
|
||||||
|
if (command === "threads") {
|
||||||
|
return dispatchThreads(storageRoot, rest);
|
||||||
|
}
|
||||||
|
if (command === "thread") {
|
||||||
|
const sub = rest[0];
|
||||||
|
if (sub === "rm") {
|
||||||
|
return dispatchThreadRm(storageRoot, rest.slice(1));
|
||||||
|
}
|
||||||
|
return dispatchThread(storageRoot, rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
printCliError(`${usage()}\n\nerror: unknown command ${command}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function printCliLine(line: string): void {
|
||||||
|
// biome-ignore lint/suspicious/noConsole: CLI user-facing output
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printCliError(line: string): void {
|
||||||
|
// biome-ignore lint/suspicious/noConsole: CLI user-facing errors
|
||||||
|
console.error(line);
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
// @uncaged/cli-workflow - uncaged-workflow CLI
|
|
||||||
console.log('uncaged-workflow');
|
import { runCli } from "./cli-dispatch.js";
|
||||||
|
import { resolveWorkflowStorageRoot } from "./storage-env.js";
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const storageRoot = resolveWorkflowStorageRoot();
|
||||||
|
const code = await runCli(storageRoot, argv);
|
||||||
|
process.exit(code);
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { readFile, stat } from "node:fs/promises";
|
||||||
|
import { basename, resolve } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
err,
|
||||||
|
hashWorkflowBundleBytes,
|
||||||
|
ok,
|
||||||
|
type Result,
|
||||||
|
readWorkflowRegistry,
|
||||||
|
registerWorkflowVersion,
|
||||||
|
validateWorkflowBundle,
|
||||||
|
writeWorkflowRegistry,
|
||||||
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import { storeWorkflowBundleCopy } from "./bundle-store.js";
|
||||||
|
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||||
|
|
||||||
|
export async function cmdAdd(
|
||||||
|
storageRoot: string,
|
||||||
|
name: string,
|
||||||
|
filePath: string,
|
||||||
|
): Promise<Result<{ hash: string }, string>> {
|
||||||
|
const nameOk = validateCliWorkflowName(name);
|
||||||
|
if (!nameOk.ok) {
|
||||||
|
return nameOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedPath: string;
|
||||||
|
try {
|
||||||
|
resolvedPath = resolve(filePath);
|
||||||
|
await stat(resolvedPath);
|
||||||
|
} catch {
|
||||||
|
return err(`bundle file not found: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let source: string;
|
||||||
|
try {
|
||||||
|
source = await readFile(resolvedPath, "utf8");
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return err(`failed to read bundle: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateWorkflowBundle({
|
||||||
|
filePath: resolvedPath,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
if (!validated.ok) {
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = encoder.encode(source);
|
||||||
|
const hash = hashWorkflowBundleBytes(bytes);
|
||||||
|
|
||||||
|
const stored = await storeWorkflowBundleCopy(storageRoot, hash, resolvedPath, source);
|
||||||
|
if (!stored.ok) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reg = await readWorkflowRegistry(storageRoot);
|
||||||
|
if (!reg.ok) {
|
||||||
|
return err(reg.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = registerWorkflowVersion(reg.value, name, hash, Date.now());
|
||||||
|
const written = await writeWorkflowRegistry(storageRoot, next);
|
||||||
|
if (!written.ok) {
|
||||||
|
return err(written.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ hash });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAddSuccess(name: string, filePath: string, hash: string): string {
|
||||||
|
return `registered workflow "${name}" from ${basename(filePath)} as ${hash}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { err, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import { readTextFileIfExists } from "./fs-utils.js";
|
||||||
|
import {
|
||||||
|
resolveRunningHashForThread,
|
||||||
|
sendWorkerTcpCommand,
|
||||||
|
type WorkerCtl,
|
||||||
|
} from "./worker-spawn.js";
|
||||||
|
|
||||||
|
export async function cmdKill(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: string,
|
||||||
|
): Promise<Result<void, string>> {
|
||||||
|
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
||||||
|
if (!hashResult.ok) {
|
||||||
|
return hashResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
|
||||||
|
const ctlText = await readTextFileIfExists(ctlPath);
|
||||||
|
if (ctlText === null) {
|
||||||
|
return err(`worker control file missing for bundle hash ${hashResult.value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctl: WorkerCtl;
|
||||||
|
try {
|
||||||
|
ctl = JSON.parse(ctlText) as WorkerCtl;
|
||||||
|
} catch {
|
||||||
|
return err(`corrupt worker control file: ${ctlPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
||||||
|
return err(`invalid worker control file: ${ctlPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await sendWorkerTcpCommand(ctl.port, { type: "kill", threadId });
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
err,
|
||||||
|
listRegisteredWorkflowNames,
|
||||||
|
ok,
|
||||||
|
type Result,
|
||||||
|
readWorkflowRegistry,
|
||||||
|
type WorkflowRegistryFile,
|
||||||
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
|
export async function cmdList(storageRoot: string): Promise<Result<WorkflowRegistryFile, string>> {
|
||||||
|
const reg = await readWorkflowRegistry(storageRoot);
|
||||||
|
if (!reg.ok) {
|
||||||
|
return err(reg.error.message);
|
||||||
|
}
|
||||||
|
return ok(reg.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatListLines(registry: WorkflowRegistryFile): string[] {
|
||||||
|
const names = listRegisteredWorkflowNames(registry);
|
||||||
|
if (names.length === 0) {
|
||||||
|
return ["(no workflows registered)"];
|
||||||
|
}
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const entry = registry.workflows[name];
|
||||||
|
if (entry === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(`${name}\t${entry.hash}\t${entry.timestamp}`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { listRunningThreads } from "./thread-scan.js";
|
||||||
|
|
||||||
|
export async function cmdPs(storageRoot: string): Promise<string[]> {
|
||||||
|
const rows = await listRunningThreads(storageRoot);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return ["(no running threads)"];
|
||||||
|
}
|
||||||
|
return rows.map((r) => `${r.threadId}\t${r.hash}\t${r.workflowName ?? "(unknown)"}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
err,
|
||||||
|
ok,
|
||||||
|
type Result,
|
||||||
|
readWorkflowRegistry,
|
||||||
|
unregisterWorkflow,
|
||||||
|
writeWorkflowRegistry,
|
||||||
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||||
|
|
||||||
|
export async function cmdRemove(storageRoot: string, name: string): Promise<Result<void, string>> {
|
||||||
|
const nameOk = validateCliWorkflowName(name);
|
||||||
|
if (!nameOk.ok) {
|
||||||
|
return nameOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reg = await readWorkflowRegistry(storageRoot);
|
||||||
|
if (!reg.ok) {
|
||||||
|
return err(reg.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = unregisterWorkflow(reg.value, name);
|
||||||
|
if (!next.ok) {
|
||||||
|
return err(next.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const written = await writeWorkflowRegistry(storageRoot, next.value);
|
||||||
|
if (!written.ok) {
|
||||||
|
return err(written.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
err,
|
||||||
|
generateUlid,
|
||||||
|
getRegisteredWorkflow,
|
||||||
|
ok,
|
||||||
|
type Result,
|
||||||
|
readWorkflowRegistry,
|
||||||
|
} from "@uncaged/workflow";
|
||||||
|
import { ensureWorkerForHash, sendWorkerTcpCommand } from "./worker-spawn.js";
|
||||||
|
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||||
|
|
||||||
|
export async function cmdRun(
|
||||||
|
storageRoot: string,
|
||||||
|
name: string,
|
||||||
|
prompt: string,
|
||||||
|
isDryRun: boolean,
|
||||||
|
maxRounds: number,
|
||||||
|
): Promise<Result<{ threadId: string }, string>> {
|
||||||
|
const nameOk = validateCliWorkflowName(name);
|
||||||
|
if (!nameOk.ok) {
|
||||||
|
return nameOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reg = await readWorkflowRegistry(storageRoot);
|
||||||
|
if (!reg.ok) {
|
||||||
|
return err(reg.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = getRegisteredWorkflow(reg.value, name);
|
||||||
|
if (entry === null) {
|
||||||
|
return err(`workflow not registered: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
||||||
|
const worker = await ensureWorkerForHash(storageRoot, entry.hash, bundlePath);
|
||||||
|
if (!worker.ok) {
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = generateUlid(Date.now());
|
||||||
|
const sent = await sendWorkerTcpCommand(worker.value.port, {
|
||||||
|
type: "run",
|
||||||
|
threadId,
|
||||||
|
prompt,
|
||||||
|
options: { isDryRun, maxRounds },
|
||||||
|
});
|
||||||
|
if (!sent.ok) {
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ threadId });
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
err,
|
||||||
|
getRegisteredWorkflow,
|
||||||
|
ok,
|
||||||
|
type Result,
|
||||||
|
readWorkflowRegistry,
|
||||||
|
type WorkflowRegistryEntry,
|
||||||
|
} from "@uncaged/workflow";
|
||||||
|
import { stringify } from "yaml";
|
||||||
|
|
||||||
|
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||||
|
|
||||||
|
export async function cmdShow(
|
||||||
|
storageRoot: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<Result<WorkflowRegistryEntry, string>> {
|
||||||
|
const nameOk = validateCliWorkflowName(name);
|
||||||
|
if (!nameOk.ok) {
|
||||||
|
return nameOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reg = await readWorkflowRegistry(storageRoot);
|
||||||
|
if (!reg.ok) {
|
||||||
|
return err(reg.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = getRegisteredWorkflow(reg.value, name);
|
||||||
|
if (entry === null) {
|
||||||
|
return err(`workflow not found: ${name}`);
|
||||||
|
}
|
||||||
|
return ok(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatShowYaml(name: string, entry: WorkflowRegistryEntry): string {
|
||||||
|
const payload = {
|
||||||
|
[name]: {
|
||||||
|
hash: entry.hash,
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
history: entry.history,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return stringify(payload, { indent: 2, defaultStringType: "QUOTE_DOUBLE" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { unlink } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import { readTextFileIfExists } from "./fs-utils.js";
|
||||||
|
import { resolveThreadDataPath } from "./thread-scan.js";
|
||||||
|
|
||||||
|
export async function cmdThreadShow(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: string,
|
||||||
|
): Promise<Result<string, string>> {
|
||||||
|
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||||
|
if (dataPath === null) {
|
||||||
|
return err(`thread not found: ${threadId}`);
|
||||||
|
}
|
||||||
|
const text = await readTextFileIfExists(dataPath);
|
||||||
|
if (text === null) {
|
||||||
|
return err(`thread data missing: ${threadId}`);
|
||||||
|
}
|
||||||
|
return ok(text.endsWith("\n") ? text.slice(0, -1) : text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadRemove(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: string,
|
||||||
|
): Promise<Result<void, string>> {
|
||||||
|
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||||
|
if (dataPath === null) {
|
||||||
|
return err(`thread not found: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = dirname(dataPath);
|
||||||
|
const infoPath = join(dir, `${threadId}.info.jsonl`);
|
||||||
|
const runningPath = join(dir, `${threadId}.running`);
|
||||||
|
|
||||||
|
await unlink(dataPath);
|
||||||
|
await unlink(infoPath).catch(() => {});
|
||||||
|
await unlink(runningPath).catch(() => {});
|
||||||
|
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import { listHistoricalThreads } from "./thread-scan.js";
|
||||||
|
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||||
|
|
||||||
|
export async function cmdThreads(
|
||||||
|
storageRoot: string,
|
||||||
|
argv: string[],
|
||||||
|
): Promise<Result<string[], string>> {
|
||||||
|
const nameFilter = argv[0];
|
||||||
|
if (argv.length > 1) {
|
||||||
|
return err("threads expects at most one workflow name argument");
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflowNameFilter: string | null = null;
|
||||||
|
if (nameFilter !== undefined) {
|
||||||
|
const nameOk = validateCliWorkflowName(nameFilter);
|
||||||
|
if (!nameOk.ok) {
|
||||||
|
return nameOk;
|
||||||
|
}
|
||||||
|
workflowNameFilter = nameFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await listHistoricalThreads(storageRoot, workflowNameFilter);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return ok(["(no threads found)"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = rows.map((r) => `${r.threadId}\t${r.hash}\t${r.workflowName ?? "(unknown)"}`);
|
||||||
|
return ok(lines);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { readFile, stat } from "node:fs/promises";
|
||||||
|
|
||||||
|
export async function readTextFileIfExists(path: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await readFile(path, "utf8");
|
||||||
|
} catch (e) {
|
||||||
|
const errObj = e as NodeJS.ErrnoException;
|
||||||
|
if (errObj.code === "ENOENT") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pathExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await stat(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
export type ParsedRunArgv = {
|
||||||
|
name: string;
|
||||||
|
prompt: string;
|
||||||
|
dryRun: boolean;
|
||||||
|
maxRounds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlagOk =
|
||||||
|
| { kind: "dry-run" }
|
||||||
|
| { kind: "prompt"; value: string }
|
||||||
|
| { kind: "max-rounds"; value: number };
|
||||||
|
|
||||||
|
function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | null {
|
||||||
|
const flag = argv[index];
|
||||||
|
if (flag === "--dry-run") {
|
||||||
|
return ok({ kind: "dry-run" });
|
||||||
|
}
|
||||||
|
if (flag === "--prompt") {
|
||||||
|
const value = argv[index + 1];
|
||||||
|
if (value === undefined) {
|
||||||
|
return err("missing value for --prompt");
|
||||||
|
}
|
||||||
|
return ok({ kind: "prompt", value });
|
||||||
|
}
|
||||||
|
if (flag === "--max-rounds") {
|
||||||
|
const value = argv[index + 1];
|
||||||
|
if (value === undefined) {
|
||||||
|
return err("missing value for --max-rounds");
|
||||||
|
}
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||||
|
return err("--max-rounds must be a non-negative integer");
|
||||||
|
}
|
||||||
|
return ok({ kind: "max-rounds", value: n });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||||
|
let name: string | undefined;
|
||||||
|
let prompt = "";
|
||||||
|
let dryRun = false;
|
||||||
|
let maxRounds = 5;
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
const first = argv[0];
|
||||||
|
if (first !== undefined && !first.startsWith("--")) {
|
||||||
|
name = first;
|
||||||
|
i = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < argv.length) {
|
||||||
|
const parsed = parseFlagAt(argv, i);
|
||||||
|
if (parsed === null) {
|
||||||
|
const unknown = argv[i];
|
||||||
|
return err(`unknown run flag: ${unknown}`);
|
||||||
|
}
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flag = parsed.value;
|
||||||
|
if (flag.kind === "dry-run") {
|
||||||
|
dryRun = true;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (flag.kind === "prompt") {
|
||||||
|
prompt = flag.value;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
maxRounds = flag.value;
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === undefined || name === "") {
|
||||||
|
return err("run requires <name>");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ name, prompt, dryRun, maxRounds });
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
/** Resolve storage root, honoring `UNCAGED_WORKFLOW_STORAGE_ROOT` for tests/tools. */
|
||||||
|
export function resolveWorkflowStorageRoot(): string {
|
||||||
|
const override = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
if (override !== undefined && override !== "") {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
return getDefaultWorkflowStorageRoot();
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { readdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||||
|
|
||||||
|
export type RunningThreadRow = {
|
||||||
|
threadId: string;
|
||||||
|
hash: string;
|
||||||
|
workflowName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HistoricalThreadRow = {
|
||||||
|
threadId: string;
|
||||||
|
hash: string;
|
||||||
|
workflowName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readWorkflowNameFromDataJsonl(dataPath: string): Promise<string | null> {
|
||||||
|
const text = await readTextFileIfExists(dataPath);
|
||||||
|
if (text === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const firstLine = text.split("\n")[0];
|
||||||
|
if (firstLine === undefined || firstLine.trim() === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(firstLine) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (parsed === null || typeof parsed !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name = (parsed as Record<string, unknown>).name;
|
||||||
|
return typeof name === "string" ? name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Threads currently executing — identified via `<threadId>.running` markers. */
|
||||||
|
export async function listRunningThreads(storageRoot: string): Promise<RunningThreadRow[]> {
|
||||||
|
const logsRoot = join(storageRoot, "logs");
|
||||||
|
if (!(await pathExists(logsRoot))) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashes = await readdir(logsRoot);
|
||||||
|
const out: RunningThreadRow[] = [];
|
||||||
|
|
||||||
|
for (const hash of hashes) {
|
||||||
|
const dir = join(logsRoot, hash);
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fileName of entries) {
|
||||||
|
if (!fileName.endsWith(".running")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const threadId = fileName.slice(0, -".running".length);
|
||||||
|
const dataPath = join(dir, `${threadId}.data.jsonl`);
|
||||||
|
const workflowName = await readWorkflowNameFromDataJsonl(dataPath);
|
||||||
|
out.push({ threadId, hash, workflowName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort((a, b) => {
|
||||||
|
const ha = `${a.hash}/${a.threadId}`;
|
||||||
|
const hb = `${b.hash}/${b.threadId}`;
|
||||||
|
return ha.localeCompare(hb);
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Historical threads discovered via `*.data.jsonl`.
|
||||||
|
* When `workflowNameFilter` is non-null, only threads whose start record `name` matches are returned.
|
||||||
|
*/
|
||||||
|
export async function listHistoricalThreads(
|
||||||
|
storageRoot: string,
|
||||||
|
workflowNameFilter: string | null,
|
||||||
|
): Promise<HistoricalThreadRow[]> {
|
||||||
|
const logsRoot = join(storageRoot, "logs");
|
||||||
|
if (!(await pathExists(logsRoot))) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashes = await readdir(logsRoot);
|
||||||
|
const out: HistoricalThreadRow[] = [];
|
||||||
|
|
||||||
|
for (const hash of hashes) {
|
||||||
|
const dir = join(logsRoot, hash);
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fileName of entries) {
|
||||||
|
if (!fileName.endsWith(".data.jsonl")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const threadId = fileName.slice(0, -".data.jsonl".length);
|
||||||
|
const dataPath = join(dir, fileName);
|
||||||
|
const workflowName = await readWorkflowNameFromDataJsonl(dataPath);
|
||||||
|
if (workflowNameFilter !== null && workflowName !== workflowNameFilter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push({ threadId, hash, workflowName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort((a, b) => {
|
||||||
|
const ha = `${a.hash}/${a.threadId}`;
|
||||||
|
const hb = `${b.hash}/${b.threadId}`;
|
||||||
|
return ha.localeCompare(hb);
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveThreadDataPath(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const logsRoot = join(storageRoot, "logs");
|
||||||
|
if (!(await pathExists(logsRoot))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const hashes = await readdir(logsRoot);
|
||||||
|
for (const hash of hashes) {
|
||||||
|
const candidate = join(logsRoot, hash, `${threadId}.data.jsonl`);
|
||||||
|
if (await pathExists(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||||
|
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
||||||
|
import { createConnection } from "node:net";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { err, getWorkerHostScriptPath, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||||
|
|
||||||
|
export type WorkerCtl = {
|
||||||
|
pid: number;
|
||||||
|
port: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isProcessAlive(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForReadyLine(
|
||||||
|
childStdout: NodeJS.ReadableStream,
|
||||||
|
child: ChildProcessWithoutNullStreams,
|
||||||
|
): Promise<Result<number, string>> {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
let buf = "";
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
function finish(result: Result<number, string>): void {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onData(chunk: Buffer | string): void {
|
||||||
|
buf += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
||||||
|
const nl = buf.indexOf("\n");
|
||||||
|
if (nl < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const line = buf.slice(0, nl).trim();
|
||||||
|
const prefix = "READY ";
|
||||||
|
if (!line.startsWith(prefix)) {
|
||||||
|
finish(err(`worker did not emit READY line (got: ${line})`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const portText = line.slice(prefix.length);
|
||||||
|
const port = Number(portText);
|
||||||
|
if (!Number.isFinite(port) || port <= 0) {
|
||||||
|
finish(err(`worker READY line had invalid port: ${portText}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish(ok(port));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnd(): void {
|
||||||
|
finish(err("worker stdout ended before READY line"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExit(code: number | null): void {
|
||||||
|
finish(err(`worker exited before READY line (code ${code})`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(): void {
|
||||||
|
childStdout.off("data", onData);
|
||||||
|
childStdout.off("end", onEnd);
|
||||||
|
child.off("exit", onExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
childStdout.on("data", onData);
|
||||||
|
childStdout.on("end", onEnd);
|
||||||
|
child.on("exit", onExit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spawnWorkerProcess(
|
||||||
|
bundlePath: string,
|
||||||
|
storageRoot: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<Result<{ pid: number; port: number }, string>> {
|
||||||
|
const scriptPath = getWorkerHostScriptPath();
|
||||||
|
const child = spawn(process.execPath, [scriptPath, bundlePath, storageRoot, hash], {
|
||||||
|
stdio: ["ignore", "pipe", "inherit"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (child.stdout === null || child.pid === undefined) {
|
||||||
|
return err("failed to spawn worker process");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pid = child.pid;
|
||||||
|
const ready = await waitForReadyLine(child.stdout, child);
|
||||||
|
if (!ready.ok) {
|
||||||
|
child.kill();
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.unref();
|
||||||
|
child.stdout.destroy();
|
||||||
|
|
||||||
|
return ok({ pid, port: ready.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureWorkerForHash(
|
||||||
|
storageRoot: string,
|
||||||
|
hash: string,
|
||||||
|
bundlePath: string,
|
||||||
|
): Promise<Result<{ port: number }, string>> {
|
||||||
|
const ctlPath = join(storageRoot, "workers", `${hash}.json`);
|
||||||
|
const existingText = await readTextFileIfExists(ctlPath);
|
||||||
|
if (existingText !== null) {
|
||||||
|
try {
|
||||||
|
const ctl = JSON.parse(existingText) as WorkerCtl;
|
||||||
|
if (
|
||||||
|
typeof ctl.pid === "number" &&
|
||||||
|
typeof ctl.port === "number" &&
|
||||||
|
ctl.pid > 0 &&
|
||||||
|
ctl.port > 0 &&
|
||||||
|
isProcessAlive(ctl.pid)
|
||||||
|
) {
|
||||||
|
return ok({ port: ctl.port });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Corrupt control file — ignore and respawn.
|
||||||
|
}
|
||||||
|
await unlink(ctlPath).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawned = await spawnWorkerProcess(bundlePath, storageRoot, hash);
|
||||||
|
if (!spawned.ok) {
|
||||||
|
return spawned;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(join(storageRoot, "workers"), { recursive: true });
|
||||||
|
const ctl: WorkerCtl = { pid: spawned.value.pid, port: spawned.value.port };
|
||||||
|
await writeFile(ctlPath, `${JSON.stringify(ctl)}\n`, "utf8");
|
||||||
|
|
||||||
|
return ok({ port: spawned.value.port });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendWorkerTcpCommand(
|
||||||
|
port: number,
|
||||||
|
payload: unknown,
|
||||||
|
): Promise<Result<void, string>> {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const socket = createConnection({ host: "127.0.0.1", port }, () => {
|
||||||
|
socket.write(`${JSON.stringify(payload)}\n`);
|
||||||
|
socket.end();
|
||||||
|
});
|
||||||
|
socket.on("error", (e) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
resolve(err(`failed to send worker command: ${message}`));
|
||||||
|
});
|
||||||
|
socket.on("close", () => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
resolve(ok(undefined));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveRunningHashForThread(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: string,
|
||||||
|
): Promise<Result<string, string>> {
|
||||||
|
const logsRoot = join(storageRoot, "logs");
|
||||||
|
if (!(await pathExists(logsRoot))) {
|
||||||
|
return err(`thread not running (no logs dir): ${threadId}`);
|
||||||
|
}
|
||||||
|
const hashes = await readdir(logsRoot);
|
||||||
|
for (const hash of hashes) {
|
||||||
|
const runningPath = join(logsRoot, hash, `${threadId}.running`);
|
||||||
|
if (await pathExists(runningPath)) {
|
||||||
|
return ok(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err(`thread not running: ${threadId}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
const WORKFLOW_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
||||||
|
|
||||||
|
export function validateCliWorkflowName(name: string): Result<void, string> {
|
||||||
|
if (!WORKFLOW_NAME_RE.test(name)) {
|
||||||
|
return err(
|
||||||
|
'invalid workflow name: use verb-first kebab-case (lowercase letters, digits, hyphens), e.g. "solve-issue"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
decodeCrockfordBase32Bits,
|
||||||
|
decodeCrockfordToUint64,
|
||||||
|
encodeCrockfordBase32Bits,
|
||||||
|
encodeUint64AsCrockford,
|
||||||
|
} from "../src/base32.js";
|
||||||
|
|
||||||
|
describe("Crockford Base32", () => {
|
||||||
|
test("roundtrip 64-bit hash encoding", () => {
|
||||||
|
const value = 0xef46_db37_51d8_e999n;
|
||||||
|
const encoded = encodeUint64AsCrockford(value);
|
||||||
|
expect(encoded.length).toBe(13);
|
||||||
|
const decoded = decodeCrockfordToUint64(encoded);
|
||||||
|
expect(decoded.ok).toBe(true);
|
||||||
|
if (decoded.ok) {
|
||||||
|
expect(decoded.value).toBe(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("roundtrip arbitrary bit widths used by ULID (128-bit)", () => {
|
||||||
|
const rand = 0x1234567890abcdef12n & ((1n << 80n) - 1n);
|
||||||
|
const payload = (12345n << 80n) | rand;
|
||||||
|
const encoded = encodeCrockfordBase32Bits(payload, 128);
|
||||||
|
expect(encoded.length).toBe(26);
|
||||||
|
const decoded = decodeCrockfordBase32Bits(encoded, 128);
|
||||||
|
expect(decoded.ok).toBe(true);
|
||||||
|
if (decoded.ok) {
|
||||||
|
expect(decoded.value).toBe(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reject invalid characters", () => {
|
||||||
|
const decoded = decodeCrockfordToUint64("!!!!!!!!!!!!!");
|
||||||
|
expect(decoded.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { validateWorkflowBundle } from "../src/bundle-validator.js";
|
||||||
|
|
||||||
|
describe("validateWorkflowBundle", () => {
|
||||||
|
test("accepts minimal valid builtin-only bundle", () => {
|
||||||
|
const source = `import fs from "node:fs";
|
||||||
|
|
||||||
|
export default async function run() {
|
||||||
|
fs.existsSync(".");
|
||||||
|
return { returnCode: 0, summary: "ok" };
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects wrong filename suffix", () => {
|
||||||
|
const r = validateWorkflowBundle({
|
||||||
|
filePath: "/tmp/w.js",
|
||||||
|
source: "export default async function run() { return { returnCode: 0, summary: '' }; }\n",
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects missing default export", () => {
|
||||||
|
const r = validateWorkflowBundle({
|
||||||
|
filePath: "/tmp/w.esm.js",
|
||||||
|
source: "export const x = 1;\n",
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.error).toContain("default export");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-builtin imports", () => {
|
||||||
|
const r = validateWorkflowBundle({
|
||||||
|
filePath: "/tmp/w.esm.js",
|
||||||
|
source:
|
||||||
|
'import x from "some-package";\nexport default async function run() { return { returnCode: 0, summary: "" }; }\n',
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects dynamic import", () => {
|
||||||
|
const r = validateWorkflowBundle({
|
||||||
|
filePath: "/tmp/w.esm.js",
|
||||||
|
source:
|
||||||
|
'export default async function run() { await import("fs"); return { returnCode: 0, summary: "" }; }\n',
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.error).toContain("dynamic import");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects require()", () => {
|
||||||
|
const r = validateWorkflowBundle({
|
||||||
|
filePath: "/tmp/w.esm.js",
|
||||||
|
source:
|
||||||
|
'export default async function run() { require("fs"); return { returnCode: 0, summary: "" }; }\n',
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { executeThread } from "../src/engine.js";
|
||||||
|
import { createLogger } from "../src/logger.js";
|
||||||
|
import { END, type WorkflowDefinition } from "../src/types.js";
|
||||||
|
|
||||||
|
type DemoMeta = {
|
||||||
|
planner: Record<string, unknown>;
|
||||||
|
coder: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const demoWorkflow: WorkflowDefinition<DemoMeta> = {
|
||||||
|
name: "demo-flow",
|
||||||
|
roles: {
|
||||||
|
planner: async () => ({
|
||||||
|
content: "plan-body",
|
||||||
|
meta: { plan: "do-it", files: ["a.ts"] },
|
||||||
|
}),
|
||||||
|
coder: async () => ({
|
||||||
|
content: "code-body",
|
||||||
|
meta: { diff: "+ok" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
moderator: (ctx) => {
|
||||||
|
if (ctx.steps.length === 0) {
|
||||||
|
return "planner";
|
||||||
|
}
|
||||||
|
if (ctx.steps.length === 1) {
|
||||||
|
return "coder";
|
||||||
|
}
|
||||||
|
return END;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("executeThread", () => {
|
||||||
|
test("writes RFC-001 `.data.jsonl` start + role records and `.info.jsonl` logs", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wf-engine-"));
|
||||||
|
try {
|
||||||
|
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||||
|
const hash = "C9NMV6V2TQT81";
|
||||||
|
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||||
|
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||||
|
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||||
|
|
||||||
|
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||||
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
const result = await executeThread(
|
||||||
|
demoWorkflow,
|
||||||
|
"Fix the login redirect bug in #3",
|
||||||
|
{ isDryRun: false, maxRounds: 5, signal: ac.signal },
|
||||||
|
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.returnCode).toBe(0);
|
||||||
|
|
||||||
|
const dataText = await readFile(dataPath, "utf8");
|
||||||
|
const lines = dataText
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l !== "");
|
||||||
|
expect(lines.length).toBe(3);
|
||||||
|
|
||||||
|
const start = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
|
||||||
|
expect(start.name).toBe("demo-flow");
|
||||||
|
expect(start.hash).toBe(hash);
|
||||||
|
expect(start.threadId).toBe(threadId);
|
||||||
|
expect(typeof start.timestamp).toBe("number");
|
||||||
|
|
||||||
|
const params = start.parameters as Record<string, unknown>;
|
||||||
|
expect(params.prompt).toBe("Fix the login redirect bug in #3");
|
||||||
|
const opts = params.options as Record<string, unknown>;
|
||||||
|
expect(opts.isDryRun).toBe(false);
|
||||||
|
expect(opts.maxRounds).toBe(5);
|
||||||
|
|
||||||
|
const role1 = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||||
|
expect(role1.role).toBe("planner");
|
||||||
|
expect(role1.content).toBe("plan-body");
|
||||||
|
expect(role1.meta).toEqual({ plan: "do-it", files: ["a.ts"] });
|
||||||
|
expect(typeof role1.timestamp).toBe("number");
|
||||||
|
|
||||||
|
const role2 = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||||
|
expect(role2.role).toBe("coder");
|
||||||
|
|
||||||
|
const infoText = await readFile(infoPath, "utf8");
|
||||||
|
const infoLines = infoText
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l !== "");
|
||||||
|
expect(infoLines.length).toBeGreaterThan(0);
|
||||||
|
const log0 = JSON.parse(infoLines[0] ?? "{}") as Record<string, unknown>;
|
||||||
|
expect(typeof log0.tag).toBe("string");
|
||||||
|
expect(String(log0.tag).length).toBe(8);
|
||||||
|
expect(typeof log0.content).toBe("string");
|
||||||
|
expect(typeof log0.timestamp).toBe("number");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("respects maxRounds=0 (start record only)", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wf-engine-max0-"));
|
||||||
|
try {
|
||||||
|
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||||
|
const hash = "C9NMV6V2TQT81";
|
||||||
|
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||||
|
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||||
|
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||||
|
|
||||||
|
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||||
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
const result = await executeThread(
|
||||||
|
demoWorkflow,
|
||||||
|
"hello",
|
||||||
|
{ isDryRun: false, maxRounds: 0, signal: ac.signal },
|
||||||
|
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.returnCode).toBe(0);
|
||||||
|
|
||||||
|
const dataText = await readFile(dataPath, "utf8");
|
||||||
|
const lines = dataText
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l !== "");
|
||||||
|
expect(lines.length).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { decodeCrockfordToUint64 } from "../src/base32.js";
|
||||||
|
import { hashWorkflowBundleBytes } from "../src/hash.js";
|
||||||
|
|
||||||
|
describe("hashWorkflowBundleBytes", () => {
|
||||||
|
test("matches XXH64 reference for empty input", () => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const digest = hashWorkflowBundleBytes(encoder.encode(""));
|
||||||
|
const decoded = decodeCrockfordToUint64(digest);
|
||||||
|
expect(decoded.ok).toBe(true);
|
||||||
|
if (decoded.ok) {
|
||||||
|
expect(decoded.value).toBe(0xef46_db37_51d8_e999n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stable for identical content", () => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(
|
||||||
|
"export default async function run() { return { returnCode: 0, summary: '' }; }\n",
|
||||||
|
);
|
||||||
|
expect(hashWorkflowBundleBytes(data)).toBe(hashWorkflowBundleBytes(data));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { createLogger } from "../src/logger.js";
|
||||||
|
|
||||||
|
describe("createLogger", () => {
|
||||||
|
test("writes JSONL records to a file sink", async () => {
|
||||||
|
const dir = join(tmpdir(), `wf-log-${process.pid}-${Date.now()}`);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
const logPath = join(dir, "test.log");
|
||||||
|
const log = createLogger({ sink: { kind: "file", path: logPath } });
|
||||||
|
log("01ABCDEF", "hello");
|
||||||
|
const text = await readFile(logPath, "utf8");
|
||||||
|
const line = text.trim().split("\n")[0];
|
||||||
|
expect(line).toBeDefined();
|
||||||
|
const obj = JSON.parse(line ?? "{}") as { tag: string; content: string; timestamp: number };
|
||||||
|
expect(obj.tag).toBe("01ABCDEF");
|
||||||
|
expect(obj.content).toBe("hello");
|
||||||
|
expect(typeof obj.timestamp).toBe("number");
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid tags", () => {
|
||||||
|
const log = createLogger({ sink: { kind: "stderr" } });
|
||||||
|
expect(() => log("BAD", "x")).toThrow();
|
||||||
|
expect(() => log("01abcdefg", "x")).toThrow();
|
||||||
|
expect(() => log("01ABCDEO", "x")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
readWorkflowRegistry,
|
||||||
|
registerWorkflowVersion,
|
||||||
|
unregisterWorkflow,
|
||||||
|
writeWorkflowRegistry,
|
||||||
|
} from "../src/registry.js";
|
||||||
|
|
||||||
|
describe("workflow registry", () => {
|
||||||
|
test("roundtrips through workflow.yaml", async () => {
|
||||||
|
const dir = join(tmpdir(), `wf-reg-${process.pid}-${Date.now()}`);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
const empty = await readWorkflowRegistry(dir);
|
||||||
|
expect(empty.ok).toBe(true);
|
||||||
|
if (!empty.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r1 = registerWorkflowVersion(empty.value, "solve-issue", "AAAAAAAAAAAAA", 100);
|
||||||
|
const w1 = await writeWorkflowRegistry(dir, r1);
|
||||||
|
expect(w1.ok).toBe(true);
|
||||||
|
|
||||||
|
const back = await readWorkflowRegistry(dir);
|
||||||
|
expect(back.ok).toBe(true);
|
||||||
|
if (!back.ok) {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(back.value.workflows["solve-issue"]?.hash).toBe("AAAAAAAAAAAAA");
|
||||||
|
|
||||||
|
const r2 = registerWorkflowVersion(back.value, "solve-issue", "BBBBBBBBBBBBB", 200);
|
||||||
|
expect(r2.workflows["solve-issue"]?.history[0]?.hash).toBe("AAAAAAAAAAAAA");
|
||||||
|
|
||||||
|
const removed = unregisterWorkflow(r2, "solve-issue");
|
||||||
|
expect(removed.ok).toBe(true);
|
||||||
|
if (!removed.ok) {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const w2 = await writeWorkflowRegistry(dir, removed.value);
|
||||||
|
expect(w2.ok).toBe(true);
|
||||||
|
|
||||||
|
const finalRead = await readWorkflowRegistry(dir);
|
||||||
|
expect(finalRead.ok).toBe(true);
|
||||||
|
if (finalRead.ok) {
|
||||||
|
expect(finalRead.value.workflows["solve-issue"]).toBeUndefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats missing registry as empty", async () => {
|
||||||
|
const dir = join(tmpdir(), `wf-reg2-${process.pid}-${Date.now()}`);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
const empty = await readWorkflowRegistry(dir);
|
||||||
|
expect(empty.ok).toBe(true);
|
||||||
|
if (empty.ok) {
|
||||||
|
expect(Object.keys(empty.value.workflows).length).toBe(0);
|
||||||
|
}
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parse errors on invalid shape", async () => {
|
||||||
|
const dir = join(tmpdir(), `wf-reg3-${process.pid}-${Date.now()}`);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(join(dir, "workflow.yaml"), 'workflows: "broken"\n', "utf8");
|
||||||
|
const bad = await readWorkflowRegistry(dir);
|
||||||
|
expect(bad.ok).toBe(false);
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { err, ok } from "../src/result.js";
|
||||||
|
|
||||||
|
describe("result helpers", () => {
|
||||||
|
test("ok wraps value", () => {
|
||||||
|
const r = ok(42);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
expect(r.value).toBe(42);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("err wraps error", () => {
|
||||||
|
const r = err("nope");
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.error).toBe("nope");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
describe("RFC-001 thread JSONL shapes", () => {
|
||||||
|
test("documents the `.data.jsonl` start record + role record keys", () => {
|
||||||
|
const startRecord = {
|
||||||
|
name: "solve-issue",
|
||||||
|
hash: "C9NMV6V2TQT81",
|
||||||
|
threadId: "01KQXKW18CT8G75T53R8F4G7YG",
|
||||||
|
parameters: {
|
||||||
|
prompt: "Fix the login redirect bug in #3",
|
||||||
|
options: {
|
||||||
|
isDryRun: false,
|
||||||
|
maxRounds: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: 1714963200000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleRecord = {
|
||||||
|
role: "planner",
|
||||||
|
content: "Plan: modify auth middleware...",
|
||||||
|
meta: { plan: "...", files: ["src/auth.ts"] },
|
||||||
|
timestamp: 1714963201000,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Object.keys(startRecord).sort()).toEqual(
|
||||||
|
["hash", "name", "parameters", "threadId", "timestamp"].sort(),
|
||||||
|
);
|
||||||
|
expect(Object.keys(roleRecord).sort()).toEqual(["content", "meta", "role", "timestamp"].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("documents the `.info.jsonl` debug record keys", () => {
|
||||||
|
const infoRecord = {
|
||||||
|
tag: "4KNMR2PX",
|
||||||
|
content: "Loading workflow bundle...",
|
||||||
|
timestamp: 1714963200500,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Object.keys(infoRecord).sort()).toEqual(["content", "tag", "timestamp"].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { decodeCrockfordBase32Bits } from "../src/base32.js";
|
||||||
|
import { generateUlid } from "../src/ulid.js";
|
||||||
|
|
||||||
|
describe("generateUlid", () => {
|
||||||
|
test("length and decodable Crockford payload", () => {
|
||||||
|
const id = generateUlid(1_714_963_200_000);
|
||||||
|
expect(id.length).toBe(26);
|
||||||
|
const decoded = decodeCrockfordBase32Bits(id, 128);
|
||||||
|
expect(decoded.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("embeds 48-bit timestamp at the MSB of the 128-bit payload", () => {
|
||||||
|
const ts = 9_999_888_777_666;
|
||||||
|
const id = generateUlid(ts);
|
||||||
|
const decoded = decodeCrockfordBase32Bits(id, 128);
|
||||||
|
expect(decoded.ok).toBe(true);
|
||||||
|
if (decoded.ok) {
|
||||||
|
const recoveredMs = decoded.value >> 80n;
|
||||||
|
expect(Number(recoveredMs)).toBe(ts);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects out-of-range timestamps", () => {
|
||||||
|
expect(() => generateUlid(-1)).toThrow();
|
||||||
|
expect(() => generateUlid(2 ** 48)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { createConnection } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { getWorkerHostScriptPath } from "../src/worker-entry-path.js";
|
||||||
|
|
||||||
|
const bundleSource = `export default {
|
||||||
|
name: "demo-flow",
|
||||||
|
roles: {
|
||||||
|
planner: async () => ({ content: "p", meta: { plan: "x" } }),
|
||||||
|
coder: async () => ({ content: "c", meta: { diff: "y" } }),
|
||||||
|
},
|
||||||
|
moderator(ctx) {
|
||||||
|
if (ctx.steps.length === 0) return "planner";
|
||||||
|
if (ctx.steps.length === 1) return "coder";
|
||||||
|
return "__end__";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function readReadyPort(child: import("node:child_process").ChildProcess): Promise<number> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
if (child.stdout === null) {
|
||||||
|
reject(new Error("missing stdout"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let buf = "";
|
||||||
|
function cleanup(): void {
|
||||||
|
child.stdout?.off("data", onData);
|
||||||
|
child.off("exit", onExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onData(chunk: Buffer): void {
|
||||||
|
buf += chunk.toString("utf8");
|
||||||
|
const nl = buf.indexOf("\n");
|
||||||
|
if (nl < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
const line = buf.slice(0, nl).trim();
|
||||||
|
const prefix = "READY ";
|
||||||
|
if (!line.startsWith(prefix)) {
|
||||||
|
reject(new Error(`unexpected READY line: ${line}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(Number(line.slice(prefix.length)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExit(code: number | null): void {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error(`worker exited before READY (code ${code})`));
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout.on("data", onData);
|
||||||
|
child.on("exit", onExit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendJson(port: number, payload: unknown): Promise<void> {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const socket = createConnection({ host: "127.0.0.1", port }, () => {
|
||||||
|
socket.write(`${JSON.stringify(payload)}\n`);
|
||||||
|
socket.end();
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
socket.on("close", () => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("worker process", () => {
|
||||||
|
test("loads bundle, runs a thread over TCP, then exits when idle", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wf-worker-"));
|
||||||
|
try {
|
||||||
|
const hash = "C9NMV6V2TQT81";
|
||||||
|
await mkdir(join(root, "bundles"), { recursive: true });
|
||||||
|
const bundlePath = join(root, "bundles", `${hash}.esm.js`);
|
||||||
|
await writeFile(bundlePath, bundleSource, "utf8");
|
||||||
|
|
||||||
|
const scriptPath = getWorkerHostScriptPath();
|
||||||
|
const child = spawn(process.execPath, [scriptPath, bundlePath, root, hash], {
|
||||||
|
stdio: ["ignore", "pipe", "inherit"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (child.stdout === null) {
|
||||||
|
throw new Error("missing stdout");
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = await readReadyPort(child);
|
||||||
|
|
||||||
|
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||||
|
await sendJson(port, {
|
||||||
|
type: "run",
|
||||||
|
threadId,
|
||||||
|
prompt: "hello",
|
||||||
|
options: { isDryRun: false, maxRounds: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitCode: number = await new Promise((resolve) => {
|
||||||
|
child.on("exit", (code) => resolve(code ?? 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
|
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||||
|
const text = await readFile(dataPath, "utf8");
|
||||||
|
expect(
|
||||||
|
text
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l !== "").length,
|
||||||
|
).toBe(3);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 15_000);
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { err, ok, type Result } from "./result.js";
|
import { err, ok, type Result } from "./result.js";
|
||||||
|
|
||||||
/** Crockford Base32 alphabet (no I, L, O, U). */
|
/** Crockford Base32 alphabet (no I, L, O, U) — exactly 32 symbols. */
|
||||||
export const CROCKFORD_BASE32_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXZ";
|
export const CROCKFORD_BASE32_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||||
|
|
||||||
const DECODE_MAP: Record<string, number> = (() => {
|
const DECODE_MAP: Record<string, number> = (() => {
|
||||||
const map: Record<string, number> = {};
|
const map: Record<string, number> = {};
|
||||||
@@ -31,13 +31,16 @@ export function encodeCrockfordBase32Bits(value: bigint, bitLength: number): str
|
|||||||
let result = "";
|
let result = "";
|
||||||
for (let i = 0; i < charCount; i++) {
|
for (let i = 0; i < charCount; i++) {
|
||||||
const shift = totalBits - 5 * (i + 1);
|
const shift = totalBits - 5 * (i + 1);
|
||||||
const quintet = Number((shifted >> BigInt(shift)) & 0x1fn);
|
const quintet = Number((shifted >> BigInt(shift)) & 31n);
|
||||||
result += CROCKFORD_BASE32_ALPHABET[quintet];
|
result += CROCKFORD_BASE32_ALPHABET[quintet];
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeCrockfordBase32Bits(encoded: string, bitLength: number): Result<bigint, Error> {
|
export function decodeCrockfordBase32Bits(
|
||||||
|
encoded: string,
|
||||||
|
bitLength: number,
|
||||||
|
): Result<bigint, Error> {
|
||||||
if (bitLength <= 0) {
|
if (bitLength <= 0) {
|
||||||
return err(new Error("bitLength must be positive"));
|
return err(new Error("bitLength must be positive"));
|
||||||
}
|
}
|
||||||
@@ -57,7 +60,7 @@ export function decodeCrockfordBase32Bits(encoded: string, bitLength: number): R
|
|||||||
if (val === undefined) {
|
if (val === undefined) {
|
||||||
return err(new Error(`invalid Crockford Base32 character: ${ch}`));
|
return err(new Error(`invalid Crockford Base32 character: ${ch}`));
|
||||||
}
|
}
|
||||||
shifted = (shifted << 5n) | BigInt(val);
|
shifted = (shifted << 5n) | BigInt(val & 31);
|
||||||
}
|
}
|
||||||
return ok(shifted >> BigInt(padBits));
|
return ok(shifted >> BigInt(padBits));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { isBuiltin } from "node:module";
|
import { isBuiltin } from "node:module";
|
||||||
|
import type {
|
||||||
|
CallExpression,
|
||||||
|
ExportAllDeclaration,
|
||||||
|
ExportNamedDeclaration,
|
||||||
|
ImportDeclaration,
|
||||||
|
Node,
|
||||||
|
Program,
|
||||||
|
} from "acorn";
|
||||||
import * as acorn from "acorn";
|
import * as acorn from "acorn";
|
||||||
import type { Node, Program } from "acorn";
|
|
||||||
|
|
||||||
import { err, ok, type Result } from "./result.js";
|
import { err, ok, type Result } from "./result.js";
|
||||||
|
|
||||||
@@ -26,22 +32,36 @@ function isAllowedImportSpecifier(spec: string): boolean {
|
|||||||
return isBuiltin(spec);
|
return isBuiltin(spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
function walk(node: Node, visit: (n: Node) => void): void {
|
function pushNestedAstNodes(value: unknown, out: Node[]): void {
|
||||||
visit(node);
|
if (value === null || value === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
if (item !== null && typeof item === "object" && "type" in item) {
|
||||||
|
out.push(item as Node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value === "object" && "type" in value) {
|
||||||
|
out.push(value as Node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectChildNodes(node: Node): Node[] {
|
||||||
|
const children: Node[] = [];
|
||||||
for (const key of Object.keys(node)) {
|
for (const key of Object.keys(node)) {
|
||||||
const val = (node as Record<string, unknown>)[key];
|
const val = (node as Record<string, unknown>)[key];
|
||||||
if (val === null || val === undefined) {
|
pushNestedAstNodes(val, children);
|
||||||
continue;
|
}
|
||||||
}
|
return children;
|
||||||
if (Array.isArray(val)) {
|
}
|
||||||
for (const item of val) {
|
|
||||||
if (item !== null && typeof item === "object" && "type" in item) {
|
function walkAst(node: Node, visit: (n: Node) => void): void {
|
||||||
walk(item as Node, visit);
|
visit(node);
|
||||||
}
|
for (const child of collectChildNodes(node)) {
|
||||||
}
|
walkAst(child, visit);
|
||||||
} else if (typeof val === "object" && "type" in val) {
|
|
||||||
walk(val as Node, visit);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +74,85 @@ function programHasDefaultExport(body: readonly Node[]): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringLiteralModuleSpecifier(src: Node): string | null {
|
||||||
|
if (src.type !== "Literal" || typeof src.value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return src.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateImportDeclaration(node: ImportDeclaration): string | null {
|
||||||
|
const spec = stringLiteralModuleSpecifier(node.source);
|
||||||
|
if (spec === null) {
|
||||||
|
return "only static string import specifiers are allowed";
|
||||||
|
}
|
||||||
|
if (!isAllowedImportSpecifier(spec)) {
|
||||||
|
return `disallowed import specifier "${spec}" (only Node built-ins are allowed)`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateExportSource(
|
||||||
|
src: Node,
|
||||||
|
staticMessage: string,
|
||||||
|
disallowedPrefix: string,
|
||||||
|
): string | null {
|
||||||
|
const spec = stringLiteralModuleSpecifier(src);
|
||||||
|
if (spec === null) {
|
||||||
|
return staticMessage;
|
||||||
|
}
|
||||||
|
if (!isAllowedImportSpecifier(spec)) {
|
||||||
|
return `${disallowedPrefix} "${spec}" (only Node built-ins are allowed)`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateExportNamedDeclaration(node: ExportNamedDeclaration): string | null {
|
||||||
|
if (node.source === null || node.source === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return validateExportSource(
|
||||||
|
node.source,
|
||||||
|
"only static string re-export specifiers are allowed",
|
||||||
|
"disallowed re-export specifier",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateExportAllDeclaration(node: ExportAllDeclaration): string | null {
|
||||||
|
return validateExportSource(
|
||||||
|
node.source,
|
||||||
|
"only static string export-all specifiers are allowed",
|
||||||
|
"disallowed export-all specifier",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRequireCall(node: CallExpression): string | null {
|
||||||
|
const callee = node.callee;
|
||||||
|
if (callee.type === "Identifier" && callee.name === "require") {
|
||||||
|
return "require() is not allowed in workflow bundles";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundleConstraintViolationForNode(node: Node): string | null {
|
||||||
|
if (node.type === "ImportExpression") {
|
||||||
|
return "dynamic import() is not allowed in workflow bundles";
|
||||||
|
}
|
||||||
|
if (node.type === "ImportDeclaration") {
|
||||||
|
return validateImportDeclaration(node);
|
||||||
|
}
|
||||||
|
if (node.type === "ExportNamedDeclaration") {
|
||||||
|
return validateExportNamedDeclaration(node);
|
||||||
|
}
|
||||||
|
if (node.type === "ExportAllDeclaration") {
|
||||||
|
return validateExportAllDeclaration(node);
|
||||||
|
}
|
||||||
|
if (node.type === "CallExpression") {
|
||||||
|
return validateRequireCall(node);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate RFC-001 bundle rules: single-file ESM shape, default export,
|
* Validate RFC-001 bundle rules: single-file ESM shape, default export,
|
||||||
* no dynamic `import()`, static imports restricted to Node builtins.
|
* no dynamic `import()`, static imports restricted to Node builtins.
|
||||||
@@ -84,58 +183,19 @@ export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Re
|
|||||||
return err("workflow bundle must have a default export");
|
return err("workflow bundle must have a default export");
|
||||||
}
|
}
|
||||||
|
|
||||||
let walkError: string | null = null;
|
let violation: string | null = null;
|
||||||
walk(ast, (n) => {
|
walkAst(ast, (node) => {
|
||||||
if (walkError !== null) {
|
if (violation !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (n.type === "ImportExpression") {
|
const next = bundleConstraintViolationForNode(node);
|
||||||
walkError = "dynamic import() is not allowed in workflow bundles";
|
if (next !== null) {
|
||||||
return;
|
violation = next;
|
||||||
}
|
|
||||||
if (n.type === "ImportDeclaration") {
|
|
||||||
const src = n.source;
|
|
||||||
if (src.type !== "Literal" || typeof src.value !== "string") {
|
|
||||||
walkError = "only static string import specifiers are allowed";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isAllowedImportSpecifier(src.value)) {
|
|
||||||
walkError = `disallowed import specifier "${src.value}" (only Node built-ins are allowed)`;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (n.type === "ExportNamedDeclaration" && n.source !== null && n.source !== undefined) {
|
|
||||||
const src = n.source;
|
|
||||||
if (src.type !== "Literal" || typeof src.value !== "string") {
|
|
||||||
walkError = "only static string re-export specifiers are allowed";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isAllowedImportSpecifier(src.value)) {
|
|
||||||
walkError = `disallowed re-export specifier "${src.value}" (only Node built-ins are allowed)`;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (n.type === "ExportAllDeclaration") {
|
|
||||||
const src = n.source;
|
|
||||||
if (src.type !== "Literal" || typeof src.value !== "string") {
|
|
||||||
walkError = "only static string export-all specifiers are allowed";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isAllowedImportSpecifier(src.value)) {
|
|
||||||
walkError = `disallowed export-all specifier "${src.value}" (only Node built-ins are allowed)`;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (n.type === "CallExpression") {
|
|
||||||
const c = n.callee;
|
|
||||||
if (c.type === "Identifier" && c.name === "require") {
|
|
||||||
walkError = "require() is not allowed in workflow bundles";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (walkError !== null) {
|
if (violation !== null) {
|
||||||
return err(walkError);
|
return err(violation);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { appendFile, mkdir } from "node:fs/promises";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
import type { LogFn } from "./logger.js";
|
||||||
|
import {
|
||||||
|
END,
|
||||||
|
type RoleMeta,
|
||||||
|
type RoleStep,
|
||||||
|
START,
|
||||||
|
type ThreadContext,
|
||||||
|
type WorkflowDefinition,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export type ExecuteThreadIo = {
|
||||||
|
threadId: string;
|
||||||
|
hash: string;
|
||||||
|
dataJsonlPath: string;
|
||||||
|
infoJsonlPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecuteThreadOptions = {
|
||||||
|
isDryRun: boolean;
|
||||||
|
maxRounds: number;
|
||||||
|
signal: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRoleNext<M extends RoleMeta>(
|
||||||
|
next: (keyof M & string) | typeof END,
|
||||||
|
): next is keyof M & string {
|
||||||
|
return next !== END;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendDataLine(path: string, record: unknown): Promise<void> {
|
||||||
|
const line = `${JSON.stringify(record)}\n`;
|
||||||
|
await appendFile(path, line, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a workflow thread: moderator loop, role steps, RFC-001 `.data.jsonl` records,
|
||||||
|
* debug lines via `logger` to `.info.jsonl`.
|
||||||
|
*/
|
||||||
|
export async function executeThread<M extends RoleMeta>(
|
||||||
|
def: WorkflowDefinition<M>,
|
||||||
|
prompt: string,
|
||||||
|
options: ExecuteThreadOptions,
|
||||||
|
io: ExecuteThreadIo,
|
||||||
|
logger: LogFn,
|
||||||
|
): Promise<{ returnCode: number; summary: string }> {
|
||||||
|
await mkdir(dirname(io.dataJsonlPath), { recursive: true });
|
||||||
|
await mkdir(dirname(io.infoJsonlPath), { recursive: true });
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const start: ThreadContext<M>["start"] = {
|
||||||
|
role: START,
|
||||||
|
content: prompt,
|
||||||
|
meta: { maxRounds: options.maxRounds, threadId: io.threadId },
|
||||||
|
timestamp: nowMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRecord = {
|
||||||
|
name: def.name,
|
||||||
|
hash: io.hash,
|
||||||
|
threadId: io.threadId,
|
||||||
|
parameters: {
|
||||||
|
prompt,
|
||||||
|
options: {
|
||||||
|
isDryRun: options.isDryRun,
|
||||||
|
maxRounds: options.maxRounds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: nowMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
await appendDataLine(io.dataJsonlPath, startRecord);
|
||||||
|
|
||||||
|
let steps: RoleStep<M>[] = [];
|
||||||
|
|
||||||
|
logger("T9HQ2KHM", `thread ${io.threadId} started for workflow ${def.name}`);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (options.signal.aborted) {
|
||||||
|
logger("V8JX4NP2", `thread ${io.threadId} aborted`);
|
||||||
|
return { returnCode: 130, summary: "thread aborted" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steps.length >= options.maxRounds) {
|
||||||
|
logger("R3CW7YBQ", `thread ${io.threadId} stopped at maxRounds=${options.maxRounds}`);
|
||||||
|
return {
|
||||||
|
returnCode: 0,
|
||||||
|
summary: `completed: reached maxRounds (${options.maxRounds})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx: ThreadContext<M> = {
|
||||||
|
threadId: io.threadId,
|
||||||
|
start,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = def.moderator(ctx);
|
||||||
|
|
||||||
|
if (!isRoleNext(next)) {
|
||||||
|
logger("M5FZ2K8H", `thread ${io.threadId} moderator returned END`);
|
||||||
|
return { returnCode: 0, summary: "completed: moderator returned END" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleFn = def.roles[next];
|
||||||
|
if (roleFn === undefined) {
|
||||||
|
logger("K2P8QX9W", `thread ${io.threadId} unknown role ${next}`);
|
||||||
|
return { returnCode: 1, summary: `unknown role: ${next}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.signal.aborted) {
|
||||||
|
logger("V8JX4NP3", `thread ${io.threadId} aborted`);
|
||||||
|
return { returnCode: 130, summary: "thread aborted" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await roleFn(ctx);
|
||||||
|
|
||||||
|
const ts = Date.now();
|
||||||
|
const step: RoleStep<M> = {
|
||||||
|
role: next,
|
||||||
|
content: result.content,
|
||||||
|
meta: result.meta,
|
||||||
|
timestamp: ts,
|
||||||
|
} as RoleStep<M>;
|
||||||
|
|
||||||
|
await appendDataLine(io.dataJsonlPath, {
|
||||||
|
role: step.role,
|
||||||
|
content: step.content,
|
||||||
|
meta: step.meta,
|
||||||
|
timestamp: step.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
steps = [...steps, step];
|
||||||
|
logger("N7BW4YHQ", `thread ${io.threadId} completed role ${next}`);
|
||||||
|
|
||||||
|
if (options.signal.aborted) {
|
||||||
|
logger("V8JX4NP4", `thread ${io.threadId} aborted`);
|
||||||
|
return { returnCode: 130, summary: "thread aborted" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,15 @@ export {
|
|||||||
encodeUint64AsCrockford,
|
encodeUint64AsCrockford,
|
||||||
} from "./base32.js";
|
} from "./base32.js";
|
||||||
export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js";
|
export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js";
|
||||||
|
export {
|
||||||
|
type ExecuteThreadIo,
|
||||||
|
type ExecuteThreadOptions,
|
||||||
|
executeThread,
|
||||||
|
} from "./engine.js";
|
||||||
export { hashWorkflowBundleBytes } from "./hash.js";
|
export { hashWorkflowBundleBytes } from "./hash.js";
|
||||||
export {
|
export {
|
||||||
createLogger,
|
|
||||||
type CreateLoggerOptions,
|
type CreateLoggerOptions,
|
||||||
|
createLogger,
|
||||||
type LogFn,
|
type LogFn,
|
||||||
type LoggerSink,
|
type LoggerSink,
|
||||||
} from "./logger.js";
|
} from "./logger.js";
|
||||||
@@ -21,12 +26,26 @@ export {
|
|||||||
registerWorkflowVersion,
|
registerWorkflowVersion,
|
||||||
stringifyWorkflowRegistryYaml,
|
stringifyWorkflowRegistryYaml,
|
||||||
unregisterWorkflow,
|
unregisterWorkflow,
|
||||||
workflowRegistryPath,
|
|
||||||
writeWorkflowRegistry,
|
|
||||||
type WorkflowHistoryEntry,
|
type WorkflowHistoryEntry,
|
||||||
type WorkflowRegistryEntry,
|
type WorkflowRegistryEntry,
|
||||||
type WorkflowRegistryFile,
|
type WorkflowRegistryFile,
|
||||||
|
workflowRegistryPath,
|
||||||
|
writeWorkflowRegistry,
|
||||||
} from "./registry.js";
|
} from "./registry.js";
|
||||||
export { err, ok, type Result } from "./result.js";
|
export { err, ok, type Result } from "./result.js";
|
||||||
export { getDefaultWorkflowStorageRoot } from "./storage-root.js";
|
export { getDefaultWorkflowStorageRoot } from "./storage-root.js";
|
||||||
|
export {
|
||||||
|
type AgentFn,
|
||||||
|
END,
|
||||||
|
type Moderator,
|
||||||
|
type Role,
|
||||||
|
type RoleMeta,
|
||||||
|
type RoleResult,
|
||||||
|
type RoleStep,
|
||||||
|
START,
|
||||||
|
type StartStep,
|
||||||
|
type ThreadContext,
|
||||||
|
type WorkflowDefinition,
|
||||||
|
} from "./types.js";
|
||||||
export { generateUlid } from "./ulid.js";
|
export { generateUlid } from "./ulid.js";
|
||||||
|
export { getWorkerHostScriptPath } from "./worker-entry-path.js";
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { appendFileSync } from "node:fs";
|
import { appendFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { CROCKFORD_BASE32_ALPHABET } from "./base32.js";
|
||||||
|
|
||||||
const TAG_LENGTH = 8;
|
const TAG_LENGTH = 8;
|
||||||
|
|
||||||
|
const TAG_CHAR_SET: ReadonlySet<string> = new Set(CROCKFORD_BASE32_ALPHABET.split(""));
|
||||||
|
|
||||||
function assertValidLogTag(tag: string): void {
|
function assertValidLogTag(tag: string): void {
|
||||||
if (tag.length !== TAG_LENGTH) {
|
if (tag.length !== TAG_LENGTH) {
|
||||||
throw new Error(`log tag must be exactly ${TAG_LENGTH} characters`);
|
throw new Error(`log tag must be exactly ${TAG_LENGTH} characters`);
|
||||||
@@ -12,15 +16,13 @@ function assertValidLogTag(tag: string): void {
|
|||||||
throw new Error("log tag validation failed");
|
throw new Error("log tag validation failed");
|
||||||
}
|
}
|
||||||
const upper = ch.toUpperCase();
|
const upper = ch.toUpperCase();
|
||||||
if (!/[0-9A-HJKMNP-TV-Z]/.test(upper)) {
|
if (!TAG_CHAR_SET.has(upper)) {
|
||||||
throw new Error(`invalid Crockford Base32 character in log tag: ${ch}`);
|
throw new Error(`invalid Crockford Base32 character in log tag: ${ch}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoggerSink =
|
export type LoggerSink = { kind: "stderr" } | { kind: "file"; path: string };
|
||||||
| { kind: "stderr" }
|
|
||||||
| { kind: "file"; path: string };
|
|
||||||
|
|
||||||
export type CreateLoggerOptions = {
|
export type CreateLoggerOptions = {
|
||||||
sink: LoggerSink;
|
sink: LoggerSink;
|
||||||
@@ -33,12 +35,11 @@ export function createLogger(options: CreateLoggerOptions): LogFn {
|
|||||||
if (options.sink.kind === "stderr") {
|
if (options.sink.kind === "stderr") {
|
||||||
return (tag: string, content: string) => {
|
return (tag: string, content: string) => {
|
||||||
assertValidLogTag(tag);
|
assertValidLogTag(tag);
|
||||||
const line =
|
const line = `${JSON.stringify({
|
||||||
`${JSON.stringify({
|
tag: tag.toUpperCase(),
|
||||||
tag: tag.toUpperCase(),
|
content,
|
||||||
content,
|
timestamp: Date.now(),
|
||||||
timestamp: Date.now(),
|
})}\n`;
|
||||||
})}\n`;
|
|
||||||
process.stderr.write(line);
|
process.stderr.write(line);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -46,12 +47,11 @@ export function createLogger(options: CreateLoggerOptions): LogFn {
|
|||||||
const filePath = options.sink.path;
|
const filePath = options.sink.path;
|
||||||
return (tag: string, content: string) => {
|
return (tag: string, content: string) => {
|
||||||
assertValidLogTag(tag);
|
assertValidLogTag(tag);
|
||||||
const line =
|
const line = `${JSON.stringify({
|
||||||
`${JSON.stringify({
|
tag: tag.toUpperCase(),
|
||||||
tag: tag.toUpperCase(),
|
content,
|
||||||
content,
|
timestamp: Date.now(),
|
||||||
timestamp: Date.now(),
|
})}\n`;
|
||||||
})}\n`;
|
|
||||||
appendFileSync(filePath, line, "utf8");
|
appendFileSync(filePath, line, "utf8");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type {
|
||||||
|
WorkflowHistoryEntry,
|
||||||
|
WorkflowRegistryEntry,
|
||||||
|
WorkflowRegistryFile,
|
||||||
|
} from "./registry-types.js";
|
||||||
|
import { err, ok, type Result } from "./result.js";
|
||||||
|
|
||||||
|
export function normalizeWorkflowHistoryEntry(
|
||||||
|
workflowName: string,
|
||||||
|
index: number,
|
||||||
|
raw: unknown,
|
||||||
|
): Result<WorkflowHistoryEntry, Error> {
|
||||||
|
if (raw === null || typeof raw !== "object") {
|
||||||
|
return err(new Error(`workflow "${workflowName}" history[${index}] must be a mapping`));
|
||||||
|
}
|
||||||
|
const he = raw as Record<string, unknown>;
|
||||||
|
const hash = he.hash;
|
||||||
|
const timestamp = he.timestamp;
|
||||||
|
if (typeof hash !== "string" || typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
|
||||||
|
return err(
|
||||||
|
new Error(`workflow "${workflowName}" history[${index}] must have hash and timestamp`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ok({ hash, timestamp });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkflowRegistryEntry(
|
||||||
|
workflowName: string,
|
||||||
|
raw: unknown,
|
||||||
|
): Result<WorkflowRegistryEntry, Error> {
|
||||||
|
if (raw === null || typeof raw !== "object") {
|
||||||
|
return err(new Error(`workflow "${workflowName}" must be a mapping`));
|
||||||
|
}
|
||||||
|
const e = raw as Record<string, unknown>;
|
||||||
|
const hash = e.hash;
|
||||||
|
const timestamp = e.timestamp;
|
||||||
|
const historyRaw = e.history;
|
||||||
|
if (typeof hash !== "string") {
|
||||||
|
return err(new Error(`workflow "${workflowName}" must have a string hash`));
|
||||||
|
}
|
||||||
|
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
|
||||||
|
return err(new Error(`workflow "${workflowName}" must have a finite numeric timestamp`));
|
||||||
|
}
|
||||||
|
if (!Array.isArray(historyRaw)) {
|
||||||
|
return err(new Error(`workflow "${workflowName}" must have a history array`));
|
||||||
|
}
|
||||||
|
const history: WorkflowHistoryEntry[] = [];
|
||||||
|
for (let i = 0; i < historyRaw.length; i++) {
|
||||||
|
const item = historyRaw[i];
|
||||||
|
const next = normalizeWorkflowHistoryEntry(workflowName, i, item);
|
||||||
|
if (!next.ok) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
history.push(next.value);
|
||||||
|
}
|
||||||
|
return ok({ hash, timestamp, history });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkflowRegistryRoot(raw: unknown): Result<WorkflowRegistryFile, Error> {
|
||||||
|
if (raw === null || typeof raw !== "object") {
|
||||||
|
return err(new Error("registry root must be a mapping"));
|
||||||
|
}
|
||||||
|
const root = raw as Record<string, unknown>;
|
||||||
|
const workflowsRaw = root.workflows;
|
||||||
|
if (workflowsRaw === null || workflowsRaw === undefined || typeof workflowsRaw !== "object") {
|
||||||
|
return err(new Error('registry must contain a "workflows" mapping'));
|
||||||
|
}
|
||||||
|
const workflows: Record<string, WorkflowRegistryEntry> = {};
|
||||||
|
for (const [name, entryRaw] of Object.entries(workflowsRaw)) {
|
||||||
|
const entryResult = normalizeWorkflowRegistryEntry(name, entryRaw);
|
||||||
|
if (!entryResult.ok) {
|
||||||
|
return entryResult;
|
||||||
|
}
|
||||||
|
workflows[name] = entryResult.value;
|
||||||
|
}
|
||||||
|
return ok({ workflows });
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export type WorkflowHistoryEntry = {
|
||||||
|
hash: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowRegistryEntry = {
|
||||||
|
hash: string;
|
||||||
|
timestamp: number;
|
||||||
|
history: WorkflowHistoryEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowRegistryFile = {
|
||||||
|
workflows: Record<string, WorkflowRegistryEntry>;
|
||||||
|
};
|
||||||
@@ -3,22 +3,19 @@ import { dirname, join } from "node:path";
|
|||||||
|
|
||||||
import { parseDocument, stringify } from "yaml";
|
import { parseDocument, stringify } from "yaml";
|
||||||
|
|
||||||
|
import { normalizeWorkflowRegistryRoot } from "./registry-normalize.js";
|
||||||
|
import type {
|
||||||
|
WorkflowHistoryEntry,
|
||||||
|
WorkflowRegistryEntry,
|
||||||
|
WorkflowRegistryFile,
|
||||||
|
} from "./registry-types.js";
|
||||||
import { err, ok, type Result } from "./result.js";
|
import { err, ok, type Result } from "./result.js";
|
||||||
|
|
||||||
export type WorkflowHistoryEntry = {
|
export type {
|
||||||
hash: string;
|
WorkflowHistoryEntry,
|
||||||
timestamp: number;
|
WorkflowRegistryEntry,
|
||||||
};
|
WorkflowRegistryFile,
|
||||||
|
} from "./registry-types.js";
|
||||||
export type WorkflowRegistryEntry = {
|
|
||||||
hash: string;
|
|
||||||
timestamp: number;
|
|
||||||
history: WorkflowHistoryEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowRegistryFile = {
|
|
||||||
workflows: Record<string, WorkflowRegistryEntry>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function workflowRegistryPath(storageRoot: string): string {
|
export function workflowRegistryPath(storageRoot: string): string {
|
||||||
return join(storageRoot, "workflow.yaml");
|
return join(storageRoot, "workflow.yaml");
|
||||||
@@ -28,50 +25,6 @@ function emptyRegistry(): WorkflowRegistryFile {
|
|||||||
return { workflows: {} };
|
return { workflows: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRegistry(raw: unknown): Result<WorkflowRegistryFile, Error> {
|
|
||||||
if (raw === null || typeof raw !== "object") {
|
|
||||||
return err(new Error("registry root must be a mapping"));
|
|
||||||
}
|
|
||||||
const root = raw as Record<string, unknown>;
|
|
||||||
const workflowsRaw = root.workflows;
|
|
||||||
if (workflowsRaw === null || workflowsRaw === undefined || typeof workflowsRaw !== "object") {
|
|
||||||
return err(new Error('registry must contain a "workflows" mapping'));
|
|
||||||
}
|
|
||||||
const workflows: Record<string, WorkflowRegistryEntry> = {};
|
|
||||||
for (const [name, entryRaw] of Object.entries(workflowsRaw)) {
|
|
||||||
if (entryRaw === null || typeof entryRaw !== "object") {
|
|
||||||
return err(new Error(`workflow "${name}" must be a mapping`));
|
|
||||||
}
|
|
||||||
const e = entryRaw as Record<string, unknown>;
|
|
||||||
const hash = e.hash;
|
|
||||||
const timestamp = e.timestamp;
|
|
||||||
const historyRaw = e.history;
|
|
||||||
if (typeof hash !== "string") {
|
|
||||||
return err(new Error(`workflow "${name}" must have a string hash`));
|
|
||||||
}
|
|
||||||
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
|
|
||||||
return err(new Error(`workflow "${name}" must have a finite numeric timestamp`));
|
|
||||||
}
|
|
||||||
if (!Array.isArray(historyRaw)) {
|
|
||||||
return err(new Error(`workflow "${name}" must have a history array`));
|
|
||||||
}
|
|
||||||
const history: WorkflowHistoryEntry[] = [];
|
|
||||||
for (let i = 0; i < historyRaw.length; i++) {
|
|
||||||
const h = historyRaw[i];
|
|
||||||
if (h === null || typeof h !== "object") {
|
|
||||||
return err(new Error(`workflow "${name}" history[${i}] must be a mapping`));
|
|
||||||
}
|
|
||||||
const he = h as Record<string, unknown>;
|
|
||||||
if (typeof he.hash !== "string" || typeof he.timestamp !== "number" || !Number.isFinite(he.timestamp)) {
|
|
||||||
return err(new Error(`workflow "${name}" history[${i}] must have hash and timestamp`));
|
|
||||||
}
|
|
||||||
history.push({ hash: he.hash, timestamp: he.timestamp });
|
|
||||||
}
|
|
||||||
workflows[name] = { hash, timestamp, history };
|
|
||||||
}
|
|
||||||
return ok({ workflows });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseWorkflowRegistryYaml(text: string): Result<WorkflowRegistryFile, Error> {
|
export function parseWorkflowRegistryYaml(text: string): Result<WorkflowRegistryFile, Error> {
|
||||||
if (text.trim() === "") {
|
if (text.trim() === "") {
|
||||||
return ok(emptyRegistry());
|
return ok(emptyRegistry());
|
||||||
@@ -82,14 +35,16 @@ export function parseWorkflowRegistryYaml(text: string): Result<WorkflowRegistry
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return err(e instanceof Error ? e : new Error(String(e)));
|
return err(e instanceof Error ? e : new Error(String(e)));
|
||||||
}
|
}
|
||||||
return normalizeRegistry(doc);
|
return normalizeWorkflowRegistryRoot(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stringifyWorkflowRegistryYaml(registry: WorkflowRegistryFile): string {
|
export function stringifyWorkflowRegistryYaml(registry: WorkflowRegistryFile): string {
|
||||||
return `${stringify(registry, { indent: 2 })}\n`;
|
return `${stringify(registry, { indent: 2, defaultStringType: "QUOTE_DOUBLE" })}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readWorkflowRegistry(storageRoot: string): Promise<Result<WorkflowRegistryFile, Error>> {
|
export async function readWorkflowRegistry(
|
||||||
|
storageRoot: string,
|
||||||
|
): Promise<Result<WorkflowRegistryFile, Error>> {
|
||||||
const path = workflowRegistryPath(storageRoot);
|
const path = workflowRegistryPath(storageRoot);
|
||||||
let text: string;
|
let text: string;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/** Sentinel values for automaton control flow. */
|
||||||
|
export const START = "__start__" as const;
|
||||||
|
export const END = "__end__" as const;
|
||||||
|
|
||||||
|
/** Maps role names → their meta types. Single generic drives all inference. */
|
||||||
|
export type RoleMeta = Record<string, Record<string, unknown>>;
|
||||||
|
|
||||||
|
/** Typed output of a Role execution. */
|
||||||
|
export type RoleResult<Meta extends Record<string, unknown>> = {
|
||||||
|
content: string;
|
||||||
|
meta: Meta;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Engine start frame: initial prompt + thread identity. */
|
||||||
|
export type StartStep = {
|
||||||
|
role: typeof START;
|
||||||
|
content: string;
|
||||||
|
meta: { maxRounds: number; threadId: string };
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A completed role step in the thread. */
|
||||||
|
export type RoleStep<M extends RoleMeta> = {
|
||||||
|
[K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number };
|
||||||
|
}[keyof M & string];
|
||||||
|
|
||||||
|
/** Thread-scoped context passed to roles and moderator. */
|
||||||
|
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
||||||
|
threadId: string;
|
||||||
|
start: StartStep;
|
||||||
|
steps: RoleStep<M>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Role — receives full thread context, returns typed content + meta.
|
||||||
|
* Implementation can be an agent, LLM call, script, HTTP request, etc.
|
||||||
|
*/
|
||||||
|
export type Role<Meta extends Record<string, unknown>> = (
|
||||||
|
ctx: ThreadContext,
|
||||||
|
) => Promise<RoleResult<Meta>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An Agent — raw string output interface for LLM/CLI adapters.
|
||||||
|
* Structured meta is extracted by the role's extract layer.
|
||||||
|
*/
|
||||||
|
export type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Moderator — a pure routing function.
|
||||||
|
* Receives the full thread context (start + all prior steps).
|
||||||
|
* On initial call, `steps` is empty.
|
||||||
|
* Returns the next role name or END to terminate.
|
||||||
|
*/
|
||||||
|
export type Moderator<M extends RoleMeta> = (
|
||||||
|
ctx: ThreadContext<M>,
|
||||||
|
) => (keyof M & string) | typeof END;
|
||||||
|
|
||||||
|
/** Complete workflow definition as authored by users. */
|
||||||
|
export type WorkflowDefinition<M extends RoleMeta> = {
|
||||||
|
name: string;
|
||||||
|
roles: { [K in keyof M & string]: Role<M[K]> };
|
||||||
|
moderator: Moderator<M>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/** Absolute path to `worker-host.ts` for spawning bundle worker processes. */
|
||||||
|
export function getWorkerHostScriptPath(): string {
|
||||||
|
return fileURLToPath(new URL("./worker.ts", import.meta.url));
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||||
|
import { createServer, type Socket } from "node:net";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
import { type ExecuteThreadIo, executeThread } from "./engine.js";
|
||||||
|
import { createLogger } from "./logger.js";
|
||||||
|
import type { RoleMeta, WorkflowDefinition } from "./types.js";
|
||||||
|
|
||||||
|
const bootLog = createLogger({ sink: { kind: "stderr" } });
|
||||||
|
|
||||||
|
type RunCommand = {
|
||||||
|
type: "run";
|
||||||
|
threadId: string;
|
||||||
|
prompt: string;
|
||||||
|
options: { isDryRun: boolean; maxRounds: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
type KillCommand = {
|
||||||
|
type: "kill";
|
||||||
|
threadId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ControlCommand = RunCommand | KillCommand;
|
||||||
|
|
||||||
|
function parseControlPayload(payload: unknown): ControlCommand | null {
|
||||||
|
if (payload === null || typeof payload !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rec = payload as Record<string, unknown>;
|
||||||
|
const type = rec.type;
|
||||||
|
if (type === "kill") {
|
||||||
|
const threadId = rec.threadId;
|
||||||
|
if (typeof threadId !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { type: "kill", threadId };
|
||||||
|
}
|
||||||
|
if (type === "run") {
|
||||||
|
const threadId = rec.threadId;
|
||||||
|
const prompt = rec.prompt;
|
||||||
|
const options = rec.options;
|
||||||
|
if (typeof threadId !== "string" || typeof prompt !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (options === null || typeof options !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const optRec = options as Record<string, unknown>;
|
||||||
|
const isDryRun = optRec.isDryRun;
|
||||||
|
const maxRounds = optRec.maxRounds;
|
||||||
|
if (typeof isDryRun !== "boolean" || typeof maxRounds !== "number") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "run",
|
||||||
|
threadId,
|
||||||
|
prompt,
|
||||||
|
options: { isDryRun, maxRounds },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCommandLine(line: string): ControlCommand | null {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
bootLog("S8KQ3WJP", "worker received invalid JSON control line");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseControlPayload(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkflowDefinitionLike(value: unknown): value is WorkflowDefinition<RoleMeta> {
|
||||||
|
if (value === null || typeof value !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const rec = value as Record<string, unknown>;
|
||||||
|
if (typeof rec.name !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (rec.roles === null || typeof rec.roles !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof rec.moderator !== "function") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLineFromSocket(socket: Socket): Promise<string | null> {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
let buf = "";
|
||||||
|
function onData(chunk: Buffer): void {
|
||||||
|
buf += chunk.toString("utf8");
|
||||||
|
const nl = buf.indexOf("\n");
|
||||||
|
if (nl >= 0) {
|
||||||
|
cleanup();
|
||||||
|
resolve(buf.slice(0, nl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onEnd(): void {
|
||||||
|
cleanup();
|
||||||
|
resolve(buf === "" ? null : buf);
|
||||||
|
}
|
||||||
|
function onError(): void {
|
||||||
|
cleanup();
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
function cleanup(): void {
|
||||||
|
socket.off("data", onData);
|
||||||
|
socket.off("end", onEnd);
|
||||||
|
socket.off("error", onError);
|
||||||
|
}
|
||||||
|
socket.on("data", onData);
|
||||||
|
socket.on("end", onEnd);
|
||||||
|
socket.on("error", onError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const bundlePath = process.argv[2];
|
||||||
|
const storageRoot = process.argv[3];
|
||||||
|
const hash = process.argv[4];
|
||||||
|
|
||||||
|
if (
|
||||||
|
bundlePath === undefined ||
|
||||||
|
storageRoot === undefined ||
|
||||||
|
hash === undefined ||
|
||||||
|
bundlePath === "" ||
|
||||||
|
storageRoot === "" ||
|
||||||
|
hash === ""
|
||||||
|
) {
|
||||||
|
bootLog("H7XN4MKQ", "worker usage: worker <bundlePath> <storageRoot> <hash>");
|
||||||
|
process.exit(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic import required: user bundle path resolved at runtime
|
||||||
|
const modUnknown: unknown = await import(pathToFileURL(bundlePath).href);
|
||||||
|
const modRec = modUnknown as Record<string, unknown>;
|
||||||
|
const defaultExport = modRec.default;
|
||||||
|
if (!isWorkflowDefinitionLike(defaultExport)) {
|
||||||
|
bootLog(
|
||||||
|
"T4BW9YJX",
|
||||||
|
"workflow bundle default export must be a WorkflowDefinition { name, roles, moderator }",
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const def = defaultExport;
|
||||||
|
|
||||||
|
const controllers = new Map<string, AbortController>();
|
||||||
|
let activeThreads = 0;
|
||||||
|
let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const workerCtlPath = join(storageRoot, "workers", `${hash}.json`);
|
||||||
|
|
||||||
|
function cancelShutdownTimer(): void {
|
||||||
|
if (shutdownTimer !== null) {
|
||||||
|
clearTimeout(shutdownTimer);
|
||||||
|
shutdownTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleShutdown(): void {
|
||||||
|
cancelShutdownTimer();
|
||||||
|
shutdownTimer = setTimeout(() => {
|
||||||
|
void unlink(workerCtlPath).catch(() => {});
|
||||||
|
process.exit(0);
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpStart(): void {
|
||||||
|
cancelShutdownTimer();
|
||||||
|
activeThreads++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpDone(): void {
|
||||||
|
activeThreads--;
|
||||||
|
if (activeThreads <= 0) {
|
||||||
|
activeThreads = 0;
|
||||||
|
scheduleShutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchCommand(cmd: ControlCommand, socket: Socket | null): Promise<void> {
|
||||||
|
if (cmd.type === "kill") {
|
||||||
|
const ac = controllers.get(cmd.threadId);
|
||||||
|
if (ac !== undefined) {
|
||||||
|
ac.abort();
|
||||||
|
bootLog("P9XK2WNQ", `kill requested for thread ${cmd.threadId}`);
|
||||||
|
}
|
||||||
|
socket?.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bumpStart();
|
||||||
|
|
||||||
|
const threadId = cmd.threadId;
|
||||||
|
const runningPath = join(storageRoot, "logs", hash, `${threadId}.running`);
|
||||||
|
const dataJsonlPath = join(storageRoot, "logs", hash, `${threadId}.data.jsonl`);
|
||||||
|
const infoJsonlPath = join(storageRoot, "logs", hash, `${threadId}.info.jsonl`);
|
||||||
|
|
||||||
|
const io: ExecuteThreadIo = {
|
||||||
|
threadId,
|
||||||
|
hash,
|
||||||
|
dataJsonlPath,
|
||||||
|
infoJsonlPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = controllers.get(threadId);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
existing.abort();
|
||||||
|
controllers.delete(threadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
controllers.set(threadId, ac);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(runningPath), { recursive: true });
|
||||||
|
await mkdir(dirname(dataJsonlPath), { recursive: true });
|
||||||
|
await writeFile(runningPath, "", "utf8");
|
||||||
|
|
||||||
|
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
|
||||||
|
|
||||||
|
await executeThread(def, cmd.prompt, { ...cmd.options, signal: ac.signal }, io, logger);
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
bootLog("Q3MN8YKW", `thread ${threadId} failed: ${message}`);
|
||||||
|
} finally {
|
||||||
|
controllers.delete(threadId);
|
||||||
|
await unlink(runningPath).catch(() => {});
|
||||||
|
bumpDone();
|
||||||
|
socket?.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof process.send === "function") {
|
||||||
|
process.on("message", (msg: unknown) => {
|
||||||
|
const cmd = parseControlPayload(msg);
|
||||||
|
if (cmd === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void dispatchCommand(cmd, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer((socket) => {
|
||||||
|
void (async () => {
|
||||||
|
const line = await readLineFromSocket(socket);
|
||||||
|
if (line === null) {
|
||||||
|
socket.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cmd = parseCommandLine(line);
|
||||||
|
if (cmd === null) {
|
||||||
|
socket.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await dispatchCommand(cmd, socket);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("error", (err) => {
|
||||||
|
bootLog("W8YK4NPX", `worker server error: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const addr = server.address();
|
||||||
|
if (addr === null || typeof addr === "string") {
|
||||||
|
bootLog("R9XK4MNW", "worker failed to bind TCP address");
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(`READY ${addr.port}\n`);
|
||||||
|
|
||||||
|
await new Promise<void>(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
+1
-4
@@ -15,8 +15,5 @@
|
|||||||
"composite": true,
|
"composite": true,
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"references": [
|
"references": [{ "path": "packages/workflow" }, { "path": "packages/cli-workflow" }]
|
||||||
{ "path": "packages/workflow" },
|
|
||||||
{ "path": "packages/cli-workflow" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user