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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user