refactor: WorkflowFn input → ThreadInput, remove threadId from bundle contract
- WorkflowFn first param is now ThreadInput { prompt, steps }
- threadId removed from WorkflowFnOptions and ThreadContext (engine-only)
- createRoleModerator seeds context from input.steps (fork/resume ready)
- New test: pre-filled steps skip already-completed roles
Closes #6
小橘 <xiaoju@shazhou.work>
This commit is contained in:
@@ -6,9 +6,9 @@ describe("validateWorkflowBundle", () => {
|
||||
test("accepts minimal valid builtin-only bundle", () => {
|
||||
const source = `import fs from "node:fs";
|
||||
|
||||
export default async function* run() {
|
||||
export default async function* (input) {
|
||||
fs.existsSync(".");
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
return { returnCode: 0, summary: input.prompt };
|
||||
}
|
||||
`;
|
||||
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
|
||||
@@ -18,7 +18,8 @@ export default async function* run() {
|
||||
test("rejects wrong filename suffix", () => {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.js",
|
||||
source: "export default async function* run() { return { returnCode: 0, summary: '' }; }\n",
|
||||
source:
|
||||
"export default async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
@@ -49,7 +50,7 @@ export default async function* run() {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.esm.js",
|
||||
source:
|
||||
'import x from "some-package";\nexport default async function* run() { return { returnCode: 0, summary: "" }; }\n',
|
||||
'import x from "some-package";\nexport default async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n',
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
@@ -58,7 +59,7 @@ export default async function* run() {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.esm.js",
|
||||
source:
|
||||
'export default async function* run() { await import("fs"); return { returnCode: 0, summary: "" }; }\n',
|
||||
'export default async function* (input) { await import("fs"); return { returnCode: 0, summary: input.prompt }; }\n',
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
@@ -70,7 +71,7 @@ export default async function* run() {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.esm.js",
|
||||
source:
|
||||
'export default async function* run() { require("fs"); return { returnCode: 0, summary: "" }; }\n',
|
||||
'export default async function* (input) { require("fs"); return { returnCode: 0, summary: input.prompt }; }\n',
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("executeThread", () => {
|
||||
const result = await executeThread(
|
||||
demoWorkflow,
|
||||
"demo-flow",
|
||||
"Fix the login redirect bug in #3",
|
||||
{ prompt: "Fix the login redirect bug in #3", steps: [] },
|
||||
{ isDryRun: false, maxRounds: 5, signal: ac.signal },
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath },
|
||||
logger,
|
||||
@@ -103,6 +103,53 @@ describe("executeThread", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("pre-filled ThreadInput.steps skips roles already present", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-fork-"));
|
||||
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,
|
||||
"demo-flow",
|
||||
{
|
||||
prompt: "continue from planner",
|
||||
steps: [
|
||||
{
|
||||
role: "planner",
|
||||
content: "plan-body",
|
||||
meta: { plan: "do-it", files: ["a.ts"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ 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(2);
|
||||
|
||||
const role1 = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(role1.role).toBe("coder");
|
||||
expect(role1.content).toBe("code-body");
|
||||
} 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 {
|
||||
@@ -118,7 +165,7 @@ describe("executeThread", () => {
|
||||
const result = await executeThread(
|
||||
demoWorkflow,
|
||||
"demo-flow",
|
||||
"hello",
|
||||
{ prompt: "hello", steps: [] },
|
||||
{ isDryRun: false, maxRounds: 0, signal: ac.signal },
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath },
|
||||
logger,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("hashWorkflowBundleBytes", () => {
|
||||
test("stable for identical content", () => {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(
|
||||
"export default async function* run() { return { returnCode: 0, summary: '' }; }\n",
|
||||
"export default async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n",
|
||||
);
|
||||
expect(hashWorkflowBundleBytes(data)).toBe(hashWorkflowBundleBytes(data));
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ import { join } from "node:path";
|
||||
|
||||
import { getWorkerHostScriptPath } from "../src/worker-entry-path.js";
|
||||
|
||||
const bundleSource = `export default async function* () {
|
||||
yield { role: "planner", content: "p", meta: { plan: "x" } };
|
||||
const bundleSource = `export default async function* (input) {
|
||||
yield { role: "planner", content: "p", meta: { plan: input.prompt } };
|
||||
yield { role: "coder", content: "c", meta: { diff: "y" } };
|
||||
return { returnCode: 0, summary: "completed: moderator returned END" };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
END,
|
||||
type RoleMeta,
|
||||
type RoleOutput,
|
||||
type RoleStep,
|
||||
START,
|
||||
type ThreadContext,
|
||||
type ThreadInput,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowFn,
|
||||
type WorkflowFnOptions,
|
||||
@@ -24,18 +26,24 @@ export function createRoleModerator<M extends RoleMeta>(
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||
): WorkflowFn {
|
||||
return async function* roleModeratorWorkflow(
|
||||
prompt: string,
|
||||
input: ThreadInput,
|
||||
options: WorkflowFnOptions,
|
||||
): AsyncGenerator<RoleOutput, WorkflowResult> {
|
||||
const nowMs = Date.now();
|
||||
const start: ThreadContext<M>["start"] = {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: { maxRounds: options.maxRounds, threadId: options.threadId },
|
||||
content: input.prompt,
|
||||
meta: { maxRounds: options.maxRounds },
|
||||
timestamp: nowMs,
|
||||
};
|
||||
|
||||
let steps: RoleStep<M>[] = [];
|
||||
const baseTs = Date.now();
|
||||
let steps: RoleStep<M>[] = input.steps.map((out, i) => ({
|
||||
role: out.role,
|
||||
content: out.content,
|
||||
meta: out.meta,
|
||||
timestamp: baseTs + i,
|
||||
})) as RoleStep<M>[];
|
||||
|
||||
while (true) {
|
||||
if (steps.length >= options.maxRounds) {
|
||||
@@ -46,7 +54,6 @@ export function createRoleModerator<M extends RoleMeta>(
|
||||
}
|
||||
|
||||
const ctx: ThreadContext<M> = {
|
||||
threadId: options.threadId,
|
||||
start,
|
||||
steps,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import type { LogFn } from "./logger.js";
|
||||
import type { WorkflowFn, WorkflowResult } from "./types.js";
|
||||
import type { ThreadInput, WorkflowFn, WorkflowResult } from "./types.js";
|
||||
|
||||
export type ExecuteThreadIo = {
|
||||
threadId: string;
|
||||
@@ -29,7 +29,7 @@ async function appendDataLine(path: string, record: unknown): Promise<void> {
|
||||
export async function executeThread(
|
||||
fn: WorkflowFn,
|
||||
workflowName: string,
|
||||
prompt: string,
|
||||
input: ThreadInput,
|
||||
options: ExecuteThreadOptions,
|
||||
io: ExecuteThreadIo,
|
||||
logger: LogFn,
|
||||
@@ -43,7 +43,7 @@ export async function executeThread(
|
||||
hash: io.hash,
|
||||
threadId: io.threadId,
|
||||
parameters: {
|
||||
prompt,
|
||||
prompt: input.prompt,
|
||||
options: {
|
||||
isDryRun: options.isDryRun,
|
||||
maxRounds: options.maxRounds,
|
||||
@@ -64,10 +64,9 @@ export async function executeThread(
|
||||
};
|
||||
}
|
||||
|
||||
const gen = fn(prompt, {
|
||||
const gen = fn(input, {
|
||||
isDryRun: options.isDryRun,
|
||||
maxRounds: options.maxRounds,
|
||||
threadId: io.threadId,
|
||||
});
|
||||
|
||||
let written = 0;
|
||||
|
||||
@@ -47,6 +47,7 @@ export {
|
||||
START,
|
||||
type StartStep,
|
||||
type ThreadContext,
|
||||
type ThreadInput,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowFn,
|
||||
type WorkflowFnOptions,
|
||||
|
||||
@@ -18,16 +18,21 @@ export type WorkflowResult = {
|
||||
summary: string;
|
||||
};
|
||||
|
||||
/** Input to a workflow — prompt plus optional historical steps for fork/resume. */
|
||||
export type ThreadInput = {
|
||||
prompt: string;
|
||||
steps: RoleOutput[];
|
||||
};
|
||||
|
||||
/** Options passed to a workflow bundle's default-export function (engine-provided). */
|
||||
export type WorkflowFnOptions = {
|
||||
isDryRun: boolean;
|
||||
maxRounds: number;
|
||||
threadId: string;
|
||||
};
|
||||
|
||||
/** Bundle contract — default export is a function returning an AsyncGenerator. */
|
||||
export type WorkflowFn = (
|
||||
prompt: string,
|
||||
input: ThreadInput,
|
||||
options: WorkflowFnOptions,
|
||||
) => AsyncGenerator<RoleOutput, WorkflowResult>;
|
||||
|
||||
@@ -41,7 +46,7 @@ export type RoleResult<Meta extends Record<string, unknown>> = {
|
||||
export type StartStep = {
|
||||
role: typeof START;
|
||||
content: string;
|
||||
meta: { maxRounds: number; threadId: string };
|
||||
meta: { maxRounds: number };
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
@@ -52,7 +57,6 @@ export type RoleStep<M extends RoleMeta> = {
|
||||
|
||||
/** Thread-scoped context passed to roles and moderator. */
|
||||
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
||||
threadId: string;
|
||||
start: StartStep;
|
||||
steps: RoleStep<M>[];
|
||||
};
|
||||
|
||||
@@ -228,7 +228,7 @@ async function main(): Promise<void> {
|
||||
await executeThread(
|
||||
workflowFn,
|
||||
cmd.workflowName,
|
||||
cmd.prompt,
|
||||
{ prompt: cmd.prompt, steps: [] },
|
||||
{ ...cmd.options, signal: ac.signal },
|
||||
io,
|
||||
logger,
|
||||
|
||||
Reference in New Issue
Block a user