@zhushanwen/pi-subagent-workflow 5.0.2 → 7.0.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/{reviewer.md → code-reviewer.md} +20 -3
- package/agents/context-builder.md +5 -0
- package/agents/doc-reviewer.md +9 -2
- package/agents/explorer.md +5 -0
- package/agents/general-purpose.md +5 -0
- package/agents/oracle.md +20 -4
- package/agents/orchestrator.md +6 -1
- package/agents/planner.md +5 -0
- package/agents/researcher.md +5 -0
- package/agents/worker.md +5 -0
- package/package.json +6 -4
- package/src/execution/__tests__/agent-registry.test.ts +189 -119
- package/src/execution/__tests__/crash-recovery.test.ts +0 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +4 -4
- package/src/execution/__tests__/index-session-start.test.ts +0 -1
- package/src/execution/__tests__/model-resolver.test.ts +20 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +0 -2
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +1 -1
- package/src/execution/agent-registry.ts +92 -169
- package/src/execution/execute-options-mapper.ts +2 -2
- package/src/execution/model-config-service.ts +13 -34
- package/src/execution/model-resolver.ts +5 -3
- package/src/execution/subagent-service.ts +9 -6
- package/src/execution/subprocess-agent-runner.ts +3 -2
- package/src/index.ts +4 -25
- package/src/injectors/__tests__/subagent-list-injector.test.ts +266 -14
- package/src/injectors/__tests__/workflow-list-injector.test.ts +236 -32
- package/src/injectors/subagent-list-injector.ts +99 -48
- package/src/injectors/workflow-list-injector.ts +65 -50
- package/src/interface/__tests__/detectors.test.ts +100 -43
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +8 -12
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +163 -0
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +55 -9
- package/src/interface/subagent-tool.ts +7 -4
- package/src/interface/tool-workflow-script.ts +27 -10
- package/src/interface/tool-workflow.ts +174 -81
- package/src/orchestration/__tests__/args-validator.test.ts +143 -0
- package/src/orchestration/__tests__/config-loader.test.ts +124 -40
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +33 -2
- package/src/orchestration/__tests__/lifecycle.test.ts +59 -2
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +116 -51
- package/src/orchestration/__tests__/script-lint.test.ts +167 -1
- package/src/orchestration/__tests__/worker-host.test.ts +120 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +69 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +51 -1
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +2 -2
- package/src/orchestration/__tests__/workflows-e2e.test.ts +177 -24
- package/src/orchestration/agent-opts-resolver.ts +51 -101
- package/src/orchestration/args-validator.ts +127 -0
- package/src/orchestration/config-loader.ts +63 -94
- package/src/orchestration/error-recovery.ts +6 -9
- package/src/orchestration/launcher.ts +77 -41
- package/src/orchestration/lifecycle.ts +7 -0
- package/src/orchestration/models/ports.ts +0 -14
- package/src/orchestration/models/run-spec.ts +24 -1
- package/src/orchestration/models/types.ts +15 -8
- package/src/orchestration/models/workflow-script-registry.ts +3 -0
- package/src/orchestration/models/workflow-script.ts +10 -14
- package/src/orchestration/script-lint.ts +159 -0
- package/src/orchestration/worker-host.ts +5 -0
- package/src/orchestration/worker-script-builder.ts +15 -4
- package/src/orchestration/workflow-script-registry-impl.ts +34 -29
- package/src/shared/__tests__/meta-parser.test.ts +304 -0
- package/src/shared/__tests__/resource-discovery.test.ts +167 -7
- package/src/shared/__tests__/resource-meta.test.ts +51 -0
- package/src/shared/agent-ref.ts +36 -0
- package/src/shared/meta-parser.ts +257 -0
- package/src/shared/resource-discovery.ts +88 -2
- package/src/shared/resource-meta.ts +60 -0
- package/workflows/README.md +2 -2
- package/workflows/_shared/agent-refs.cjs +40 -0
- package/workflows/chain.js +30 -6
- package/workflows/map-reduce.js +33 -5
- package/workflows/parallel.js +34 -7
- package/workflows/review-fix-loop-utils.cjs +23 -103
- package/workflows/review-fix-loop.js +121 -58
- package/workflows/scatter-gather.js +30 -6
|
@@ -14,6 +14,13 @@ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
|
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
|
|
17
|
+
// homedir 只影响 normalizeRef 的 `~/` 展开(getWorkflowByPath 路径),
|
|
18
|
+
// mock 成可写临时目录(测试内动态改),其余 node:os 保持真实。
|
|
19
|
+
vi.mock("node:os", async (importOriginal) => {
|
|
20
|
+
const actual = await importOriginal<typeof import("node:os")>();
|
|
21
|
+
return { ...actual, homedir: vi.fn(() => "/nonexistent-home-for-tests") };
|
|
22
|
+
});
|
|
23
|
+
|
|
17
24
|
// resource-discovery 位于 src/shared/(从 __tests__/ 看是 ../../shared/)。
|
|
18
25
|
// 只覆盖 findWorkspaceRoot;其余(discoverResources 等)保持真实实现。
|
|
19
26
|
vi.mock("../../shared/resource-discovery.ts", async (importOriginal) => {
|
|
@@ -29,12 +36,15 @@ vi.mock("../../shared/resource-discovery.ts", async (importOriginal) => {
|
|
|
29
36
|
import {
|
|
30
37
|
discoverWorkflows,
|
|
31
38
|
getWorkflow,
|
|
39
|
+
getWorkflowByPath,
|
|
32
40
|
invalidateCache,
|
|
33
41
|
type WorkflowScanConfig,
|
|
34
42
|
} from "../config-loader.ts";
|
|
35
43
|
import { findWorkspaceRoot } from "../../shared/resource-discovery.ts";
|
|
44
|
+
import { homedir } from "node:os";
|
|
36
45
|
|
|
37
46
|
const mockedFindWorkspaceRoot = vi.mocked(findWorkspaceRoot);
|
|
47
|
+
const mockedHomedir = vi.mocked(homedir);
|
|
38
48
|
|
|
39
49
|
// ── 临时工作区工具 ────────────────────────────────────────────
|
|
40
50
|
|
|
@@ -77,7 +87,7 @@ function validScript(
|
|
|
77
87
|
): string {
|
|
78
88
|
const description = opts.description ?? `${name} desc`;
|
|
79
89
|
const phases = JSON.stringify(opts.phases ?? []);
|
|
80
|
-
return
|
|
90
|
+
return `/* @pi-meta\nname: ${name}\ndescription: ${description}\nphases: ${phases}\n*/\nagent({ prompt: "x" });\n`;
|
|
81
91
|
}
|
|
82
92
|
|
|
83
93
|
// ── Setup / Teardown ──────────────────────────────────────────
|
|
@@ -116,32 +126,16 @@ describe("discoverWorkflows — 加载合法配置", () => {
|
|
|
116
126
|
expect(foo!.path).toBe(join(ws.projectDir, "foo.js"));
|
|
117
127
|
});
|
|
118
128
|
|
|
119
|
-
it("
|
|
120
|
-
await writeScript(
|
|
121
|
-
ws.projectDir,
|
|
122
|
-
"bar.mjs",
|
|
123
|
-
`export const meta = { name: "bar", description: "d", phases: ["p1"] };\nagent({ prompt: "x" });\n`,
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
127
|
-
const bar = result.find((w) => w.name === "bar");
|
|
128
|
-
|
|
129
|
-
expect(bar).toBeDefined();
|
|
130
|
-
expect(bar!.available).toBe(true);
|
|
131
|
-
expect(bar!.description).toBe("d");
|
|
132
|
-
expect(bar!.phases).toEqual(["p1"]);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("多行 meta 对象正确解析", async () => {
|
|
129
|
+
it("多行 @pi-meta YAML 正确解析", async () => {
|
|
136
130
|
await writeScript(
|
|
137
131
|
ws.projectDir,
|
|
138
132
|
"multi.js",
|
|
139
133
|
[
|
|
140
|
-
"
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
"
|
|
134
|
+
"/* @pi-meta",
|
|
135
|
+
"name: multi",
|
|
136
|
+
"description: multi-line",
|
|
137
|
+
"phases: [a, b]",
|
|
138
|
+
"*/",
|
|
145
139
|
'agent({ prompt: "x" });',
|
|
146
140
|
"",
|
|
147
141
|
].join("\n"),
|
|
@@ -156,11 +150,11 @@ describe("discoverWorkflows — 加载合法配置", () => {
|
|
|
156
150
|
expect(multi!.phases).toEqual(["a", "b"]);
|
|
157
151
|
});
|
|
158
152
|
|
|
159
|
-
it("
|
|
153
|
+
it("phases 为空数组合法", async () => {
|
|
160
154
|
await writeScript(
|
|
161
155
|
ws.projectDir,
|
|
162
156
|
"nophase.js",
|
|
163
|
-
|
|
157
|
+
`/* @pi-meta\nname: nophase\ndescription: x\nphases: []\n*/\n`,
|
|
164
158
|
);
|
|
165
159
|
|
|
166
160
|
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
@@ -169,6 +163,45 @@ describe("discoverWorkflows — 加载合法配置", () => {
|
|
|
169
163
|
expect(noPhase).toBeDefined();
|
|
170
164
|
expect(noPhase!.phases).toEqual([]);
|
|
171
165
|
});
|
|
166
|
+
|
|
167
|
+
it("parameters/usage 整对象透传不丢(m2 exec-review MAJOR-2 回归护栏)", async () => {
|
|
168
|
+
await writeScript(
|
|
169
|
+
ws.projectDir,
|
|
170
|
+
"params.js",
|
|
171
|
+
[
|
|
172
|
+
"/* @pi-meta",
|
|
173
|
+
"name: params",
|
|
174
|
+
"description: x",
|
|
175
|
+
"phases: [a]",
|
|
176
|
+
"parameters:",
|
|
177
|
+
" type: object",
|
|
178
|
+
" properties:",
|
|
179
|
+
" autoCommit: { type: boolean, default: false }",
|
|
180
|
+
" required: [autoCommit]",
|
|
181
|
+
"usage: |",
|
|
182
|
+
" ## 使用说明",
|
|
183
|
+
" - 示例:workflow run params --args autoCommit=true",
|
|
184
|
+
"*/",
|
|
185
|
+
'agent({ prompt: "x" });',
|
|
186
|
+
"",
|
|
187
|
+
].join("\n"),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
191
|
+
const p = result.find((w) => w.name === "params");
|
|
192
|
+
|
|
193
|
+
expect(p).toBeDefined();
|
|
194
|
+
expect(p!.available).toBe(true);
|
|
195
|
+
// 若未来有人重引入 {name,description,phases} 解构重映射(m2 消灭的反模式),
|
|
196
|
+
// 此断言会失败——parameters/usage 是 m3 args-validator 与 m4 meta 消费的依赖。
|
|
197
|
+
const params = p!.parameters as
|
|
198
|
+
| { type: string; properties: { autoCommit: { type: string } }; required: string[] }
|
|
199
|
+
| undefined;
|
|
200
|
+
expect(params?.type).toBe("object");
|
|
201
|
+
expect(params?.properties?.autoCommit?.type).toBe("boolean");
|
|
202
|
+
expect(params?.required).toEqual(["autoCommit"]);
|
|
203
|
+
expect(p!.usage).toContain("autoCommit=true");
|
|
204
|
+
});
|
|
172
205
|
});
|
|
173
206
|
|
|
174
207
|
describe("getWorkflow — 按名查找", () => {
|
|
@@ -203,7 +236,7 @@ describe("缓存 — invalidateCache 与 TTL", () => {
|
|
|
203
236
|
mockedFindWorkspaceRoot.mockReturnValue(ws.root);
|
|
204
237
|
});
|
|
205
238
|
|
|
206
|
-
it("
|
|
239
|
+
it("TC6: mtime 判变——文件修改后 getWorkflow 立即反映新内容(删 60s TTL)", async () => {
|
|
207
240
|
const scriptPath = await writeScript(
|
|
208
241
|
ws.projectDir,
|
|
209
242
|
"foo.js",
|
|
@@ -214,23 +247,21 @@ describe("缓存 — invalidateCache 与 TTL", () => {
|
|
|
214
247
|
let foo = await getWorkflow("foo");
|
|
215
248
|
expect(foo!.description).toBe("v1");
|
|
216
249
|
|
|
217
|
-
//
|
|
250
|
+
// 修改文件内容(mtime 变)→ getWorkflow 立即反映(不再有 60s 陈旧窗口)
|
|
218
251
|
await writeFile(
|
|
219
252
|
scriptPath,
|
|
220
253
|
validScript("foo", { description: "v2" }),
|
|
221
254
|
"utf-8",
|
|
222
255
|
);
|
|
223
256
|
foo = await getWorkflow("foo");
|
|
224
|
-
expect(foo!.description).toBe("
|
|
257
|
+
expect(foo!.description).toBe("v2");
|
|
225
258
|
|
|
226
|
-
//
|
|
227
|
-
invalidateCache();
|
|
259
|
+
// 文件未变 → 再次调用命中缓存(同内容)
|
|
228
260
|
foo = await getWorkflow("foo");
|
|
229
261
|
expect(foo!.description).toBe("v2");
|
|
230
262
|
});
|
|
231
263
|
|
|
232
|
-
it("
|
|
233
|
-
vi.useFakeTimers({ now: 1_000_000 });
|
|
264
|
+
it("invalidateCache 清统一缓存层后 getWorkflow 强制重读(mtime 漏判场景兜底)", async () => {
|
|
234
265
|
const scriptPath = await writeScript(
|
|
235
266
|
ws.projectDir,
|
|
236
267
|
"foo.js",
|
|
@@ -240,23 +271,22 @@ describe("缓存 — invalidateCache 与 TTL", () => {
|
|
|
240
271
|
await discoverWorkflows({ projectDir: ws.projectDir });
|
|
241
272
|
expect((await getWorkflow("foo"))!.description).toBe("v1");
|
|
242
273
|
|
|
243
|
-
//
|
|
274
|
+
// 修改文件 → mtime 判变立即反映(不等 invalidateCache)
|
|
244
275
|
await writeFile(
|
|
245
276
|
scriptPath,
|
|
246
277
|
validScript("foo", { description: "v2" }),
|
|
247
278
|
"utf-8",
|
|
248
279
|
);
|
|
249
|
-
|
|
250
|
-
expect((await getWorkflow("foo"))!.description).toBe("v1");
|
|
280
|
+
expect((await getWorkflow("foo"))!.description).toBe("v2");
|
|
251
281
|
|
|
252
|
-
//
|
|
253
|
-
|
|
282
|
+
// invalidateCache 后仍强制重读(内容变 mtime 未变场景的兜底——cp -p 类)
|
|
283
|
+
invalidateCache();
|
|
254
284
|
expect((await getWorkflow("foo"))!.description).toBe("v2");
|
|
255
285
|
});
|
|
256
286
|
});
|
|
257
287
|
|
|
258
288
|
describe("坏配置容错 — 不 crash,标记 available=false", () => {
|
|
259
|
-
it("缺少
|
|
289
|
+
it("缺少 @pi-meta 声明的脚本被标记不可用", async () => {
|
|
260
290
|
await writeScript(
|
|
261
291
|
ws.projectDir,
|
|
262
292
|
"broken.js",
|
|
@@ -277,7 +307,7 @@ describe("坏配置容错 — 不 crash,标记 available=false", () => {
|
|
|
277
307
|
await writeScript(
|
|
278
308
|
ws.projectDir,
|
|
279
309
|
"badname.js",
|
|
280
|
-
|
|
310
|
+
`/* @pi-meta\nname: 123\ndescription: x\nphases: []\n*/\n`,
|
|
281
311
|
);
|
|
282
312
|
|
|
283
313
|
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
@@ -292,7 +322,7 @@ describe("坏配置容错 — 不 crash,标记 available=false", () => {
|
|
|
292
322
|
await writeScript(
|
|
293
323
|
ws.projectDir,
|
|
294
324
|
"garbage.js",
|
|
295
|
-
|
|
325
|
+
`/* @pi-meta\nname: oops\ndescription: d\nphases: [a\n*/\n`,
|
|
296
326
|
);
|
|
297
327
|
|
|
298
328
|
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
@@ -364,6 +394,60 @@ describe("目录优先级 — 同名资源高优先级覆盖", () => {
|
|
|
364
394
|
});
|
|
365
395
|
});
|
|
366
396
|
|
|
397
|
+
describe("getWorkflowByPath — 按绝对路径加载(S2 路径统一核心新入口)", () => {
|
|
398
|
+
it("合法文件:返回完整 CachedWorkflowMeta 结构(available=true)", async () => {
|
|
399
|
+
const scriptPath = await writeScript(
|
|
400
|
+
ws.projectDir,
|
|
401
|
+
"bypath.js",
|
|
402
|
+
validScript("bypath", { description: "path loaded", phases: ["a"] }),
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const meta = await getWorkflowByPath(scriptPath);
|
|
406
|
+
|
|
407
|
+
expect(meta).toBeDefined();
|
|
408
|
+
expect(meta!.available).toBe(true);
|
|
409
|
+
expect(meta!.name).toBe("bypath");
|
|
410
|
+
expect(meta!.description).toBe("path loaded");
|
|
411
|
+
expect(meta!.phases).toEqual(["a"]);
|
|
412
|
+
expect(meta!.path).toBe(scriptPath);
|
|
413
|
+
// toCachedMeta 的 source 参数 "user-pi" → saved(非 project-pi-tmp)
|
|
414
|
+
expect(meta!.source).toBe("saved");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("相对路径返回 undefined(引用唯一形态 = 绝对路径)", async () => {
|
|
418
|
+
await expect(getWorkflowByPath("workflows/foo.js")).resolves.toBeUndefined();
|
|
419
|
+
await expect(getWorkflowByPath("./foo.js")).resolves.toBeUndefined();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("非 .js 扩展名返回 undefined", async () => {
|
|
423
|
+
await expect(getWorkflowByPath("/tmp/foo.ts")).resolves.toBeUndefined();
|
|
424
|
+
await expect(getWorkflowByPath("/tmp/foo.js.txt")).resolves.toBeUndefined();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("~/ 前缀展开为 homedir 下绝对路径后正常加载", async () => {
|
|
428
|
+
mockedHomedir.mockReturnValue(ws.root);
|
|
429
|
+
const scriptPath = await writeScript(ws.root, "tilda.js", validScript("tilda"));
|
|
430
|
+
|
|
431
|
+
const meta = await getWorkflowByPath("~/tilda.js");
|
|
432
|
+
|
|
433
|
+
expect(meta).toBeDefined();
|
|
434
|
+
expect(meta!.available).toBe(true);
|
|
435
|
+
expect(meta!.name).toBe("tilda");
|
|
436
|
+
expect(meta!.path).toBe(scriptPath);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("文件不存在:available=false 不抛(fail-safe),name fallback 到文件 stem", async () => {
|
|
440
|
+
const meta = await getWorkflowByPath("/nonexistent/ghost.js");
|
|
441
|
+
|
|
442
|
+
expect(meta).toBeDefined(); // 不是 undefined——normalizeRef 通过,meta 提取失败标不可用
|
|
443
|
+
expect(meta!.available).toBe(false);
|
|
444
|
+
expect(meta!.name).toBe("ghost");
|
|
445
|
+
expect(meta!.description).toBe("");
|
|
446
|
+
expect(meta!.phases).toEqual([]);
|
|
447
|
+
expect(meta!.path).toBe("/nonexistent/ghost.js");
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
367
451
|
describe("WorkflowScanConfig 类型接口", () => {
|
|
368
452
|
it("接受完整 WorkflowScanConfig(仅 projectDir 即可触发隔离模式)", async () => {
|
|
369
453
|
await writeScript(ws.projectDir, "foo.js", validScript("foo"));
|
|
@@ -29,6 +29,7 @@ vi.mock("../lifecycle.ts", () => ({
|
|
|
29
29
|
// import 在 vi.mock 之后(hoisting 保证拿到 mock 版本)。runWorkflow 从被 mock 的
|
|
30
30
|
// lifecycle 模块导入,与 launcher.ts 内部引用的是同一 mock 实例。
|
|
31
31
|
import { runWorkflow } from "../lifecycle.ts";
|
|
32
|
+
import { ArgsValidationError } from "../args-validator.ts";
|
|
32
33
|
import { executeNestedWorkflow } from "../launcher.ts";
|
|
33
34
|
|
|
34
35
|
const MOCK_RUN_ID = "wf-test-child";
|
|
@@ -114,12 +115,12 @@ function makeDoneChildRun(opts: {
|
|
|
114
115
|
function makeDeps(opts: {
|
|
115
116
|
script?: WorkflowScript;
|
|
116
117
|
childRun?: WorkflowRun;
|
|
117
|
-
registry?: {
|
|
118
|
+
registry?: { getPath: ReturnType<typeof vi.fn> };
|
|
118
119
|
} = {}): LauncherDeps {
|
|
119
120
|
const runs = new Map<string, WorkflowRun>();
|
|
120
121
|
if (opts.childRun) runs.set(MOCK_RUN_ID, opts.childRun);
|
|
121
122
|
const registry = opts.registry ?? {
|
|
122
|
-
|
|
123
|
+
getPath: vi.fn(async () => opts.script),
|
|
123
124
|
};
|
|
124
125
|
return {
|
|
125
126
|
registry,
|
|
@@ -149,6 +150,9 @@ beforeEach(() => {
|
|
|
149
150
|
describe("executeNestedWorkflow", () => {
|
|
150
151
|
it("returns error result when workflow not found", async () => {
|
|
151
152
|
const parent = makeParentRun();
|
|
153
|
+
const signal = parent.runtime!.controller.signal;
|
|
154
|
+
const addSpy = vi.spyOn(signal, "addEventListener");
|
|
155
|
+
const removeSpy = vi.spyOn(signal, "removeEventListener");
|
|
152
156
|
const deps = makeDeps({ script: undefined });
|
|
153
157
|
|
|
154
158
|
const result = await executeNestedWorkflow("missing", {}, parent, deps);
|
|
@@ -156,6 +160,9 @@ describe("executeNestedWorkflow", () => {
|
|
|
156
160
|
expect(result.error).toBe("Workflow 'missing' not found");
|
|
157
161
|
expect(result.content).toBe("");
|
|
158
162
|
expect(runWorkflow).not.toHaveBeenCalled();
|
|
163
|
+
// E8:早返回也走 finally——Step 2 注册的 listener 被移除(原实现泄漏)
|
|
164
|
+
expect(addSpy).toHaveBeenCalledTimes(1);
|
|
165
|
+
expect(removeSpy).toHaveBeenCalledTimes(1);
|
|
159
166
|
});
|
|
160
167
|
|
|
161
168
|
it("returns error result on lint failure", async () => {
|
|
@@ -170,6 +177,30 @@ describe("executeNestedWorkflow", () => {
|
|
|
170
177
|
expect(runWorkflow).not.toHaveBeenCalled();
|
|
171
178
|
});
|
|
172
179
|
|
|
180
|
+
it("TC11: chokepoint 校验失败 → {error} + parentSignal listener 不泄漏(E8)", async () => {
|
|
181
|
+
// 模块级 mock lifecycle:runWorkflow reject ArgsValidationError(真实实例——
|
|
182
|
+
// launcher.ts 未被 mock,instanceof 检查命中同一 class)。
|
|
183
|
+
vi.mocked(runWorkflow).mockRejectedValueOnce(
|
|
184
|
+
new ArgsValidationError(
|
|
185
|
+
"child-wf",
|
|
186
|
+
"Invalid args for workflow 'child-wf': 1 error(s)\n- /target: is required\nRead the workflow script file (location from <available_workflows>) for the parameter schema and usage.",
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
const parent = makeParentRun();
|
|
190
|
+
const signal = parent.runtime!.controller.signal;
|
|
191
|
+
const addSpy = vi.spyOn(signal, "addEventListener");
|
|
192
|
+
const removeSpy = vi.spyOn(signal, "removeEventListener");
|
|
193
|
+
const deps = makeDeps({ script: makeScript() });
|
|
194
|
+
|
|
195
|
+
const result = await executeNestedWorkflow("child-wf", {}, parent, deps);
|
|
196
|
+
|
|
197
|
+
expect(result.content).toBe("");
|
|
198
|
+
expect(result.error).toContain("Invalid args for workflow 'child-wf'");
|
|
199
|
+
// Step 2 注册 1 次 + finally 移除 1 次 → 平衡即无泄漏(E8 修复点)
|
|
200
|
+
expect(addSpy).toHaveBeenCalledTimes(1);
|
|
201
|
+
expect(removeSpy).toHaveBeenCalledTimes(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
173
204
|
it("detects circular call chain", async () => {
|
|
174
205
|
// parent chain ["a"], parent scriptName "b", target name "a" → a→b→a 循环
|
|
175
206
|
const parent = makeParentRun({
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
runWorkflow,
|
|
23
23
|
scheduleTimeBudget,
|
|
24
24
|
} from "../lifecycle.ts";
|
|
25
|
+
import { ArgsValidationError } from "../args-validator.ts";
|
|
25
26
|
import { Budget } from "../models/budget.ts";
|
|
26
27
|
import { RunRuntime } from "../models/run-runtime.ts";
|
|
27
28
|
import { Trace } from "../models/trace.ts";
|
|
@@ -32,10 +33,16 @@ import { WorkflowRun } from "../models/workflow-run.ts";
|
|
|
32
33
|
// ── helpers ──────────────────────────────────────────────────
|
|
33
34
|
|
|
34
35
|
/** 构造一个最小 RunSpec(满足 WorkflowRun 构造的字段需求)。 */
|
|
35
|
-
function makeSpec(opts: {
|
|
36
|
+
function makeSpec(opts: {
|
|
37
|
+
budgetTimeMs?: number;
|
|
38
|
+
budgetTokens?: number;
|
|
39
|
+
parameters?: Record<string, unknown>;
|
|
40
|
+
args?: Record<string, unknown>;
|
|
41
|
+
} = {}): RunSpec {
|
|
36
42
|
return {
|
|
37
43
|
scriptSource: "execute() {}",
|
|
38
|
-
args: {},
|
|
44
|
+
args: opts.args ?? {},
|
|
45
|
+
parameters: opts.parameters,
|
|
39
46
|
scriptName: "test-wf",
|
|
40
47
|
scriptPath: "/fake/test.js",
|
|
41
48
|
budgetTimeMs: opts.budgetTimeMs,
|
|
@@ -199,6 +206,34 @@ describe("resumeRun", () => {
|
|
|
199
206
|
expect(deps.store.save).toHaveBeenCalledTimes(1);
|
|
200
207
|
});
|
|
201
208
|
|
|
209
|
+
it("TC13: resume 重建 worker 收到 coerce 后 args(WQ2 一致性不变式)", async () => {
|
|
210
|
+
// runWorkflow 首行校验 + coerceTypes 原地规范化 spec.args → pause → resume 重建
|
|
211
|
+
// worker 用同一 args 对象(run.spec === spec)→ start 收到 boolean false。
|
|
212
|
+
const deps = makeDeps();
|
|
213
|
+
const spec = makeSpec({
|
|
214
|
+
parameters: {
|
|
215
|
+
type: "object",
|
|
216
|
+
properties: { autoCommit: { type: "boolean" } },
|
|
217
|
+
required: [],
|
|
218
|
+
},
|
|
219
|
+
args: { autoCommit: "false" },
|
|
220
|
+
});
|
|
221
|
+
const runId = await runWorkflow(spec, deps);
|
|
222
|
+
expect(spec.args.autoCommit).toBe(false); // 原地 coerce 生效
|
|
223
|
+
const run = deps.runs.get(runId)!;
|
|
224
|
+
run.transition("paused");
|
|
225
|
+
deps.runs.set(runId, run);
|
|
226
|
+
deps.workerHost.start.mockClear();
|
|
227
|
+
|
|
228
|
+
await resumeRun(runId, deps);
|
|
229
|
+
|
|
230
|
+
const startArgs = deps.workerHost.start.mock.calls[0]?.[1] as
|
|
231
|
+
| Record<string, unknown>
|
|
232
|
+
| undefined;
|
|
233
|
+
expect(startArgs).toBeDefined();
|
|
234
|
+
expect(startArgs!.autoCommit).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
202
237
|
it("无 budgetTimeMs 时 resume 不调度时间预算计时器", async () => {
|
|
203
238
|
const { run } = makeRunningRealRun("wf-resume-no-budget");
|
|
204
239
|
run.transition("paused");
|
|
@@ -331,6 +366,28 @@ describe("runWorkflow", () => {
|
|
|
331
366
|
expect(deps.runs.size).toBe(0);
|
|
332
367
|
expect(deps.workerHost.start).not.toHaveBeenCalled();
|
|
333
368
|
});
|
|
369
|
+
|
|
370
|
+
it("TC8: 参数校验失败 → throw ArgsValidationError + zero side effects(E9)", async () => {
|
|
371
|
+
const deps = makeDeps();
|
|
372
|
+
const controller = new AbortController();
|
|
373
|
+
const addSpy = vi.spyOn(controller.signal, "addEventListener");
|
|
374
|
+
const spec = makeSpec({
|
|
375
|
+
parameters: {
|
|
376
|
+
type: "object",
|
|
377
|
+
properties: { target: { type: "string" } },
|
|
378
|
+
required: ["target"],
|
|
379
|
+
},
|
|
380
|
+
args: {},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
await expect(runWorkflow(spec, deps, controller.signal)).rejects.toThrow(ArgsValidationError);
|
|
384
|
+
expect(deps.runs.size).toBe(0);
|
|
385
|
+
expect(deps.workerHost.start).not.toHaveBeenCalled();
|
|
386
|
+
expect(deps.store.save).not.toHaveBeenCalled();
|
|
387
|
+
expect(deps.eventBus.emit).not.toHaveBeenCalled();
|
|
388
|
+
expect(addSpy).not.toHaveBeenCalled();
|
|
389
|
+
expect(deps.log).not.toHaveBeenCalled(); // 校验在 deps.log 前 throw(无日志痕迹)
|
|
390
|
+
});
|
|
334
391
|
});
|
|
335
392
|
|
|
336
393
|
// ── abortRun ─────────────────────────────────────────────────
|