@zhushanwen/pi-subagent-workflow 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/context-builder.md +17 -0
- package/agents/general-purpose.md +16 -0
- package/agents/oracle.md +17 -0
- package/agents/planner.md +17 -0
- package/agents/researcher.md +17 -0
- package/agents/reviewer.md +17 -0
- package/agents/scout.md +17 -0
- package/agents/worker.md +16 -0
- package/examples/README.md +43 -0
- package/examples/chain.example.js +92 -0
- package/examples/map-reduce.example.js +99 -0
- package/examples/parallel.example.js +82 -0
- package/examples/scatter-gather.example.js +106 -0
- package/index.ts +1 -0
- package/package.json +66 -0
- package/skills/workflow-script-format/SKILL.md +328 -0
- package/src/execution/__tests__/agent-registry.test.ts +164 -0
- package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
- package/src/execution/__tests__/alive-store.test.ts +147 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
- package/src/execution/__tests__/config.test.ts +110 -0
- package/src/execution/__tests__/crash-recovery.test.ts +311 -0
- package/src/execution/__tests__/execute-nesting.test.ts +359 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
- package/src/execution/__tests__/execution-record.test.ts +959 -0
- package/src/execution/__tests__/finalized-marker.test.ts +82 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
- package/src/execution/__tests__/format.test.ts +320 -0
- package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
- package/src/execution/__tests__/list-component.test.ts +347 -0
- package/src/execution/__tests__/model-resolver.test.ts +356 -0
- package/src/execution/__tests__/output-collector.test.ts +61 -0
- package/src/execution/__tests__/path-encoding.test.ts +75 -0
- package/src/execution/__tests__/pi-invocation.test.ts +73 -0
- package/src/execution/__tests__/record-store.test.ts +545 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
- package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
- package/src/execution/__tests__/sdk-contract.test.ts +272 -0
- package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
- package/src/execution/__tests__/session-file-gc.test.ts +247 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
- package/src/execution/__tests__/spawn-args.test.ts +244 -0
- package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
- package/src/execution/__tests__/subagent-service.test.ts +678 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
- package/src/execution/__tests__/temp-prompt.test.ts +53 -0
- package/src/execution/__tests__/timeout-integration.test.ts +381 -0
- package/src/execution/__tests__/tombstone-store.test.ts +73 -0
- package/src/execution/__tests__/tool-action.test.ts +330 -0
- package/src/execution/__tests__/turn-limiter.test.ts +65 -0
- package/src/execution/__tests__/worktree-manager.test.ts +423 -0
- package/src/execution/__tests__/worktree-registry.test.ts +161 -0
- package/src/execution/agent-registry.ts +252 -0
- package/src/execution/agent-result-mapper.ts +84 -0
- package/src/execution/alive-store.ts +92 -0
- package/src/execution/best-effort.ts +30 -0
- package/src/execution/concurrency-pool.ts +84 -0
- package/src/execution/config.ts +73 -0
- package/src/execution/execute-options-mapper.ts +86 -0
- package/src/execution/execution-record.ts +778 -0
- package/src/execution/finalized-marker.ts +51 -0
- package/src/execution/model-config-service.ts +225 -0
- package/src/execution/model-resolver.ts +247 -0
- package/src/execution/notifier.ts +168 -0
- package/src/execution/output-collector.ts +88 -0
- package/src/execution/path-encoding.ts +34 -0
- package/src/execution/pi-invocation.ts +70 -0
- package/src/execution/record-store.ts +350 -0
- package/src/execution/session-context-resolver.ts +64 -0
- package/src/execution/session-file-gc.ts +98 -0
- package/src/execution/session-reconstructor.ts +450 -0
- package/src/execution/session-runner.ts +725 -0
- package/src/execution/spawn-event-adapter.ts +150 -0
- package/src/execution/subagent-service.ts +973 -0
- package/src/execution/subprocess-agent-runner.ts +108 -0
- package/src/execution/temp-prompt.ts +57 -0
- package/src/execution/tombstone-store.ts +72 -0
- package/src/execution/turn-limiter.ts +88 -0
- package/src/execution/types.ts +634 -0
- package/src/execution/worktree-manager.ts +285 -0
- package/src/execution/worktree-registry.ts +144 -0
- package/src/index.ts +454 -0
- package/src/interface/bg-notify-render.ts +286 -0
- package/src/interface/commands.ts +157 -0
- package/src/interface/format.ts +501 -0
- package/src/interface/gui-adapter.ts +136 -0
- package/src/interface/helpers.ts +110 -0
- package/src/interface/list-component.ts +643 -0
- package/src/interface/list-shared.ts +84 -0
- package/src/interface/list-view.ts +373 -0
- package/src/interface/reentry-guard.ts +30 -0
- package/src/interface/subagent-actions.ts +294 -0
- package/src/interface/subagent-tool.ts +294 -0
- package/src/interface/subagents.ts +30 -0
- package/src/interface/tool-render.ts +333 -0
- package/src/interface/tool-workflow-script.ts +351 -0
- package/src/interface/tool-workflow.ts +485 -0
- package/src/interface/views/WorkflowsView.ts +944 -0
- package/src/interface/views/detail-content.ts +298 -0
- package/src/interface/views/format.ts +320 -0
- package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
- package/src/orchestration/__tests__/config-loader.test.ts +381 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
- package/src/orchestration/__tests__/script-lint.test.ts +347 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
- package/src/orchestration/agent-opts-resolver.ts +128 -0
- package/src/orchestration/concurrency-gate.ts +69 -0
- package/src/orchestration/config-loader.ts +313 -0
- package/src/orchestration/error-recovery.ts +578 -0
- package/src/orchestration/execute-agent-call.ts +174 -0
- package/src/orchestration/jsonl-run-store.ts +292 -0
- package/src/orchestration/launcher.ts +368 -0
- package/src/orchestration/lifecycle.ts +373 -0
- package/src/orchestration/models/__tests__/budget.test.ts +367 -0
- package/src/orchestration/models/agent-call.ts +76 -0
- package/src/orchestration/models/budget.ts +148 -0
- package/src/orchestration/models/ports.ts +165 -0
- package/src/orchestration/models/run-runtime.ts +91 -0
- package/src/orchestration/models/run-spec.ts +54 -0
- package/src/orchestration/models/run-state.ts +44 -0
- package/src/orchestration/models/trace.ts +102 -0
- package/src/orchestration/models/types.ts +242 -0
- package/src/orchestration/models/workflow-run.ts +275 -0
- package/src/orchestration/models/workflow-script-registry.ts +32 -0
- package/src/orchestration/models/workflow-script.ts +90 -0
- package/src/orchestration/node-ops.ts +192 -0
- package/src/orchestration/script-lint.ts +387 -0
- package/src/orchestration/skill-discovery.ts +60 -0
- package/src/orchestration/worker-handle.ts +115 -0
- package/src/orchestration/worker-host.ts +93 -0
- package/src/orchestration/worker-script-builder.ts +281 -0
- package/src/orchestration/workflow-files.ts +85 -0
- package/src/orchestration/workflow-script-registry-impl.ts +128 -0
- package/src/shared/__tests__/resource-discovery.test.ts +226 -0
- package/src/shared/agent-event.ts +13 -0
- package/src/shared/resource-discovery.ts +535 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* executeNestedWorkflow — workflow() 嵌套调用实现测试。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖 6 个场景:循环检测、registry 未找到、lint 失败、成功执行、budget 同步、
|
|
5
|
+
* 子 workflow 失败。
|
|
6
|
+
*
|
|
7
|
+
* 通过 vi.mock("../lifecycle.ts") 控制 runWorkflow:返回固定 runId + 把预构造的
|
|
8
|
+
* child run 注入 deps.runs,使 pollRunToResult 首轮即命中 done 返回(无需真实 worker)。
|
|
9
|
+
*/
|
|
10
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
import { Budget } from "../models/budget.ts";
|
|
13
|
+
import type { RunSpec } from "../models/run-spec.ts";
|
|
14
|
+
import type { LauncherDeps } from "../launcher.ts";
|
|
15
|
+
import type { LintResult } from "../script-lint.ts";
|
|
16
|
+
import type { WorkflowRun } from "../models/workflow-run.ts";
|
|
17
|
+
import type { WorkflowScript } from "../models/workflow-script.ts";
|
|
18
|
+
|
|
19
|
+
// ── module mock:lifecycle.runWorkflow 由各 test 配置 ──────────────────
|
|
20
|
+
|
|
21
|
+
vi.mock("../lifecycle.ts", () => ({
|
|
22
|
+
runWorkflow: vi.fn(),
|
|
23
|
+
abortRun: vi.fn(async () => {}),
|
|
24
|
+
pauseRun: vi.fn(async () => {}),
|
|
25
|
+
resumeRun: vi.fn(async () => {}),
|
|
26
|
+
scheduleTimeBudget: vi.fn(() => undefined),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// import 在 vi.mock 之后(hoisting 保证拿到 mock 版本)。runWorkflow 从被 mock 的
|
|
30
|
+
// lifecycle 模块导入,与 launcher.ts 内部引用的是同一 mock 实例。
|
|
31
|
+
import { runWorkflow } from "../lifecycle.ts";
|
|
32
|
+
import { executeNestedWorkflow } from "../launcher.ts";
|
|
33
|
+
|
|
34
|
+
const MOCK_RUN_ID = "wf-test-child";
|
|
35
|
+
|
|
36
|
+
// ── helpers ──────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/** 构造 mock WorkflowScript(validate / toExecutable 可控)。 */
|
|
39
|
+
function makeScript(opts: { valid?: boolean; lintErrorMsg?: string } = {}): WorkflowScript {
|
|
40
|
+
const valid = opts.valid ?? true;
|
|
41
|
+
return {
|
|
42
|
+
name: "child-wf",
|
|
43
|
+
path: "/fake/child-wf.js",
|
|
44
|
+
meta: { name: "child-wf", description: "child workflow", phases: [] },
|
|
45
|
+
toExecutable: () => "const meta = {}; execute() {}",
|
|
46
|
+
validate: (): LintResult => ({
|
|
47
|
+
valid,
|
|
48
|
+
findings: valid
|
|
49
|
+
? []
|
|
50
|
+
: [
|
|
51
|
+
{
|
|
52
|
+
severity: "error",
|
|
53
|
+
line: 3,
|
|
54
|
+
message: opts.lintErrorMsg ?? "lint boom",
|
|
55
|
+
suggestion: "fix it",
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
} as unknown as WorkflowScript;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 构造 mock parent WorkflowRun。
|
|
64
|
+
*
|
|
65
|
+
* parentBudget 用真实 Budget 实例(remaining() + 可写 usedTokens/usedCost)。
|
|
66
|
+
* controller 提供 AbortSignal(默认未 abort)。
|
|
67
|
+
*/
|
|
68
|
+
function makeParentRun(opts: {
|
|
69
|
+
scriptName?: string;
|
|
70
|
+
parentWorkflowChain?: readonly string[];
|
|
71
|
+
budget?: Budget;
|
|
72
|
+
aborted?: boolean;
|
|
73
|
+
} = {}): WorkflowRun {
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
if (opts.aborted) controller.abort();
|
|
76
|
+
const spec = {
|
|
77
|
+
scriptName: opts.scriptName ?? "parent-wf",
|
|
78
|
+
parentWorkflowChain: opts.parentWorkflowChain,
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
spec,
|
|
82
|
+
state: { budget: opts.budget ?? new Budget({ maxTokens: 10000 }) },
|
|
83
|
+
runtime: { controller },
|
|
84
|
+
} as unknown as WorkflowRun;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 构造 mock child WorkflowRun(done 终态,pollRunToResult 首轮命中)。
|
|
89
|
+
*
|
|
90
|
+
* childBudget 用普通对象(仅需 usedTokens/usedCost 供 budget 同步读取)。
|
|
91
|
+
*/
|
|
92
|
+
function makeDoneChildRun(opts: {
|
|
93
|
+
reason?: "completed" | "failed" | "aborted";
|
|
94
|
+
scriptResult?: unknown;
|
|
95
|
+
error?: string;
|
|
96
|
+
usedTokens?: number;
|
|
97
|
+
usedCost?: number;
|
|
98
|
+
}): WorkflowRun {
|
|
99
|
+
const reason = opts.reason ?? "completed";
|
|
100
|
+
return {
|
|
101
|
+
runId: MOCK_RUN_ID,
|
|
102
|
+
spec: { scriptName: "child-wf" },
|
|
103
|
+
state: {
|
|
104
|
+
status: "done",
|
|
105
|
+
reason,
|
|
106
|
+
scriptResult: opts.scriptResult,
|
|
107
|
+
error: opts.error,
|
|
108
|
+
budget: { usedTokens: opts.usedTokens ?? 0, usedCost: opts.usedCost ?? 0 },
|
|
109
|
+
},
|
|
110
|
+
} as unknown as WorkflowRun;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 构造 LauncherDeps mock:registry + runs(Map)+ 占位 port。 */
|
|
114
|
+
function makeDeps(opts: {
|
|
115
|
+
script?: WorkflowScript;
|
|
116
|
+
childRun?: WorkflowRun;
|
|
117
|
+
registry?: { get: ReturnType<typeof vi.fn> };
|
|
118
|
+
} = {}): LauncherDeps {
|
|
119
|
+
const runs = new Map<string, WorkflowRun>();
|
|
120
|
+
if (opts.childRun) runs.set(MOCK_RUN_ID, opts.childRun);
|
|
121
|
+
const registry = opts.registry ?? {
|
|
122
|
+
get: vi.fn(async () => opts.script),
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
registry,
|
|
126
|
+
runs,
|
|
127
|
+
store: { save: vi.fn(async () => {}), loadAll: vi.fn(async () => []) },
|
|
128
|
+
workerHost: { start: vi.fn(() => ({ postMessage: vi.fn() })) },
|
|
129
|
+
runner: { run: vi.fn(async () => ({})) },
|
|
130
|
+
log: vi.fn(),
|
|
131
|
+
eventBus: { emit: vi.fn() },
|
|
132
|
+
} as unknown as LauncherDeps;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 配置 runWorkflow mock:把 childRun 注入 deps.runs 并返回 MOCK_RUN_ID。 */
|
|
136
|
+
function setupRunWorkflow(childRun: WorkflowRun): void {
|
|
137
|
+
vi.mocked(runWorkflow).mockImplementation(async (_spec, deps) => {
|
|
138
|
+
deps.runs.set(MOCK_RUN_ID, childRun);
|
|
139
|
+
return MOCK_RUN_ID;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
vi.clearAllMocks();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── tests ────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
describe("executeNestedWorkflow", () => {
|
|
150
|
+
it("returns error result when workflow not found", async () => {
|
|
151
|
+
const parent = makeParentRun();
|
|
152
|
+
const deps = makeDeps({ script: undefined });
|
|
153
|
+
|
|
154
|
+
const result = await executeNestedWorkflow("missing", {}, parent, deps);
|
|
155
|
+
|
|
156
|
+
expect(result.error).toBe("Workflow 'missing' not found");
|
|
157
|
+
expect(result.content).toBe("");
|
|
158
|
+
expect(runWorkflow).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns error result on lint failure", async () => {
|
|
162
|
+
const parent = makeParentRun();
|
|
163
|
+
const deps = makeDeps({ script: makeScript({ valid: false, lintErrorMsg: "no entry point" }) });
|
|
164
|
+
|
|
165
|
+
const result = await executeNestedWorkflow("child-wf", {}, parent, deps);
|
|
166
|
+
|
|
167
|
+
expect(result.error).toContain("has lint errors");
|
|
168
|
+
expect(result.error).toContain("no entry point");
|
|
169
|
+
expect(result.content).toBe("");
|
|
170
|
+
expect(runWorkflow).not.toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("detects circular call chain", async () => {
|
|
174
|
+
// parent chain ["a"], parent scriptName "b", target name "a" → a→b→a 循环
|
|
175
|
+
const parent = makeParentRun({
|
|
176
|
+
scriptName: "b",
|
|
177
|
+
parentWorkflowChain: ["a"],
|
|
178
|
+
});
|
|
179
|
+
const deps = makeDeps({ script: makeScript() });
|
|
180
|
+
|
|
181
|
+
const result = await executeNestedWorkflow("a", {}, parent, deps);
|
|
182
|
+
|
|
183
|
+
expect(result.error).toContain("Circular workflow call detected");
|
|
184
|
+
expect(result.error).toContain("a → b → a");
|
|
185
|
+
expect(result.content).toBe("");
|
|
186
|
+
expect(runWorkflow).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("executes child workflow and returns result on success", async () => {
|
|
190
|
+
const parent = makeParentRun();
|
|
191
|
+
const childRun = makeDoneChildRun({
|
|
192
|
+
reason: "completed",
|
|
193
|
+
scriptResult: { summary: "done" },
|
|
194
|
+
});
|
|
195
|
+
const deps = makeDeps({ script: makeScript(), childRun });
|
|
196
|
+
setupRunWorkflow(childRun);
|
|
197
|
+
|
|
198
|
+
const result = await executeNestedWorkflow("child-wf", { k: 1 }, parent, deps);
|
|
199
|
+
|
|
200
|
+
// content = JSON.stringify(scriptResult);parsedOutput 原样回传对象
|
|
201
|
+
expect(result.error).toBeUndefined();
|
|
202
|
+
expect(result.content).toBe(JSON.stringify({ summary: "done" }));
|
|
203
|
+
expect(result.parsedOutput).toEqual({ summary: "done" });
|
|
204
|
+
expect(runWorkflow).toHaveBeenCalledTimes(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("shares parent budget reference with child run (no sync-back)", async () => {
|
|
208
|
+
const parentBudget = new Budget({ maxTokens: 10000 });
|
|
209
|
+
parentBudget.usedTokens = 100;
|
|
210
|
+
const parent = makeParentRun({ budget: parentBudget });
|
|
211
|
+
const childRun = makeDoneChildRun({
|
|
212
|
+
reason: "completed",
|
|
213
|
+
scriptResult: "ok",
|
|
214
|
+
});
|
|
215
|
+
const deps = makeDeps({ script: makeScript(), childRun });
|
|
216
|
+
|
|
217
|
+
// 捕获传给 runWorkflow 的 spec——验证 budgetRef 共享父 Budget 引用
|
|
218
|
+
let capturedSpec: RunSpec | undefined;
|
|
219
|
+
vi.mocked(runWorkflow).mockImplementation(async (spec, d) => {
|
|
220
|
+
capturedSpec = spec;
|
|
221
|
+
d.runs.set(MOCK_RUN_ID, childRun);
|
|
222
|
+
return MOCK_RUN_ID;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await executeNestedWorkflow("child-wf", {}, parent, deps);
|
|
226
|
+
|
|
227
|
+
// 子 run 直接复用父 Budget 引用(budgetRef),而非独立 Budget + sync-back
|
|
228
|
+
expect(capturedSpec?.budgetRef).toBe(parentBudget);
|
|
229
|
+
expect(capturedSpec?.budgetTokens).toBeUndefined();
|
|
230
|
+
// 无 sync-back:parent budget 不被 launcher 直接修改(mock 未真实 consume)
|
|
231
|
+
expect(parent.state.budget.usedTokens).toBe(100);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("returns error result when child workflow fails", async () => {
|
|
235
|
+
const parent = makeParentRun();
|
|
236
|
+
const childRun = makeDoneChildRun({
|
|
237
|
+
reason: "failed",
|
|
238
|
+
error: "agent exploded",
|
|
239
|
+
});
|
|
240
|
+
const deps = makeDeps({ script: makeScript(), childRun });
|
|
241
|
+
setupRunWorkflow(childRun);
|
|
242
|
+
|
|
243
|
+
const result = await executeNestedWorkflow("child-wf", {}, parent, deps);
|
|
244
|
+
|
|
245
|
+
expect(result.content).toBe("");
|
|
246
|
+
expect(result.error).toBe("agent exploded");
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lifecycle — pauseRun/resumeRun/scheduleTimeBudget/runWorkflow/abortRun 测试。
|
|
3
|
+
*
|
|
4
|
+
* 用真实 WorkflowRun(含真实状态机 + I1/I2 不变式守卫)+ mock RunRuntime(释放副作用
|
|
5
|
+
* 可控)+ mock LifecycleDeps(store/workerHost/eventBus 可观察)。这样能真正测到
|
|
6
|
+
* transition/assignRuntime/releaseRuntime 的状态机逻辑,而非全 mock 聚合根。
|
|
7
|
+
*
|
|
8
|
+
* 覆盖:
|
|
9
|
+
* - pauseRun:running → paused(releaseRuntime:worker terminate + controller abort)
|
|
10
|
+
* + 在飞 call 清理 + store.save
|
|
11
|
+
* - resumeRun:paused → running(workerHost.start 重建 + assignRuntime + 时间预算重排)
|
|
12
|
+
* - scheduleTimeBudget:定时器到期 → abortRun(done,time_limited)(用 fake timers)
|
|
13
|
+
* - runWorkflow:spec → 创建 run + workerHost.start + store.save + emit pending:register
|
|
14
|
+
* - abortRun:done no-op / running→done / paused→done + emit pending:unregister
|
|
15
|
+
*/
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
abortRun,
|
|
20
|
+
pauseRun,
|
|
21
|
+
resumeRun,
|
|
22
|
+
runWorkflow,
|
|
23
|
+
scheduleTimeBudget,
|
|
24
|
+
} from "../lifecycle.ts";
|
|
25
|
+
import { Budget } from "../models/budget.ts";
|
|
26
|
+
import { RunRuntime } from "../models/run-runtime.ts";
|
|
27
|
+
import { Trace } from "../models/trace.ts";
|
|
28
|
+
import type { RunSpec } from "../models/run-spec.ts";
|
|
29
|
+
import type { LifecycleDeps } from "../models/ports.ts";
|
|
30
|
+
import { WorkflowRun } from "../models/workflow-run.ts";
|
|
31
|
+
|
|
32
|
+
// ── helpers ──────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** 构造一个最小 RunSpec(满足 WorkflowRun 构造的字段需求)。 */
|
|
35
|
+
function makeSpec(opts: { budgetTimeMs?: number; budgetTokens?: number } = {}): RunSpec {
|
|
36
|
+
return {
|
|
37
|
+
scriptSource: "execute() {}",
|
|
38
|
+
args: {},
|
|
39
|
+
scriptName: "test-wf",
|
|
40
|
+
scriptPath: "/fake/test.js",
|
|
41
|
+
budgetTimeMs: opts.budgetTimeMs,
|
|
42
|
+
budgetTokens: opts.budgetTokens,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 构造一个 status="running" 的真实 WorkflowRun,注入 mock RunRuntime。
|
|
48
|
+
*
|
|
49
|
+
* 流程:new WorkflowRun(status=paused)→ assignRuntime(→running)。
|
|
50
|
+
* mock runtime 的 worker.terminate / controller.abort / release 均可观察。
|
|
51
|
+
*/
|
|
52
|
+
/** flush microtask 队列多次,让 void .then().catch() + async 链跑完。
|
|
53
|
+
*
|
|
54
|
+
* 注意:fake timers 下 setTimeout(resolve,0) 也会被拦截,故用 Promise.resolve()
|
|
55
|
+
* 走原生 microtask 队列(不被 fake timers 拦截)。 */
|
|
56
|
+
async function flushMicrotasks(times = 10): Promise<void> {
|
|
57
|
+
for (let i = 0; i < times; i++) {
|
|
58
|
+
await Promise.resolve();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeRunningRealRun(
|
|
63
|
+
runId: string,
|
|
64
|
+
opts: { budgetTimeMs?: number } = {},
|
|
65
|
+
): { run: WorkflowRun; terminate: ReturnType<typeof vi.fn>; abort: ReturnType<typeof vi.fn> } {
|
|
66
|
+
const spec = makeSpec(opts);
|
|
67
|
+
const run = new WorkflowRun(
|
|
68
|
+
runId,
|
|
69
|
+
spec,
|
|
70
|
+
{
|
|
71
|
+
status: "paused",
|
|
72
|
+
budget: new Budget({ maxTokens: 1000 }),
|
|
73
|
+
calls: new Map(),
|
|
74
|
+
trace: new Trace(),
|
|
75
|
+
errorLogs: [],
|
|
76
|
+
},
|
|
77
|
+
{ startedAt: new Date().toISOString() },
|
|
78
|
+
);
|
|
79
|
+
const terminate = vi.fn(async () => {});
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
const abort = vi.spyOn(controller, "abort");
|
|
82
|
+
const worker = { terminate, postMessage: vi.fn() } as unknown as Parameters<typeof RunRuntime.prototype.constructor>[0];
|
|
83
|
+
const runtime = new RunRuntime(
|
|
84
|
+
worker as never,
|
|
85
|
+
{ withSlot: vi.fn() } as never,
|
|
86
|
+
controller,
|
|
87
|
+
undefined,
|
|
88
|
+
);
|
|
89
|
+
run.assignRuntime(runtime);
|
|
90
|
+
return { run, terminate, abort };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** LifecycleDeps mock:store/workerHost/eventBus/onRunDone/log 可观察。 */
|
|
94
|
+
function makeDeps(): LifecycleDeps & {
|
|
95
|
+
store: { save: ReturnType<typeof vi.fn>; loadAll: ReturnType<typeof vi.fn> };
|
|
96
|
+
workerHost: { start: ReturnType<typeof vi.fn> };
|
|
97
|
+
eventBus: { emit: ReturnType<typeof vi.fn> };
|
|
98
|
+
onRunDone: ReturnType<typeof vi.fn>;
|
|
99
|
+
log: ReturnType<typeof vi.fn>;
|
|
100
|
+
} {
|
|
101
|
+
return {
|
|
102
|
+
store: { save: vi.fn(async () => {}), loadAll: vi.fn(async () => []) },
|
|
103
|
+
workerHost: { start: vi.fn(() => ({ postMessage: vi.fn(), terminate: vi.fn(async () => {}) })) },
|
|
104
|
+
runner: { run: vi.fn(async () => ({})) },
|
|
105
|
+
runs: new Map(),
|
|
106
|
+
eventBus: { emit: vi.fn() },
|
|
107
|
+
onRunDone: vi.fn(),
|
|
108
|
+
log: vi.fn(),
|
|
109
|
+
} as unknown as ReturnType<typeof makeDeps>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
vi.useFakeTimers();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
vi.useRealTimers();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── pauseRun ─────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
describe("pauseRun", () => {
|
|
123
|
+
it("running run → paused:releaseRuntime(worker terminate + controller abort)+ store.save", async () => {
|
|
124
|
+
const { run, terminate, abort } = makeRunningRealRun("wf-pause-1");
|
|
125
|
+
const deps = makeDeps();
|
|
126
|
+
deps.runs.set("wf-pause-1", run);
|
|
127
|
+
|
|
128
|
+
await pauseRun("wf-pause-1", deps);
|
|
129
|
+
|
|
130
|
+
expect(run.state.status).toBe("paused");
|
|
131
|
+
expect(run.meta.pausedAt).toBeDefined();
|
|
132
|
+
// releaseRuntime 释放了 worker + controller
|
|
133
|
+
expect(terminate).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(abort).toHaveBeenCalledTimes(1);
|
|
135
|
+
// runtime 已解绑(I1:paused ⟺ runtime undefined)
|
|
136
|
+
expect(run.runtime).toBeUndefined();
|
|
137
|
+
// 持久化
|
|
138
|
+
expect(deps.store.save).toHaveBeenCalledTimes(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("清留在飞 call(status !== done)及其 trace 节点", async () => {
|
|
142
|
+
const { run } = makeRunningRealRun("wf-pause-2");
|
|
143
|
+
// 注入一个未完成的在飞 call
|
|
144
|
+
run.state.calls.set(7, {
|
|
145
|
+
id: 7,
|
|
146
|
+
status: "running",
|
|
147
|
+
attempts: 1,
|
|
148
|
+
} as never);
|
|
149
|
+
// 注入一个已完成 call(应保留)
|
|
150
|
+
run.state.calls.set(8, {
|
|
151
|
+
id: 8,
|
|
152
|
+
status: "done",
|
|
153
|
+
result: { content: "ok" },
|
|
154
|
+
} as never);
|
|
155
|
+
const deps = makeDeps();
|
|
156
|
+
deps.runs.set("wf-pause-2", run);
|
|
157
|
+
|
|
158
|
+
await pauseRun("wf-pause-2", deps);
|
|
159
|
+
|
|
160
|
+
// 在飞 call(running)被移除;已完成 call(done)保留
|
|
161
|
+
expect(run.state.calls.has(7)).toBe(false);
|
|
162
|
+
expect(run.state.calls.has(8)).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("runId 不存在 → 抛错", async () => {
|
|
166
|
+
const deps = makeDeps();
|
|
167
|
+
await expect(pauseRun("wf-missing", deps)).rejects.toThrow("not found");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("status !== running → 抛错(只允许 running 暂停)", async () => {
|
|
171
|
+
const { run } = makeRunningRealRun("wf-pause-3");
|
|
172
|
+
run.transition("paused"); // 先 pause → paused
|
|
173
|
+
const deps = makeDeps();
|
|
174
|
+
deps.runs.set("wf-pause-3", run);
|
|
175
|
+
|
|
176
|
+
await expect(pauseRun("wf-pause-3", deps)).rejects.toThrow("only 'running' can be paused");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── resumeRun ────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
describe("resumeRun", () => {
|
|
183
|
+
it("paused → running:workerHost.start 重建 worker + assignRuntime + 时间预算重排", async () => {
|
|
184
|
+
const { run } = makeRunningRealRun("wf-resume-1", { budgetTimeMs: 5000 });
|
|
185
|
+
run.transition("paused"); // → paused
|
|
186
|
+
const deps = makeDeps();
|
|
187
|
+
deps.runs.set("wf-resume-1", run);
|
|
188
|
+
|
|
189
|
+
await resumeRun("wf-resume-1", deps);
|
|
190
|
+
|
|
191
|
+
expect(run.state.status).toBe("running");
|
|
192
|
+
expect(run.runtime).toBeDefined(); // assignRuntime 绑定新 runtime
|
|
193
|
+
// workerHost.start 被调(重建 worker)
|
|
194
|
+
expect(deps.workerHost.start).toHaveBeenCalledTimes(1);
|
|
195
|
+
// 时间预算重排(budgetTimeMs > 0):resumeRun 内调本文件 scheduleTimeBudget,
|
|
196
|
+
// 结果存入 run.runtime.timeBudgetTimer。budgetTimeMs <= 0 时为 undefined。
|
|
197
|
+
expect(run.runtime!.timeBudgetTimer).toBeDefined();
|
|
198
|
+
// 持久化
|
|
199
|
+
expect(deps.store.save).toHaveBeenCalledTimes(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("无 budgetTimeMs 时 resume 不调度时间预算计时器", async () => {
|
|
203
|
+
const { run } = makeRunningRealRun("wf-resume-no-budget");
|
|
204
|
+
run.transition("paused");
|
|
205
|
+
const deps = makeDeps();
|
|
206
|
+
deps.runs.set("wf-resume-no-budget", run);
|
|
207
|
+
|
|
208
|
+
await resumeRun("wf-resume-no-budget", deps);
|
|
209
|
+
|
|
210
|
+
expect(run.state.status).toBe("running");
|
|
211
|
+
// budgetTimeMs 未设 → timeBudgetTimer 为 undefined
|
|
212
|
+
expect(run.runtime!.timeBudgetTimer).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("runId 不存在 → 抛错", async () => {
|
|
216
|
+
const deps = makeDeps();
|
|
217
|
+
await expect(resumeRun("wf-missing", deps)).rejects.toThrow("not found");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("status !== paused → 抛错(只允许 paused 恢复)", async () => {
|
|
221
|
+
const { run } = makeRunningRealRun("wf-resume-2");
|
|
222
|
+
// 仍 running
|
|
223
|
+
const deps = makeDeps();
|
|
224
|
+
deps.runs.set("wf-resume-2", run);
|
|
225
|
+
|
|
226
|
+
await expect(resumeRun("wf-resume-2", deps)).rejects.toThrow("only 'paused' can be resumed");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── scheduleTimeBudget ───────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
describe("scheduleTimeBudget", () => {
|
|
233
|
+
it("定时器到期 → abortRun(done,time_limited)", async () => {
|
|
234
|
+
const { run } = makeRunningRealRun("wf-budget-1");
|
|
235
|
+
const deps = makeDeps();
|
|
236
|
+
deps.runs.set("wf-budget-1", run);
|
|
237
|
+
|
|
238
|
+
const timer = scheduleTimeBudget("wf-budget-1", deps, 1000);
|
|
239
|
+
expect(timer).toBeDefined();
|
|
240
|
+
|
|
241
|
+
// 推进定时器到到期 + flush 让 abortRun async 链跑完
|
|
242
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
243
|
+
await flushMicrotasks();
|
|
244
|
+
|
|
245
|
+
expect(run.state.status).toBe("done");
|
|
246
|
+
expect(run.state.reason).toBe("time_limited");
|
|
247
|
+
expect(run.state.error).toContain("Time budget exceeded");
|
|
248
|
+
// 完成通知
|
|
249
|
+
expect(deps.eventBus.emit).toHaveBeenCalledWith("pending:unregister", {
|
|
250
|
+
id: "wf-budget-1",
|
|
251
|
+
reason: "time_limited",
|
|
252
|
+
});
|
|
253
|
+
expect(deps.onRunDone).toHaveBeenCalledTimes(1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("run 已 done 时 abortRun no-op(到期不重复 transition)", async () => {
|
|
257
|
+
const { run } = makeRunningRealRun("wf-budget-2");
|
|
258
|
+
// 先把 run 转 done(手动)
|
|
259
|
+
run.transition("done", "completed");
|
|
260
|
+
const deps = makeDeps();
|
|
261
|
+
deps.runs.set("wf-budget-2", run);
|
|
262
|
+
|
|
263
|
+
scheduleTimeBudget("wf-budget-2", deps, 500);
|
|
264
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
265
|
+
await flushMicrotasks();
|
|
266
|
+
|
|
267
|
+
// 状态保持原 done/completed,未被 time_limited 覆盖
|
|
268
|
+
expect(run.state.reason).toBe("completed");
|
|
269
|
+
expect(deps.onRunDone).not.toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("定时器 unref(不阻止 Node 退出)", () => {
|
|
273
|
+
const deps = makeDeps();
|
|
274
|
+
const timer = scheduleTimeBudget("wf-x", deps, 10000);
|
|
275
|
+
// unref 是 Node timer 的方法,fake timer 也支持——验证不抛错
|
|
276
|
+
expect(() => timer.unref()).not.toThrow();
|
|
277
|
+
timer.unref();
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ── runWorkflow ──────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
describe("runWorkflow", () => {
|
|
284
|
+
it("spec → 创建 run + 启动 worker + store.save + emit pending:register", async () => {
|
|
285
|
+
const deps = makeDeps();
|
|
286
|
+
const spec = makeSpec();
|
|
287
|
+
|
|
288
|
+
const runId = await runWorkflow(spec, deps);
|
|
289
|
+
|
|
290
|
+
expect(runId).toMatch(/^wf-/);
|
|
291
|
+
// run 注册到 deps.runs
|
|
292
|
+
expect(deps.runs.has(runId)).toBe(true);
|
|
293
|
+
const run = deps.runs.get(runId)!;
|
|
294
|
+
// status 为 running(assignRuntime 已绑定 runtime)
|
|
295
|
+
expect(run.state.status).toBe("running");
|
|
296
|
+
expect(run.runtime).toBeDefined();
|
|
297
|
+
// workerHost.start 被调
|
|
298
|
+
expect(deps.workerHost.start).toHaveBeenCalledTimes(1);
|
|
299
|
+
// store.save 持久化
|
|
300
|
+
expect(deps.store.save).toHaveBeenCalledTimes(1);
|
|
301
|
+
// pending:register 通知
|
|
302
|
+
expect(deps.eventBus.emit).toHaveBeenCalledWith("pending:register", {
|
|
303
|
+
id: runId,
|
|
304
|
+
type: "workflow",
|
|
305
|
+
name: "test-wf",
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("带 budgetTimeMs 时调度时间预算计时器", async () => {
|
|
310
|
+
const deps = makeDeps();
|
|
311
|
+
const scheduleTimeBudgetSpy = vi.fn(() => undefined);
|
|
312
|
+
(deps as LifecycleDeps & { scheduleTimeBudget?: unknown }).scheduleTimeBudget = scheduleTimeBudgetSpy;
|
|
313
|
+
const spec = makeSpec({ budgetTimeMs: 3000 });
|
|
314
|
+
|
|
315
|
+
const runId = await runWorkflow(spec, deps);
|
|
316
|
+
|
|
317
|
+
// scheduleTimeBudget 在 lifecycle 内被调(runWorkflow 内联调,非走 deps.scheduleTimeBudget)
|
|
318
|
+
// 注意:runWorkflow 内直接调本文件的 scheduleTimeBudget,不读 deps.scheduleTimeBudget
|
|
319
|
+
expect(runId).toMatch(/^wf-/);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("signal 已 abort → fail fast(抛错,不创建 run)", async () => {
|
|
323
|
+
const deps = makeDeps();
|
|
324
|
+
const spec = makeSpec();
|
|
325
|
+
const controller = new AbortController();
|
|
326
|
+
controller.abort();
|
|
327
|
+
|
|
328
|
+
await expect(runWorkflow(spec, deps, controller.signal)).rejects.toThrow(
|
|
329
|
+
"aborted before start",
|
|
330
|
+
);
|
|
331
|
+
expect(deps.runs.size).toBe(0);
|
|
332
|
+
expect(deps.workerHost.start).not.toHaveBeenCalled();
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ── abortRun ─────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
describe("abortRun", () => {
|
|
339
|
+
it("running run → done,aborted:releaseRuntime + emit pending:unregister", async () => {
|
|
340
|
+
const { run, terminate } = makeRunningRealRun("wf-abort-1");
|
|
341
|
+
const deps = makeDeps();
|
|
342
|
+
deps.runs.set("wf-abort-1", run);
|
|
343
|
+
|
|
344
|
+
await abortRun("wf-abort-1", deps, "user cancelled");
|
|
345
|
+
|
|
346
|
+
expect(run.state.status).toBe("done");
|
|
347
|
+
expect(run.state.reason).toBe("aborted");
|
|
348
|
+
expect(run.state.error).toBe("user cancelled");
|
|
349
|
+
expect(terminate).toHaveBeenCalledTimes(1);
|
|
350
|
+
expect(run.runtime).toBeUndefined();
|
|
351
|
+
expect(deps.eventBus.emit).toHaveBeenCalledWith("pending:unregister", {
|
|
352
|
+
id: "wf-abort-1",
|
|
353
|
+
reason: "aborted",
|
|
354
|
+
});
|
|
355
|
+
expect(deps.onRunDone).toHaveBeenCalledTimes(1);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("done 状态 no-op(不重复 abort)", async () => {
|
|
359
|
+
const { run } = makeRunningRealRun("wf-abort-2");
|
|
360
|
+
run.transition("done", "completed");
|
|
361
|
+
const deps = makeDeps();
|
|
362
|
+
deps.runs.set("wf-abort-2", run);
|
|
363
|
+
|
|
364
|
+
await abortRun("wf-abort-2", deps, "late abort");
|
|
365
|
+
|
|
366
|
+
expect(run.state.reason).toBe("completed"); // 未被覆盖
|
|
367
|
+
expect(deps.onRunDone).not.toHaveBeenCalled();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("自定义 doneReason(time_limited)", async () => {
|
|
371
|
+
const { run } = makeRunningRealRun("wf-abort-3");
|
|
372
|
+
const deps = makeDeps();
|
|
373
|
+
deps.runs.set("wf-abort-3", run);
|
|
374
|
+
|
|
375
|
+
await abortRun("wf-abort-3", deps, "timeout", "time_limited");
|
|
376
|
+
|
|
377
|
+
expect(run.state.reason).toBe("time_limited");
|
|
378
|
+
expect(run.state.error).toBe("timeout");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("runId 不存在 → 抛错", async () => {
|
|
382
|
+
const deps = makeDeps();
|
|
383
|
+
await expect(abortRun("wf-missing", deps)).rejects.toThrow("not found");
|
|
384
|
+
});
|
|
385
|
+
});
|