feat: Phase 4 — fork threads + bun publish verified
- fork-thread.ts: parse .data.jsonl, trim steps by role - cmd-fork.ts: --from-role <role> or retry last step - engine: forkFrom lineage tracking, prefilled step replay - worker: accept steps in run IPC command - bun publish --dry-run: both packages pass - 53 tests pass, biome clean Closes #5 小橘 <xiaoju@shazhou.work>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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 { cmdFork } from "../src/cmd-fork.js";
|
||||
import { cmdRun } from "../src/cmd-run.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||
const threeRoleBundleSource = `export default async function* (input) {
|
||||
const has = (r) => input.steps.some((s) => s.role === r);
|
||||
if (!has("planner")) {
|
||||
yield { role: "planner", content: "p1", meta: { k: "planner" } };
|
||||
}
|
||||
if (!has("coder")) {
|
||||
yield { role: "coder", content: "c1", meta: { k: "coder" } };
|
||||
}
|
||||
if (!has("reviewer")) {
|
||||
yield {
|
||||
role: "reviewer",
|
||||
content: "rev-" + String(input.steps.length),
|
||||
meta: { k: "reviewer" },
|
||||
};
|
||||
}
|
||||
return { returnCode: 0, summary: "done" };
|
||||
}
|
||||
`;
|
||||
|
||||
async function countDataJsonlLines(dataPath: string): Promise<number> {
|
||||
try {
|
||||
const text = await readFile(dataPath, "utf8");
|
||||
return text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "").length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilMinDataLines(dataPath: string, minLines: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < 120; attempt++) {
|
||||
if ((await countDataJsonlLines(dataPath)) >= minLines) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilRunningAbsent(runningPath: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 120; attempt++) {
|
||||
if (!(await pathExists(runningPath))) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
}
|
||||
|
||||
describe("cli fork", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-fork-"));
|
||||
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("fork --from-role planner continues with coder then reviewer", async () => {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(bundlePath, threeRoleBundleSource, "utf8");
|
||||
|
||||
const added = await cmdAdd(storageRoot, "solve-issue", bundlePath);
|
||||
expect(added.ok).toBe(true);
|
||||
if (!added.ok) {
|
||||
return;
|
||||
}
|
||||
const hash = added.value.hash;
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
}
|
||||
const sourceId = ran.value.threadId;
|
||||
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
|
||||
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
|
||||
await waitUntilRunningAbsent(sourceRunning);
|
||||
await waitUntilMinDataLines(sourceData, 4);
|
||||
|
||||
const forked = await cmdFork(storageRoot, sourceId, "planner");
|
||||
expect(forked.ok).toBe(true);
|
||||
if (!forked.ok) {
|
||||
return;
|
||||
}
|
||||
const newId = forked.value.threadId;
|
||||
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
|
||||
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
|
||||
await waitUntilRunningAbsent(newRunning);
|
||||
await waitUntilMinDataLines(newData, 4);
|
||||
|
||||
const text = await readFile(newData, "utf8");
|
||||
const lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(4);
|
||||
const start = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
|
||||
expect(start.threadId).toBe(newId);
|
||||
expect(start.forkFrom).toEqual({ threadId: sourceId });
|
||||
|
||||
const last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
|
||||
expect(last.role).toBe("reviewer");
|
||||
expect(last.content).toBe("rev-1");
|
||||
});
|
||||
|
||||
test("fork without --from-role retries last role", async () => {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(bundlePath, threeRoleBundleSource, "utf8");
|
||||
|
||||
const added = await cmdAdd(storageRoot, "solve-issue", bundlePath);
|
||||
expect(added.ok).toBe(true);
|
||||
if (!added.ok) {
|
||||
return;
|
||||
}
|
||||
const hash = added.value.hash;
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
}
|
||||
const sourceId = ran.value.threadId;
|
||||
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
|
||||
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
|
||||
await waitUntilRunningAbsent(sourceRunning);
|
||||
await waitUntilMinDataLines(sourceData, 4);
|
||||
|
||||
const forked = await cmdFork(storageRoot, sourceId, null);
|
||||
expect(forked.ok).toBe(true);
|
||||
if (!forked.ok) {
|
||||
return;
|
||||
}
|
||||
const newId = forked.value.threadId;
|
||||
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
|
||||
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
|
||||
await waitUntilRunningAbsent(newRunning);
|
||||
await waitUntilMinDataLines(newData, 4);
|
||||
|
||||
const text = await readFile(newData, "utf8");
|
||||
const lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(4);
|
||||
|
||||
const replayCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
expect(replayCoder.role).toBe("coder");
|
||||
expect(replayCoder.content).toBe("c1");
|
||||
|
||||
const last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
|
||||
expect(last.role).toBe("reviewer");
|
||||
expect(last.content).toBe("rev-2");
|
||||
});
|
||||
|
||||
test("fork rejects unknown role with available names", async () => {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(bundlePath, threeRoleBundleSource, "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 sourceId = ran.value.threadId;
|
||||
const sourceData = join(storageRoot, "logs", added.value.hash, `${sourceId}.data.jsonl`);
|
||||
const sourceRunning = join(storageRoot, "logs", added.value.hash, `${sourceId}.running`);
|
||||
await waitUntilRunningAbsent(sourceRunning);
|
||||
await waitUntilMinDataLines(sourceData, 4);
|
||||
|
||||
const bad = await cmdFork(storageRoot, sourceId, "ghost-role");
|
||||
expect(bad.ok).toBe(false);
|
||||
if (bad.ok) {
|
||||
return;
|
||||
}
|
||||
expect(bad.error).toContain("ghost-role");
|
||||
expect(bad.error).toContain("planner");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { printCliError, printCliLine } from "./cli-output.js";
|
||||
import { cmdAdd, formatAddSuccess } from "./cmd-add.js";
|
||||
import { cmdFork, parseForkArgv } from "./cmd-fork.js";
|
||||
import { cmdHistory } from "./cmd-history.js";
|
||||
import { cmdKill } from "./cmd-kill.js";
|
||||
import { cmdList, formatListLines } from "./cmd-list.js";
|
||||
@@ -31,6 +32,7 @@ function usage(): string {
|
||||
" uncaged-workflow threads [name]",
|
||||
" uncaged-workflow thread <id>",
|
||||
" uncaged-workflow thread rm <id>",
|
||||
" uncaged-workflow fork <thread-id> [--from-role <role>]",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -258,6 +260,21 @@ async function dispatchThreadBranch(storageRoot: string, rest: string[]): Promis
|
||||
return dispatchThread(storageRoot, rest);
|
||||
}
|
||||
|
||||
async function dispatchFork(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseForkArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value.threadId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
|
||||
|
||||
const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
@@ -274,6 +291,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
resume: dispatchResume,
|
||||
threads: dispatchThreads,
|
||||
thread: dispatchThreadBranch,
|
||||
fork: dispatchFork,
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { buildForkPlan, err, generateUlid, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||
import { resolveThreadDataPath } from "./thread-scan.js";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "./worker-spawn.js";
|
||||
|
||||
export function parseForkArgv(
|
||||
argv: string[],
|
||||
): Result<{ threadId: string; fromRole: string | null }, string> {
|
||||
if (argv.length === 0) {
|
||||
return err("fork requires <thread-id>");
|
||||
}
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || threadId === "") {
|
||||
return err("fork requires <thread-id>");
|
||||
}
|
||||
let fromRole: string | null = null;
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--from-role") {
|
||||
const r = argv[i + 1];
|
||||
if (r === undefined || r === "") {
|
||||
return err("--from-role requires a role name");
|
||||
}
|
||||
fromRole = r;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
return err(`unexpected argument: ${a}`);
|
||||
}
|
||||
return ok({ threadId, fromRole });
|
||||
}
|
||||
|
||||
export async function cmdFork(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
fromRole: string | null,
|
||||
): Promise<Result<{ threadId: 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}`);
|
||||
}
|
||||
|
||||
const plan = buildForkPlan(text, fromRole);
|
||||
if (!plan.ok) {
|
||||
return plan;
|
||||
}
|
||||
|
||||
const bundlePath = join(storageRoot, "bundles", `${plan.value.hash}.esm.js`);
|
||||
if (!(await pathExists(bundlePath))) {
|
||||
return err(`bundle file missing for thread hash ${plan.value.hash}`);
|
||||
}
|
||||
|
||||
const worker = await ensureWorkerForHash(storageRoot, plan.value.hash, bundlePath);
|
||||
if (!worker.ok) {
|
||||
return worker;
|
||||
}
|
||||
|
||||
const newThreadId = generateUlid(Date.now());
|
||||
const stepsOnWire = plan.value.historicalSteps.map((s) => ({
|
||||
role: s.role,
|
||||
content: s.content,
|
||||
meta: s.meta,
|
||||
timestamp: s.timestamp,
|
||||
}));
|
||||
|
||||
const sent = await sendWorkerTcpCommand(
|
||||
worker.value.port,
|
||||
{
|
||||
type: "run",
|
||||
threadId: newThreadId,
|
||||
workflowName: plan.value.workflowName,
|
||||
prompt: plan.value.prompt,
|
||||
options: plan.value.runOptions,
|
||||
steps: stepsOnWire,
|
||||
forkSourceThreadId: plan.value.sourceThreadId,
|
||||
},
|
||||
{ awaitResponseLine: false },
|
||||
);
|
||||
if (!sent.ok) {
|
||||
return sent;
|
||||
}
|
||||
|
||||
return ok({ threadId: newThreadId });
|
||||
}
|
||||
Reference in New Issue
Block a user