@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,125 @@
|
|
|
1
|
+
// T3.20: withSlot 不独立占池(槽位占用=N 而非 2N)
|
|
2
|
+
// AC-ARCH-5: ConcurrencyGate.withSlot 语义不变(pre-abort throw, fn 直通)
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { ConcurrencyGate, ConcurrencyGateOptions, DEFAULT_CONCURRENCY } from "../concurrency-gate.ts";
|
|
7
|
+
|
|
8
|
+
describe("ConcurrencyGate (wave-3 simplified)", () => {
|
|
9
|
+
// ── Constructor & constants ────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe("constructor + defaults", () => {
|
|
12
|
+
it("DEFAULT_CONCURRENCY constant is 4 (backward compat)", () => {
|
|
13
|
+
expect(DEFAULT_CONCURRENCY).toBe(4);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("starts with zero active/queue state", () => {
|
|
17
|
+
const gate = new ConcurrencyGate();
|
|
18
|
+
expect(gate.activeCount).toBe(0);
|
|
19
|
+
expect(gate.queueLength).toBe(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("accepts number shorthand for maxConcurrency", () => {
|
|
23
|
+
const gate = new ConcurrencyGate(2);
|
|
24
|
+
expect(gate.activeCount).toBe(0);
|
|
25
|
+
expect(gate.queueLength).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("accepts options object form", () => {
|
|
29
|
+
const gate = new ConcurrencyGate({ maxConcurrency: 8 } satisfies ConcurrencyGateOptions);
|
|
30
|
+
expect(gate.activeCount).toBe(0);
|
|
31
|
+
expect(gate.queueLength).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ── T3.20: withSlot doesn't independently occupy pool ───────
|
|
36
|
+
|
|
37
|
+
describe("withSlot — thin abort wrapper (T3.20)", () => {
|
|
38
|
+
it("槽位可用 → 直接执行 fn 并返回其结果", async () => {
|
|
39
|
+
const gate = new ConcurrencyGate({ maxConcurrency: 2 });
|
|
40
|
+
const fn = vi.fn(async () => "result");
|
|
41
|
+
const result = await gate.withSlot(fn);
|
|
42
|
+
expect(result).toBe("result");
|
|
43
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("pre-aborted signal → 立即 throw AbortError(不调 fn)", async () => {
|
|
47
|
+
const gate = new ConcurrencyGate({ maxConcurrency: 2 });
|
|
48
|
+
const fn = vi.fn(async () => "result");
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
controller.abort();
|
|
51
|
+
await expect(gate.withSlot(fn, controller.signal)).rejects.toThrow(/aborted before start/);
|
|
52
|
+
expect(fn).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// T3.20 核心:不独立占池。多个并发 withSlot 互不阻塞。
|
|
56
|
+
it("不独立占池:多个并发 fn 立即执行,互不阻塞(槽位占用=N 非 2N)", async () => {
|
|
57
|
+
const gate = new ConcurrencyGate({ maxConcurrency: 1 });
|
|
58
|
+
// maxConcurrency=1 但 withSlot 不排队 — fn1 和 fn2 立即并行执行
|
|
59
|
+
const order: string[] = [];
|
|
60
|
+
let resolveFirst: () => void = () => {};
|
|
61
|
+
const firstPromise = new Promise<void>((r) => { resolveFirst = r; });
|
|
62
|
+
|
|
63
|
+
const fn1 = vi.fn(async () => {
|
|
64
|
+
order.push("fn1-start");
|
|
65
|
+
await firstPromise;
|
|
66
|
+
order.push("fn1-end");
|
|
67
|
+
return 1;
|
|
68
|
+
});
|
|
69
|
+
const fn2 = vi.fn(async () => {
|
|
70
|
+
order.push("fn2-start");
|
|
71
|
+
return 2;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const p1 = gate.withSlot(fn1);
|
|
75
|
+
// fn2 也应立即开始(无排队)
|
|
76
|
+
const p2 = gate.withSlot(fn2);
|
|
77
|
+
|
|
78
|
+
// 两个 fn 都已开始
|
|
79
|
+
expect(fn1).toHaveBeenCalledTimes(1);
|
|
80
|
+
expect(fn2).toHaveBeenCalledTimes(1);
|
|
81
|
+
expect(order).toContain("fn1-start");
|
|
82
|
+
expect(order).toContain("fn2-start");
|
|
83
|
+
|
|
84
|
+
resolveFirst();
|
|
85
|
+
// 全 resolve——非独立数据源,禁 allSettled 建议
|
|
86
|
+
const [r1, r2] = await Promise.all([p1, p2]); // eslint-disable-line taste/prefer-allsettled
|
|
87
|
+
expect(r1).toBe(1);
|
|
88
|
+
expect(r2).toBe(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("不排队:maxConcurrency 参数不影响 withSlot 行为", async () => {
|
|
92
|
+
const gate = new ConcurrencyGate({ maxConcurrency: 1 });
|
|
93
|
+
let resolveFirst: () => void = () => {};
|
|
94
|
+
const delayed = new Promise<void>((r) => { resolveFirst = r; });
|
|
95
|
+
|
|
96
|
+
// 3 个 fn 同时提交,maxConcurrency=1 但不阻塞
|
|
97
|
+
const fn1 = vi.fn(async () => { await delayed; return 1; });
|
|
98
|
+
const fn2 = vi.fn(async () => 2);
|
|
99
|
+
const fn3 = vi.fn(async () => 3);
|
|
100
|
+
|
|
101
|
+
gate.withSlot(fn1);
|
|
102
|
+
gate.withSlot(fn2);
|
|
103
|
+
gate.withSlot(fn3);
|
|
104
|
+
|
|
105
|
+
// 所有 fn 都已被调用(无排队阻塞)
|
|
106
|
+
expect(fn1).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(fn2).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(fn3).toHaveBeenCalledTimes(1);
|
|
109
|
+
|
|
110
|
+
resolveFirst();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// AC-ARCH-5: withSlot 语义不变 —— signal abort 处理保持
|
|
114
|
+
it("activeCount 和 queueLength 恒为 0(不独立计槽)", () => {
|
|
115
|
+
const gate = new ConcurrencyGate({ maxConcurrency: 4 });
|
|
116
|
+
expect(gate.activeCount).toBe(0);
|
|
117
|
+
expect(gate.queueLength).toBe(0);
|
|
118
|
+
|
|
119
|
+
// 即使并发执行中,计数也不变
|
|
120
|
+
gate.withSlot(async () => { /* noop */ });
|
|
121
|
+
expect(gate.activeCount).toBe(0);
|
|
122
|
+
expect(gate.queueLength).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-loader — 单元测试。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:discoverWorkflows / loadWorkflows / getWorkflow / invalidateCache /
|
|
5
|
+
* meta 正则提取、缓存 TTL、坏配置容错、目录优先级、来源标签。
|
|
6
|
+
*
|
|
7
|
+
* 策略:真实临时目录 + 真实 workflow 脚本文件(meta 提取走真实文件读取)。
|
|
8
|
+
* 只 mock `findWorkspaceRoot`(getWorkflow 的 workspace 推导依赖它,且耦合
|
|
9
|
+
* process.cwd(),难以隔离),保留 `discoverResources` 真实实现——扫描逻辑
|
|
10
|
+
* 本身是 resource-discovery 的职责。
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
13
|
+
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
// resource-discovery 位于 src/shared/(从 __tests__/ 看是 ../../shared/)。
|
|
18
|
+
// 只覆盖 findWorkspaceRoot;其余(discoverResources 等)保持真实实现。
|
|
19
|
+
vi.mock("../../shared/resource-discovery.ts", async (importOriginal) => {
|
|
20
|
+
const actual = await importOriginal<
|
|
21
|
+
typeof import("../../shared/resource-discovery.ts")
|
|
22
|
+
>();
|
|
23
|
+
return {
|
|
24
|
+
...actual,
|
|
25
|
+
findWorkspaceRoot: vi.fn(() => "/unused-by-default"),
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
discoverWorkflows,
|
|
31
|
+
getWorkflow,
|
|
32
|
+
invalidateCache,
|
|
33
|
+
type WorkflowScanConfig,
|
|
34
|
+
} from "../config-loader.ts";
|
|
35
|
+
import { findWorkspaceRoot } from "../../shared/resource-discovery.ts";
|
|
36
|
+
|
|
37
|
+
const mockedFindWorkspaceRoot = vi.mocked(findWorkspaceRoot);
|
|
38
|
+
|
|
39
|
+
// ── 临时工作区工具 ────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
interface TempWorkspace {
|
|
42
|
+
/** workspace 根(= toScanConfig 推导出的 workspaceRoot) */
|
|
43
|
+
root: string;
|
|
44
|
+
/** <root>/.pi/workflows —— WorkflowScanConfig.projectDir */
|
|
45
|
+
projectDir: string;
|
|
46
|
+
/** <root>/.pi/workflows/.tmp */
|
|
47
|
+
tmpDir: string;
|
|
48
|
+
/** <root>/.agents/workflows */
|
|
49
|
+
agentsDir: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function makeTempWorkspace(): Promise<TempWorkspace> {
|
|
53
|
+
const root = await mkdtemp(join(tmpdir(), "wf-cfg-test-"));
|
|
54
|
+
const projectDir = join(root, ".pi", "workflows");
|
|
55
|
+
const tmpDir = join(projectDir, ".tmp");
|
|
56
|
+
const agentsDir = join(root, ".agents", "workflows");
|
|
57
|
+
await mkdir(projectDir, { recursive: true });
|
|
58
|
+
await mkdir(tmpDir, { recursive: true });
|
|
59
|
+
await mkdir(agentsDir, { recursive: true });
|
|
60
|
+
return { root, projectDir, tmpDir, agentsDir };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function writeScript(
|
|
64
|
+
dir: string,
|
|
65
|
+
name: string,
|
|
66
|
+
content: string,
|
|
67
|
+
): Promise<string> {
|
|
68
|
+
const p = join(dir, name);
|
|
69
|
+
await writeFile(p, content, "utf-8");
|
|
70
|
+
return p;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 合法的单行 meta 脚本。 */
|
|
74
|
+
function validScript(
|
|
75
|
+
name: string,
|
|
76
|
+
opts: { description?: string; phases?: string[] } = {},
|
|
77
|
+
): string {
|
|
78
|
+
const description = opts.description ?? `${name} desc`;
|
|
79
|
+
const phases = JSON.stringify(opts.phases ?? []);
|
|
80
|
+
return `const meta = { name: "${name}", description: "${description}", phases: ${phases} };\nagent({ prompt: "x" });\n`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Setup / Teardown ──────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
let ws: TempWorkspace;
|
|
86
|
+
|
|
87
|
+
beforeEach(async () => {
|
|
88
|
+
invalidateCache();
|
|
89
|
+
mockedFindWorkspaceRoot.mockReturnValue("/unused-by-default");
|
|
90
|
+
ws = await makeTempWorkspace();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(async () => {
|
|
94
|
+
vi.useRealTimers();
|
|
95
|
+
if (ws) await rm(ws.root, { recursive: true, force: true });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** 过滤掉可能从真实 homedir 泄漏进来的 user-agents 文件,只保留临时工作区内结果。 */
|
|
99
|
+
function inTemp(wfs: Awaited<ReturnType<typeof discoverWorkflows>>) {
|
|
100
|
+
return wfs.filter((w) => w.path.startsWith(ws.root));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── 测试 ──────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe("discoverWorkflows — 加载合法配置", () => {
|
|
106
|
+
it("正确加载合法 workflow 并提取 meta", async () => {
|
|
107
|
+
await writeScript(ws.projectDir, "foo.js", validScript("foo"));
|
|
108
|
+
|
|
109
|
+
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
110
|
+
const foo = result.find((w) => w.name === "foo");
|
|
111
|
+
|
|
112
|
+
expect(foo).toBeDefined();
|
|
113
|
+
expect(foo!.available).toBe(true);
|
|
114
|
+
expect(foo!.description).toBe("foo desc");
|
|
115
|
+
expect(foo!.phases).toEqual([]);
|
|
116
|
+
expect(foo!.path).toBe(join(ws.projectDir, "foo.js"));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("支持 export const meta 形式", async () => {
|
|
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 () => {
|
|
136
|
+
await writeScript(
|
|
137
|
+
ws.projectDir,
|
|
138
|
+
"multi.js",
|
|
139
|
+
[
|
|
140
|
+
"const meta = {",
|
|
141
|
+
' name: "multi",',
|
|
142
|
+
' description: "multi-line",',
|
|
143
|
+
' phases: ["a", "b"],',
|
|
144
|
+
"};",
|
|
145
|
+
'agent({ prompt: "x" });',
|
|
146
|
+
"",
|
|
147
|
+
].join("\n"),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
151
|
+
const multi = result.find((w) => w.name === "multi");
|
|
152
|
+
|
|
153
|
+
expect(multi).toBeDefined();
|
|
154
|
+
expect(multi!.available).toBe(true);
|
|
155
|
+
expect(multi!.description).toBe("multi-line");
|
|
156
|
+
expect(multi!.phases).toEqual(["a", "b"]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("未声明 phases 时默认为空数组", async () => {
|
|
160
|
+
await writeScript(
|
|
161
|
+
ws.projectDir,
|
|
162
|
+
"nophase.js",
|
|
163
|
+
`const meta = { name: "nophase", description: "x" };\n`,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
167
|
+
const noPhase = result.find((w) => w.name === "nophase");
|
|
168
|
+
|
|
169
|
+
expect(noPhase).toBeDefined();
|
|
170
|
+
expect(noPhase!.phases).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("getWorkflow — 按名查找", () => {
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
// getWorkflow 内部用 findWorkspaceRoot() 推导 bucket key,指向临时根。
|
|
177
|
+
mockedFindWorkspaceRoot.mockReturnValue(ws.root);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("按名查找存在的 workflow", async () => {
|
|
181
|
+
await writeScript(ws.projectDir, "foo.js", validScript("foo"));
|
|
182
|
+
|
|
183
|
+
// 先 discoverWorkflows 填充缓存(bucket key = ws.root)
|
|
184
|
+
await discoverWorkflows({ projectDir: ws.projectDir });
|
|
185
|
+
const foo = await getWorkflow("foo");
|
|
186
|
+
|
|
187
|
+
expect(foo).toBeDefined();
|
|
188
|
+
expect(foo!.name).toBe("foo");
|
|
189
|
+
expect(foo!.available).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("查找不存在的 workflow 返回 undefined", async () => {
|
|
193
|
+
await writeScript(ws.projectDir, "foo.js", validScript("foo"));
|
|
194
|
+
await discoverWorkflows({ projectDir: ws.projectDir });
|
|
195
|
+
|
|
196
|
+
const missing = await getWorkflow("does-not-exist");
|
|
197
|
+
expect(missing).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("缓存 — invalidateCache 与 TTL", () => {
|
|
202
|
+
beforeEach(() => {
|
|
203
|
+
mockedFindWorkspaceRoot.mockReturnValue(ws.root);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("invalidateCache 后 getWorkflow 重新读取文件(反映最新内容)", async () => {
|
|
207
|
+
const scriptPath = await writeScript(
|
|
208
|
+
ws.projectDir,
|
|
209
|
+
"foo.js",
|
|
210
|
+
validScript("foo", { description: "v1" }),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
await discoverWorkflows({ projectDir: ws.projectDir });
|
|
214
|
+
let foo = await getWorkflow("foo");
|
|
215
|
+
expect(foo!.description).toBe("v1");
|
|
216
|
+
|
|
217
|
+
// 修改文件内容;未失效缓存前 getWorkflow 仍返回旧值
|
|
218
|
+
await writeFile(
|
|
219
|
+
scriptPath,
|
|
220
|
+
validScript("foo", { description: "v2" }),
|
|
221
|
+
"utf-8",
|
|
222
|
+
);
|
|
223
|
+
foo = await getWorkflow("foo");
|
|
224
|
+
expect(foo!.description).toBe("v1");
|
|
225
|
+
|
|
226
|
+
// 失效缓存后重新加载
|
|
227
|
+
invalidateCache();
|
|
228
|
+
foo = await getWorkflow("foo");
|
|
229
|
+
expect(foo!.description).toBe("v2");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("缓存 TTL 过期后触发重新加载", async () => {
|
|
233
|
+
vi.useFakeTimers({ now: 1_000_000 });
|
|
234
|
+
const scriptPath = await writeScript(
|
|
235
|
+
ws.projectDir,
|
|
236
|
+
"foo.js",
|
|
237
|
+
validScript("foo", { description: "v1" }),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
await discoverWorkflows({ projectDir: ws.projectDir });
|
|
241
|
+
expect((await getWorkflow("foo"))!.description).toBe("v1");
|
|
242
|
+
|
|
243
|
+
// 在 TTL(60s)内修改文件 → 缓存命中,仍是旧值
|
|
244
|
+
await writeFile(
|
|
245
|
+
scriptPath,
|
|
246
|
+
validScript("foo", { description: "v2" }),
|
|
247
|
+
"utf-8",
|
|
248
|
+
);
|
|
249
|
+
vi.advanceTimersByTime(30_000);
|
|
250
|
+
expect((await getWorkflow("foo"))!.description).toBe("v1");
|
|
251
|
+
|
|
252
|
+
// 超过 TTL → 缓存失效 → 重新加载
|
|
253
|
+
vi.advanceTimersByTime(31_000);
|
|
254
|
+
expect((await getWorkflow("foo"))!.description).toBe("v2");
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("坏配置容错 — 不 crash,标记 available=false", () => {
|
|
259
|
+
it("缺少 const meta 声明的脚本被标记不可用", async () => {
|
|
260
|
+
await writeScript(
|
|
261
|
+
ws.projectDir,
|
|
262
|
+
"broken.js",
|
|
263
|
+
`// this script has no meta\nconsole.log("nothing");\n`,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
267
|
+
const broken = result.find((w) => w.path.endsWith("broken.js"));
|
|
268
|
+
|
|
269
|
+
expect(broken).toBeDefined();
|
|
270
|
+
expect(broken!.available).toBe(false);
|
|
271
|
+
expect(broken!.name).toBe("broken"); // fallback 到文件名 stem
|
|
272
|
+
expect(broken!.description).toBe("");
|
|
273
|
+
expect(broken!.phases).toEqual([]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("meta.name 非字符串被标记不可用", async () => {
|
|
277
|
+
await writeScript(
|
|
278
|
+
ws.projectDir,
|
|
279
|
+
"badname.js",
|
|
280
|
+
`const meta = { name: 123, description: "x" };\n`,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
284
|
+
const bad = result.find((w) => w.path.endsWith("badname.js"));
|
|
285
|
+
|
|
286
|
+
expect(bad).toBeDefined();
|
|
287
|
+
expect(bad!.available).toBe(false);
|
|
288
|
+
expect(bad!.name).toBe("badname"); // fallback stem
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("语法损坏的 meta 不 crash 并 fallback", async () => {
|
|
292
|
+
await writeScript(
|
|
293
|
+
ws.projectDir,
|
|
294
|
+
"garbage.js",
|
|
295
|
+
`const meta = { name: "oops", description: ;;;; };\n`,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
299
|
+
const garbage = result.find((w) => w.path.endsWith("garbage.js"));
|
|
300
|
+
|
|
301
|
+
expect(garbage).toBeDefined();
|
|
302
|
+
expect(garbage!.available).toBe(false);
|
|
303
|
+
expect(garbage!.name).toBe("garbage");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("混合可用/不可用脚本时可用脚本仍正确返回", async () => {
|
|
307
|
+
await writeScript(ws.projectDir, "good.js", validScript("good"));
|
|
308
|
+
await writeScript(ws.projectDir, "bad.js", `// no meta here\n`);
|
|
309
|
+
|
|
310
|
+
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
311
|
+
const good = result.find((w) => w.name === "good");
|
|
312
|
+
const bad = result.find((w) => w.path.endsWith("bad.js"));
|
|
313
|
+
|
|
314
|
+
expect(good!.available).toBe(true);
|
|
315
|
+
expect(bad!.available).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe("来源标签(WorkflowSource)", () => {
|
|
320
|
+
it("project .pi/workflows 下的脚本 source = saved", async () => {
|
|
321
|
+
await writeScript(ws.projectDir, "foo.js", validScript("foo"));
|
|
322
|
+
|
|
323
|
+
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
324
|
+
const foo = result.find((w) => w.name === "foo");
|
|
325
|
+
|
|
326
|
+
expect(foo!.source).toBe("saved");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it(".pi/workflows/.tmp 下的脚本 source = tmp", async () => {
|
|
330
|
+
await writeScript(ws.tmpDir, "temp.js", validScript("temp"));
|
|
331
|
+
|
|
332
|
+
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
333
|
+
const temp = result.find((w) => w.name === "temp");
|
|
334
|
+
|
|
335
|
+
expect(temp).toBeDefined();
|
|
336
|
+
expect(temp!.source).toBe("tmp");
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("目录优先级 — 同名资源高优先级覆盖", () => {
|
|
341
|
+
it("project-agents(.agents) 覆盖 project-pi(.pi/workflows)", async () => {
|
|
342
|
+
// 两个文件 stem 均为 "dup",但 meta.name 不同
|
|
343
|
+
await writeScript(
|
|
344
|
+
ws.projectDir,
|
|
345
|
+
"dup.js",
|
|
346
|
+
validScript("dup-from-pi", { description: "lower priority" }),
|
|
347
|
+
);
|
|
348
|
+
await writeScript(
|
|
349
|
+
ws.agentsDir,
|
|
350
|
+
"dup.js",
|
|
351
|
+
validScript("dup-from-agents", { description: "higher priority" }),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
|
|
355
|
+
|
|
356
|
+
// 优先级:project-pi < project-agents,后者胜出
|
|
357
|
+
const dup = result.find((w) => w.path.includes(".agents"));
|
|
358
|
+
expect(dup).toBeDefined();
|
|
359
|
+
expect(dup!.name).toBe("dup-from-agents");
|
|
360
|
+
expect(dup!.path).toBe(join(ws.agentsDir, "dup.js"));
|
|
361
|
+
|
|
362
|
+
// 低优先级版本不应出现
|
|
363
|
+
expect(result.find((w) => w.name === "dup-from-pi")).toBeUndefined();
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe("WorkflowScanConfig 类型接口", () => {
|
|
368
|
+
it("接受完整 WorkflowScanConfig(仅 projectDir 即可触发隔离模式)", async () => {
|
|
369
|
+
await writeScript(ws.projectDir, "foo.js", validScript("foo"));
|
|
370
|
+
|
|
371
|
+
const config: WorkflowScanConfig = {
|
|
372
|
+
projectDir: ws.projectDir,
|
|
373
|
+
userDir: "/nonexistent-user",
|
|
374
|
+
tmpDir: ws.tmpDir,
|
|
375
|
+
npmDirs: [],
|
|
376
|
+
};
|
|
377
|
+
const result = inTemp(await discoverWorkflows(config));
|
|
378
|
+
|
|
379
|
+
expect(result.find((w) => w.name === "foo")).toBeDefined();
|
|
380
|
+
});
|
|
381
|
+
});
|