From 4903f544b77a70ebf11c23afa6d988c2260978ce Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sun, 14 Jun 2026 15:48:29 +0800 Subject: [PATCH] =?UTF-8?q?chore(workflow):=20=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E4=BB=A3=E7=A0=81=E4=B8=AD=E6=96=87=E6=96=87?= =?UTF-8?q?=E6=A1=88=E5=85=A8=E9=83=A8=E8=8B=B1=E6=96=87=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 源码(src/workflow/ + packages/workflow-engine/src/)的中文注释、 用户可见错误消息、字符串字面量;测试文件的标题与注释;同步 6 条 硬编码断言到英文化后的错误消息。 Co-Authored-By: glm-5.2 --- .../src/__tests__/WorkflowTool.test.ts | 56 ++++---- .../src/__tests__/agentAdapter.test.ts | 24 ++-- .../src/__tests__/agentId.test.ts | 4 +- .../src/__tests__/budget.test.ts | 6 +- .../src/__tests__/concurrency.test.ts | 40 +++--- .../src/__tests__/context.test.ts | 20 +-- .../src/__tests__/errors.test.ts | 12 +- .../src/__tests__/events.test.ts | 12 +- .../src/__tests__/hooks.test.ts | 108 +++++++------- .../src/__tests__/index.test.ts | 14 +- .../src/__tests__/integration.test.ts | 36 ++--- .../src/__tests__/journal.test.ts | 24 ++-- .../src/__tests__/namedWorkflows.test.ts | 10 +- .../src/__tests__/paths.test.ts | 20 +-- .../src/__tests__/persistInline.test.ts | 6 +- .../src/__tests__/ports.test.ts | 14 +- .../src/__tests__/runWorkflow.test.ts | 48 +++---- .../src/__tests__/schema.test.ts | 16 +-- .../src/__tests__/script.test.ts | 44 +++--- .../src/__tests__/structuredOutput.test.ts | 10 +- .../src/__tests__/types.test.ts | 16 +-- packages/workflow-engine/src/agentAdapter.ts | 84 +++++------ packages/workflow-engine/src/constants.ts | 24 ++-- packages/workflow-engine/src/engine/budget.ts | 6 +- .../workflow-engine/src/engine/concurrency.ts | 18 +-- .../workflow-engine/src/engine/context.ts | 10 +- packages/workflow-engine/src/engine/errors.ts | 6 +- packages/workflow-engine/src/engine/hooks.ts | 64 ++++----- .../workflow-engine/src/engine/journal.ts | 10 +- .../src/engine/namedWorkflows.ts | 8 +- packages/workflow-engine/src/engine/paths.ts | 12 +- .../workflow-engine/src/engine/runWorkflow.ts | 26 ++-- packages/workflow-engine/src/engine/script.ts | 54 +++---- .../src/engine/structuredOutput.ts | 4 +- packages/workflow-engine/src/index.ts | 2 +- packages/workflow-engine/src/ports.ts | 66 ++++----- .../workflow-engine/src/progress/events.ts | 4 +- .../workflow-engine/src/tool/WorkflowTool.ts | 36 ++--- packages/workflow-engine/src/tool/schema.ts | 34 +++-- packages/workflow-engine/src/types.ts | 54 +++---- .../__tests__/WorkflowsPanel.test.tsx | 80 +++++------ .../__tests__/claudeCodeBackend.test.ts | 102 ++++++------- src/workflow/__tests__/notifications.test.ts | 28 ++-- src/workflow/__tests__/persistence.test.ts | 26 ++-- src/workflow/__tests__/ports.test.ts | 62 ++++---- src/workflow/__tests__/progressBus.test.ts | 4 +- src/workflow/__tests__/progressStore.test.ts | 30 ++-- .../__tests__/runStatePersistence.test.ts | 36 ++--- src/workflow/__tests__/selectors.test.ts | 8 +- src/workflow/__tests__/service.test.ts | 136 +++++++++--------- src/workflow/__tests__/status.test.ts | 16 +-- .../__tests__/useWorkflowKeyboard.test.ts | 8 +- src/workflow/backends/claudeCodeBackend.ts | 124 ++++++++-------- src/workflow/hostHandle.ts | 6 +- src/workflow/namedWorkflowCommands.ts | 2 +- src/workflow/notifications.ts | 27 ++-- src/workflow/panel/AgentList.tsx | 24 ++-- src/workflow/panel/PhaseSidebar.tsx | 8 +- src/workflow/panel/TabsBar.tsx | 4 +- src/workflow/panel/WorkflowsPanel.tsx | 62 ++++---- src/workflow/panel/panelCall.tsx | 8 +- src/workflow/panel/selectors.ts | 20 +-- src/workflow/panel/status.ts | 24 ++-- src/workflow/panel/useWorkflowKeyboard.ts | 38 ++--- src/workflow/persistence.ts | 48 +++---- src/workflow/ports.ts | 37 ++--- src/workflow/progress/bus.ts | 2 +- src/workflow/progress/store.ts | 26 ++-- src/workflow/registry.ts | 5 +- src/workflow/service.ts | 88 ++++++------ src/workflow/wiring.ts | 17 +-- 71 files changed, 1091 insertions(+), 1077 deletions(-) diff --git a/packages/workflow-engine/src/__tests__/WorkflowTool.test.ts b/packages/workflow-engine/src/__tests__/WorkflowTool.test.ts index da6302b85..922feb146 100644 --- a/packages/workflow-engine/src/__tests__/WorkflowTool.test.ts +++ b/packages/workflow-engine/src/__tests__/WorkflowTool.test.ts @@ -48,7 +48,7 @@ function mockPorts( return { ports, events, runStatus } } -test('call 返回 launch 消息并在后台完成', async () => { +test('call returns launch message and completes in background', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) try { const { ports, runStatus } = mockPorts( @@ -74,7 +74,7 @@ test('call 返回 launch 消息并在后台完成', async () => { } }) -test('inline script 持久化到 run 目录,返回真实 scriptPath', async () => { +test('inline script persists to run directory, returns real scriptPath', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) try { const { ports } = mockPorts( @@ -102,7 +102,7 @@ test('inline script 持久化到 run 目录,返回真实 scriptPath', async () } }) -test('缺少 script/name/scriptPath → 返回错误(不进后台)', async () => { +test('missing script/name/scriptPath → returns error (does not enter background)', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) try { const { ports, runStatus } = mockPorts(dir, new Map()) @@ -115,7 +115,7 @@ test('缺少 script/name/scriptPath → 返回错误(不进后台)', async ( } }) -test('脚本语法错 → 返回校验错误(不进后台)', async () => { +test('script syntax error → returns validation error (does not enter background)', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) try { const { ports, runStatus } = mockPorts(dir, new Map()) @@ -126,14 +126,14 @@ test('脚本语法错 → 返回校验错误(不进后台)', async () => { undefined, undefined, ) - expect(res.data.output).toMatch(/校验失败|Error/) + expect(res.data.output).toMatch(/validation failed|Error/i) expect(runStatus.size).toBe(0) } finally { await rm(dir, { recursive: true, force: true }) } }) -test('name 解析到 .claude/workflows/.ts', async () => { +test('name resolves to .claude/workflows/.ts', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) try { await mkdir(join(dir, '.claude', 'workflows'), { recursive: true }) @@ -180,7 +180,7 @@ test('renderToolUseMessage / mapToolResultToToolResultBlockParam', () => { expect(block.content[0]!.text).toBe('hi') }) -test('scriptPath 解析到文件内容并后台执行', async () => { +test('scriptPath resolves to file content and runs in background', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) try { const scriptFile = join(dir, 'external.ts') @@ -209,7 +209,7 @@ test('scriptPath 解析到文件内容并后台执行', async () => { } }) -test('脚本运行时失败 → onFinish 路由到 fail', async () => { +test('script runtime failure → onFinish routes to fail', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) try { const { ports, runStatus } = mockPorts(dir, new Map()) @@ -229,7 +229,7 @@ test('脚本运行时失败 → onFinish 路由到 fail', async () => { } }) -test('元数据方法:description/prompt/renderToolUseMessage', async () => { +test('metadata methods: description/prompt/renderToolUseMessage', async () => { const { ports } = mockPorts('/tmp', new Map()) const tool = createWorkflowTool(ports) expect(tool.isEnabled()).toBe(true) @@ -242,7 +242,7 @@ test('元数据方法:description/prompt/renderToolUseMessage', async () => { ) }) -test('prompt 包含默认并发 3 + AskUserQuestion 指引', async () => { +test('prompt includes default concurrency 3 + AskUserQuestion guidance', async () => { const { ports } = mockPorts('/tmp', new Map()) const tool = createWorkflowTool(ports) const p = await tool.prompt() @@ -251,7 +251,7 @@ test('prompt 包含默认并发 3 + AskUserQuestion 指引', async () => { expect(p).toMatch(/AskUserQuestion/i) }) -test('name 不存在 → 返回错误(不进后台)', async () => { +test('name does not exist → returns error (does not enter background)', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) try { await mkdir(join(dir, '.claude', 'workflows'), { recursive: true }) @@ -270,7 +270,7 @@ test('name 不存在 → 返回错误(不进后台)', async () => { } }) -test('workflow 被 abort → onFinish 路由 kill', async () => { +test('workflow aborted → onFinish routes to kill', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) try { const runStatus = new Map() @@ -321,7 +321,7 @@ test('workflow 被 abort → onFinish 路由 kill', async () => { } }) -test('args 为 JSON 字符串化的对象时防御性 parse(向后兼容旧 z.string() 契约)', async () => { +test('args defensively parses when a JSON-stringified object (backward compatible with old z.string() contract)', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) try { const capturedPrompts: unknown[] = [] @@ -360,7 +360,7 @@ test('args 为 JSON 字符串化的对象时防御性 parse(向后兼容旧 z. await tool.call( { script: `return agent(args.commit)`, - // 模拟旧契约下模型发送的字符串化 JSON + // simulate stringified JSON sent by model under old contract args: '{"commit":"abc123"}', }, undefined, @@ -370,15 +370,15 @@ test('args 为 JSON 字符串化的对象时防御性 parse(向后兼容旧 z. await new Promise(r => { setTimeout(r, 50) }) - // 若 args 未归一化:args.commit === undefined(string 上无 commit 属性) - // 若 args 归一化:args.commit === 'abc123' + // if args not normalized: args.commit === undefined (string has no commit property) + // if args normalized: args.commit === 'abc123' expect(capturedPrompts).toContain('abc123') } finally { await rm(dir, { recursive: true, force: true }) } }) -test('args 为非合法 JSON 字符串时保持原值不抛', async () => { +test('args keeps original value for non-legal JSON string without throwing', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) try { const capturedPrompts: unknown[] = [] @@ -416,7 +416,7 @@ test('args 为非合法 JSON 字符串时保持原值不抛', async () => { const tool = createWorkflowTool(ports) await tool.call( { - // 脚本把 args 当字符串用:agent(args) → agent('hello') + // script uses args as a string: agent(args) → agent('hello') script: `return agent(args)`, args: 'hello', }, @@ -427,22 +427,22 @@ test('args 为非合法 JSON 字符串时保持原值不抛', async () => { await new Promise(r => { setTimeout(r, 50) }) - // 'hello' 不是合法 JSON,应保持为字符串 + // 'hello' is not valid JSON, should be kept as a string expect(capturedPrompts).toContain('hello') } finally { await rm(dir, { recursive: true, force: true }) } }) -test('scriptPath 越界(resolve 后在 cwd 之外)→ 拒绝并报错(防任意文件读)', async () => { +test('scriptPath out of bounds (resolved outside cwd) → rejected with error (prevents arbitrary file read)', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) try { const subDir = join(dir, 'sub') await mkdir(subDir, { recursive: true }) - // 在 subDir 之外(dir 内)放置一个脚本 + // place a script outside subDir (inside dir) const outsideScript = join(dir, 'outside.ts') await writeFile(outsideScript, `return agent('x')`) - // host.cwd = subDir,scriptPath 是 subDir 外的绝对路径 + // host.cwd = subDir, scriptPath is an absolute path outside subDir const { ports, runStatus } = mockPorts(subDir, new Map()) const tool = createWorkflowTool(ports) const res = await tool.call( @@ -452,22 +452,22 @@ test('scriptPath 越界(resolve 后在 cwd 之外)→ 拒绝并报错(防 undefined, ) expect(res.data.output).toMatch(/^Error:/) - expect(res.data.output).toMatch(/越界|外|outside|contain/i) + expect(res.data.output).toMatch(/out of bounds|outside|not within/i) expect(runStatus.size).toBe(0) } finally { await rm(dir, { recursive: true, force: true }) } }) -test('name 含 ".." 路径段 → 拒绝(防路径遍历逃出 workflowDir)', async () => { +test('name contains ".." path segment → rejected (prevents path traversal escaping workflowDir)', async () => { const outer = await mkdtemp(join(tmpdir(), 'wf-outer-')) try { - // 在 outer 根下放置 evil.ts(在 .claude/workflows 之外) + // place evil.ts at outer root (outside .claude/workflows) await writeFile(join(outer, 'evil.ts'), `return agent('x')`) await mkdir(join(outer, '.claude', 'workflows'), { recursive: true }) const { ports, runStatus } = mockPorts(outer, new Map()) const tool = createWorkflowTool(ports) - // name = '../../evil' → join 后逃离 workflows 目录到 outer/evil.ts + // name = '../../evil' → after join escapes the workflows directory to outer/evil.ts const res = await tool.call( { name: '../../evil' }, undefined, @@ -481,7 +481,7 @@ test('name 含 ".." 路径段 → 拒绝(防路径遍历逃出 workflowDir)' } }) -test('name 含路径分隔符或为绝对路径 → 拒绝', async () => { +test('name contains path separators or is absolute → rejected', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) try { await mkdir(join(dir, '.claude', 'workflows'), { recursive: true }) @@ -501,7 +501,7 @@ test('name 含路径分隔符或为绝对路径 → 拒绝', async () => { } }) -test('returnValue 为对象 → complete(formatValue 走 JSON 分支)', async () => { +test('returnValue is an object → complete (formatValue takes JSON branch)', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) try { const { ports, runStatus } = mockPorts( diff --git a/packages/workflow-engine/src/__tests__/agentAdapter.test.ts b/packages/workflow-engine/src/__tests__/agentAdapter.test.ts index 0de4d92da..25c40fc4a 100644 --- a/packages/workflow-engine/src/__tests__/agentAdapter.test.ts +++ b/packages/workflow-engine/src/__tests__/agentAdapter.test.ts @@ -36,7 +36,7 @@ const CTX = { agentId: 1, } -test('resolve 默认走 default adapter,run 返回结果', async () => { +test('resolve goes to default adapter, run returns result', async () => { const reg = new AgentAdapterRegistry() .register(makeAdapter('a')) .register(makeAdapter('b')) @@ -46,7 +46,7 @@ test('resolve 默认走 default adapter,run 返回结果', async () => { expect(r.kind).toBe('ok') }) -test('route agentType 命中优先于 default', () => { +test('route agentType hit takes priority over default', () => { const reg = new AgentAdapterRegistry() .register(makeAdapter('default')) .register(makeAdapter('research')) @@ -56,7 +56,7 @@ test('route agentType 命中优先于 default', () => { expect(reg.resolve(P({ agentType: 'other' })).id).toBe('default') }) -test('route model 前缀匹配', () => { +test('route model prefix match', () => { const reg = new AgentAdapterRegistry() .register(makeAdapter('cheap')) .register(makeAdapter('strong')) @@ -64,10 +64,10 @@ test('route model 前缀匹配', () => { .default('cheap') expect(reg.resolve(P({ model: 'claude-opus-4' })).id).toBe('strong') expect(reg.resolve(P({ model: 'claude-sonnet-4' })).id).toBe('cheap') - expect(reg.resolve(P()).id).toBe('cheap') // 无 model → default + expect(reg.resolve(P()).id).toBe('cheap') // no model → default }) -test('route custom 谓词', () => { +test('route custom predicate', () => { const reg = new AgentAdapterRegistry() .register(makeAdapter('main')) .register(makeAdapter('special')) @@ -81,7 +81,7 @@ test('route custom 谓词', () => { expect(reg.resolve(P({ prompt: 'normal' })).id).toBe('main') }) -test('规则按顺序匹配(先命中先用)', () => { +test('rules match in order (first hit wins)', () => { const reg = new AgentAdapterRegistry() .register(makeAdapter('a')) .register(makeAdapter('b')) @@ -90,7 +90,7 @@ test('规则按顺序匹配(先命中先用)', () => { expect(reg.resolve(P({ agentType: 'x' })).id).toBe('a') }) -test('规则命中的 adapter 未注册 → 跳过该规则继续匹配', () => { +test('rule-matched adapter not registered → skip that rule and continue matching', () => { const reg = new AgentAdapterRegistry() .register(makeAdapter('real')) .route({ kind: 'agentType', agentType: 'x', adapter: 'ghost' }) @@ -98,12 +98,12 @@ test('规则命中的 adapter 未注册 → 跳过该规则继续匹配', () => expect(reg.resolve(P({ agentType: 'x' })).id).toBe('real') }) -test('无匹配且无 default → AdapterNotFoundError', () => { +test('no match and no default → AdapterNotFoundError', () => { const reg = new AgentAdapterRegistry().register(makeAdapter('a')) expect(() => reg.resolve(P())).toThrow(AdapterNotFoundError) }) -test('default 指向未注册的 adapter → 仍抛(不静默回退)', () => { +test('default points to an unregistered adapter → still throws (no silent fallback)', () => { const reg = new AgentAdapterRegistry() .register(makeAdapter('a')) .default('missing') @@ -118,7 +118,7 @@ test('has / get', () => { expect(reg.get('b')).toBeUndefined() }) -test('initializeAll / disposeAll 触发 lifecycle(跳过未实现)', async () => { +test('initializeAll / disposeAll triggers lifecycle (skips unimplemented)', async () => { const events: string[] = [] const withLifecycle: AgentAdapter = { id: 'a', @@ -133,7 +133,7 @@ test('initializeAll / disposeAll 触发 lifecycle(跳过未实现)', async ( events.push('dispose-a') }, } - const noLifecycle = makeAdapter('b') // 无 initialize/dispose + const noLifecycle = makeAdapter('b') // no initialize/dispose const reg = new AgentAdapterRegistry() .register(withLifecycle) .register(noLifecycle) @@ -142,7 +142,7 @@ test('initializeAll / disposeAll 触发 lifecycle(跳过未实现)', async ( expect(events).toEqual(['init-a', 'dispose-a']) }) -test('capabilities 声明可读', () => { +test('capabilities declaration is readable', () => { const adapter: AgentAdapter = { id: 'a', capabilities: { structuredOutput: true, tools: true, stream: false }, diff --git a/packages/workflow-engine/src/__tests__/agentId.test.ts b/packages/workflow-engine/src/__tests__/agentId.test.ts index b2bae9e73..e013c835c 100644 --- a/packages/workflow-engine/src/__tests__/agentId.test.ts +++ b/packages/workflow-engine/src/__tests__/agentId.test.ts @@ -46,7 +46,7 @@ function build(results: Map) { return { ctx, events, hooks: makeHooks(ctx, async () => null) } } -test('并发 agent 各自拿到唯一 agentId,started/done 配对', async () => { +test('concurrent agents each get a unique agentId, started/done are paired', async () => { const ok = (out: string): AgentRunResult => ({ kind: 'ok', output: out, @@ -71,7 +71,7 @@ test('并发 agent 各自拿到唯一 agentId,started/done 配对', async () = expect(ctx.resources.agentIdSeq.value).toBe(2) }) -test('agentId 单调递增', async () => { +test('agentId increases monotonically', async () => { const ok = (out: string): AgentRunResult => ({ kind: 'ok', output: out, diff --git a/packages/workflow-engine/src/__tests__/budget.test.ts b/packages/workflow-engine/src/__tests__/budget.test.ts index d84bdbacb..69e9026ce 100644 --- a/packages/workflow-engine/src/__tests__/budget.test.ts +++ b/packages/workflow-engine/src/__tests__/budget.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'bun:test' import { Budget, BudgetExhaustedError } from '../engine/budget.js' -test('total=null 时无限制', () => { +test('total=null means unlimited', () => { const b = new Budget(null) expect(b.total).toBeNull() expect(b.remaining()).toBe(Infinity) @@ -10,7 +10,7 @@ test('total=null 时无限制', () => { expect(() => b.assertCanSpend()).not.toThrow() }) -test('累加并触顶抛错', () => { +test('accumulates and throws when cap exceeded', () => { const b = new Budget(100) expect(b.remaining()).toBe(100) b.addOutputTokens(40) @@ -22,7 +22,7 @@ test('累加并触顶抛错', () => { expect(() => b.assertCanSpend()).toThrow(BudgetExhaustedError) }) -test('addOutputTokens 负值忽略', () => { +test('addOutputTokens ignores negative values', () => { const b = new Budget(100) b.addOutputTokens(-50) expect(b.spent()).toBe(0) diff --git a/packages/workflow-engine/src/__tests__/concurrency.test.ts b/packages/workflow-engine/src/__tests__/concurrency.test.ts index 0291bc6cf..98fbe1640 100644 --- a/packages/workflow-engine/src/__tests__/concurrency.test.ts +++ b/packages/workflow-engine/src/__tests__/concurrency.test.ts @@ -6,7 +6,7 @@ import { } from '../engine/concurrency.js' import { DEFAULT_MAX_CONCURRENCY, MAX_CONCURRENCY_CAP } from '../constants.js' -test('Semaphore 限制并发,permit 转移不泄漏', async () => { +test('Semaphore limits concurrency, permit transfer does not leak', async () => { const sem = new Semaphore(2) let active = 0 let peak = 0 @@ -21,15 +21,15 @@ test('Semaphore 限制并发,permit 转移不泄漏', async () => { release() } await Promise.all(Array.from({ length: 6 }, () => task())) - expect(peak).toBe(2) // 永不超过 permits + expect(peak).toBe(2) // never exceeds permits }) -test('maxConcurrency 返回 DEFAULT_MAX_CONCURRENCY (=3)', () => { +test('maxConcurrency returns DEFAULT_MAX_CONCURRENCY (=3)', () => { expect(maxConcurrency()).toBe(DEFAULT_MAX_CONCURRENCY) expect(maxConcurrency()).toBe(3) }) -test('clampMaxConcurrency:undefined/NaN→DEFAULT;<1→1;>CAP→CAP;正常原值', () => { +test('clampMaxConcurrency: undefined/NaN→DEFAULT; <1→1; >CAP→CAP; normal value kept', () => { expect(clampMaxConcurrency(undefined)).toBe(DEFAULT_MAX_CONCURRENCY) expect(clampMaxConcurrency(Number.NaN)).toBe(DEFAULT_MAX_CONCURRENCY) expect(clampMaxConcurrency(0)).toBe(1) @@ -40,18 +40,18 @@ test('clampMaxConcurrency:undefined/NaN→DEFAULT;<1→1;>CAP→CAP;正 expect(clampMaxConcurrency(5)).toBe(5) expect(clampMaxConcurrency(1)).toBe(1) expect(clampMaxConcurrency(MAX_CONCURRENCY_CAP)).toBe(MAX_CONCURRENCY_CAP) - // 小数截断(Semaphore 已有 Math.max(1, Math.floor);clampMaxConcurrency 显式 trunc) + // decimal truncation (Semaphore already does Math.max(1, Math.floor); clampMaxConcurrency explicitly truncs) expect(clampMaxConcurrency(2.9)).toBe(2) }) -test('Semaphore(0) 至少 1 permit,acquire 不阻塞', async () => { +test('Semaphore(0) has at least 1 permit, acquire does not block', async () => { const sem = new Semaphore(0) const release = await sem.acquire() expect(release).toBeTypeOf('function') release() }) -test('Semaphore 唤醒按 FIFO 顺序', async () => { +test('Semaphore wakes up in FIFO order', async () => { const sem = new Semaphore(1) const order: string[] = [] const first = await sem.acquire() @@ -80,40 +80,40 @@ test('Semaphore 唤醒按 FIFO 顺序', async () => { ;(await p2)() }) -test('Semaphore.acquire 传 aborted signal → 立即 reject,不消耗 permit', async () => { - // 修复 L:queued waiter 在 abort 时必须立即 reject 而非等 permit。 - // 否则一个被取消的 agent 阻塞在 acquire(),permit 被消耗(transfer 给已死的 waiter), - // 实际并发能力降低;最坏情况下所有 waiter 都被取消,semaphore 还在排队等死掉的 waiter。 +test('Semaphore.acquire with an aborted signal → immediately rejects, no permit consumed', async () => { + // Fix L: a queued waiter on abort must reject immediately instead of waiting for a permit. + // Otherwise a cancelled agent blocks on acquire(), the permit is consumed (transferred to a dead waiter), + // reducing actual concurrency capacity; in the worst case all waiters are cancelled while the semaphore still queues for dead waiters. const sem = new Semaphore(1) const ac = new AbortController() - // 占用唯一 permit + // occupy the only permit const first = await sem.acquire() - // 排队的 waiter + // queued waiter const queued = sem.acquire(ac.signal) await new Promise(r => { setTimeout(r, 5) }) - // abort → waiter 应立即 reject + // abort → waiter should reject immediately ac.abort() await expect(queued).rejects.toThrow() - // permit 无泄漏:释放 first 后,新 acquire 应能立即拿到(无 stale waiter 抢占) + // no permit leak: after releasing first, a new acquire should get it immediately (no stale waiter preemption) first() const third = await sem.acquire() expect(third).toBeTypeOf('function') third() }) -test('Semaphore.acquire 传已 aborted 的 signal → 同步 reject', async () => { +test('Semaphore.acquire with an already aborted signal → synchronous reject', async () => { const sem = new Semaphore(1) const ac = new AbortController() ac.abort() - // 信号已 aborted,即使有 permit 也不应 acquire(语义:调用者已取消) - // 注意:当前实现先看 available,可能直接返回。本测试 lock "先 check abort"。 - // 若实现选择"permit 可用时优先发放",则此测试改为:acquire 成功,调用者后续检查 abort。 - // 当前实现选择前者:aborted signal 立即抛错,避免已死 agent 拿 permit。 + // signal already aborted, should not acquire even if a permit is available (semantics: caller already cancelled) + // Note: current implementation checks available first and may return directly. This test locks "check abort first". + // If the implementation chose "prefer granting when permit available", this test would change to: acquire succeeds, caller checks abort later. + // Current implementation chose the former: aborted signal throws immediately, preventing dead agents from grabbing permits. await expect(sem.acquire(ac.signal)).rejects.toThrow() }) diff --git a/packages/workflow-engine/src/__tests__/context.test.ts b/packages/workflow-engine/src/__tests__/context.test.ts index 1ea7075ef..d00e9d676 100644 --- a/packages/workflow-engine/src/__tests__/context.test.ts +++ b/packages/workflow-engine/src/__tests__/context.test.ts @@ -33,15 +33,15 @@ function mockPorts(): WorkflowPorts { } } -test('createSharedResources 初始化预算与计数', () => { +test('createSharedResources initializes budget and counts', () => { const r = createSharedResources(100) expect(r.budget.total).toBe(100) expect(r.agentCountBox.value).toBe(0) expect(r.depth).toBe(0) }) -test('createSharedResources:maxConcurrency 控制 semaphore permits', async () => { - // 默认 permits = DEFAULT_MAX_CONCURRENCY = 3:4 次 acquire 后第 4 次 pending +test('createSharedResources: maxConcurrency controls semaphore permits', async () => { + // default permits = DEFAULT_MAX_CONCURRENCY = 3: after 4 acquires the 4th is pending const r1 = createSharedResources(null) const releases1: Array<() => void> = [] for (let i = 0; i < 3; i++) releases1.push(await r1.semaphore.acquire()) @@ -54,11 +54,11 @@ test('createSharedResources:maxConcurrency 控制 semaphore permits', async () setTimeout(res, 5) }) expect(fourthResolved).toBe(false) - releases1[0]!() // 释放一个,第四个应被唤醒 + releases1[0]!() // release one, the fourth should be woken up releases1.push(await pending) for (const rel of releases1) rel() - // 显式 maxConcurrency=2:第 3 次 acquire pending + // explicit maxConcurrency=2: the 3rd acquire is pending const r2 = createSharedResources(null, 2) const releases2: Array<() => void> = [] releases2.push(await r2.semaphore.acquire()) @@ -77,7 +77,7 @@ test('createSharedResources:maxConcurrency 控制 semaphore permits', async () for (const rel of releases2) rel() }) -test('createEngineContext 透传 maxConcurrency 到 resources.semaphore', async () => { +test('createEngineContext passes maxConcurrency through to resources.semaphore', async () => { const ctx = createEngineContext({ ports: mockPorts(), host: createHostHandle(null), @@ -88,7 +88,7 @@ test('createEngineContext 透传 maxConcurrency 到 resources.semaphore', async budgetTotal: null, maxConcurrency: 1, }) - // maxConcurrency=1:第二次 acquire 应 pending + // maxConcurrency=1: the second acquire should be pending const first = await ctx.resources.semaphore.acquire() let secondResolved = false const pending = ctx.resources.semaphore.acquire().then(r => { @@ -103,7 +103,7 @@ test('createEngineContext 透传 maxConcurrency 到 resources.semaphore', async await pending }) -test('createEngineContext 复制 journal 并重置游标', () => { +test('createEngineContext copies journal and resets cursor', () => { const journal = [ { key: 'k', @@ -126,13 +126,13 @@ test('createEngineContext 复制 journal 并重置游标', () => { expect(ctx.journalInvalidated).toBe(false) }) -test('createBufferingEmitter 收集事件', () => { +test('createBufferingEmitter collects events', () => { const { emitter, events } = createBufferingEmitter() emitter.emit({ type: 'log', runId: 'r', message: 'hi' }) expect(events).toHaveLength(1) }) -test('WorkflowError 可识别', () => { +test('WorkflowError is recognizable', () => { const e = new WorkflowError('boom') expect(e).toBeInstanceOf(Error) expect(e.message).toBe('boom') diff --git a/packages/workflow-engine/src/__tests__/errors.test.ts b/packages/workflow-engine/src/__tests__/errors.test.ts index 496ce6831..1c3f9fa52 100644 --- a/packages/workflow-engine/src/__tests__/errors.test.ts +++ b/packages/workflow-engine/src/__tests__/errors.test.ts @@ -1,21 +1,21 @@ import { expect, test } from 'bun:test' import { WorkflowError, WorkflowAbortedError } from '../engine/errors.js' -test('WorkflowError 携带消息与 name', () => { - const e = new WorkflowError('脚本错误') +test('WorkflowError carries message and name', () => { + const e = new WorkflowError('script error') expect(e).toBeInstanceOf(Error) - expect(e.message).toBe('脚本错误') + expect(e.message).toBe('script error') expect(e.name).toBe('WorkflowError') }) -test('WorkflowAbortedError 是可识别的取消错误', () => { +test('WorkflowAbortedError is a recognizable cancellation error', () => { const e = new WorkflowAbortedError() expect(e).toBeInstanceOf(Error) expect(e.name).toBe('WorkflowAbortedError') expect(e.message).toBeTruthy() }) -test('两类错误可被 instanceof 区分(互不混淆)', () => { +test('the two error types can be distinguished by instanceof (not confused)', () => { const a = new WorkflowError('x') const b = new WorkflowAbortedError() expect(a).toBeInstanceOf(WorkflowError) @@ -24,7 +24,7 @@ test('两类错误可被 instanceof 区分(互不混淆)', () => { expect(b).not.toBeInstanceOf(WorkflowError) }) -test('可作为普通 Error 在 catch 中捕获', () => { +test('can be caught as a plain Error in a catch block', () => { const throwIt = (): never => { throw new WorkflowAbortedError() } diff --git a/packages/workflow-engine/src/__tests__/events.test.ts b/packages/workflow-engine/src/__tests__/events.test.ts index b184ae04b..2106e57e3 100644 --- a/packages/workflow-engine/src/__tests__/events.test.ts +++ b/packages/workflow-engine/src/__tests__/events.test.ts @@ -10,7 +10,7 @@ const log = (message: string): ProgressEvent => const phase = (p: string): ProgressEvent => ({ type: 'phase_started', runId: 'r', phase: p }) as ProgressEvent -test('createBufferingEmitter 按序收集所有事件', () => { +test('createBufferingEmitter collects all events in order', () => { const { emitter, events } = createBufferingEmitter() emitter.emit(log('a')) emitter.emit(phase('P')) @@ -19,12 +19,12 @@ test('createBufferingEmitter 按序收集所有事件', () => { expect(events[1]).toEqual(phase('P')) }) -test('createBufferingEmitter emit 返回 void(无返回值)', () => { +test('createBufferingEmitter emit returns void (no return value)', () => { const { emitter } = createBufferingEmitter() expect(emitter.emit(log('x'))).toBeUndefined() }) -test('createBufferingEmitter 各自独立(不共享缓冲)', () => { +test('createBufferingEmitter instances are independent (no shared buffer)', () => { const a = createBufferingEmitter() const b = createBufferingEmitter() a.emitter.emit(log('1')) @@ -32,7 +32,7 @@ test('createBufferingEmitter 各自独立(不共享缓冲)', () => { expect(b.events).toHaveLength(0) }) -test('createProgressEmitter 转发事件到回调(按序、不缓冲)', () => { +test('createProgressEmitter forwards events to callback (in order, no buffering)', () => { const received: ProgressEvent[] = [] const emitter = createProgressEmitter(e => void received.push(e)) emitter.emit(log('a')) @@ -40,12 +40,12 @@ test('createProgressEmitter 转发事件到回调(按序、不缓冲)', () = expect(received).toEqual([log('a'), log('b')]) }) -test('createProgressEmitter 回调同步触发', () => { +test('createProgressEmitter triggers callback synchronously', () => { let seen = '' const emitter = createProgressEmitter(e => { seen = (e as { message: string }).message }) emitter.emit(log('sync')) - // emit 返回前回调已执行 + // callback already executed before emit returns expect(seen).toBe('sync') }) diff --git a/packages/workflow-engine/src/__tests__/hooks.test.ts b/packages/workflow-engine/src/__tests__/hooks.test.ts index 6d5432279..9b58b81d1 100644 --- a/packages/workflow-engine/src/__tests__/hooks.test.ts +++ b/packages/workflow-engine/src/__tests__/hooks.test.ts @@ -24,8 +24,8 @@ type CtxOverrides = Partial<{ truncated: string[] agentAdapterRegistry: AgentAdapterRegistry loggerWarn: (msg: string) => void - // taskRegistrar 的 agent 级 abort 绑定(agent kill 桥接)。 - // 提供后 buildCtx 注入到 ports.taskRegistrar;hooks.agent 把闭包塞进 adapterCtx。 + // taskRegistrar agent-level abort binding (agent kill bridge). + // When provided, buildCtx injects it into ports.taskRegistrar; hooks.agent pushes the closure into adapterCtx. registerAgentAbort: ( runId: string, agentId: number, @@ -98,7 +98,7 @@ function buildCtx(overrides: CtxOverrides = {}): { return { ctx, events, hooks: makeHooks(ctx, noopSub) } } -test('agent 返回文本结果并计数', async () => { +test('agent returns text result and counts', async () => { const { ctx, hooks } = buildCtx({ agentResults: new Map([ ['hi', { kind: 'ok', output: 'hello', usage: { outputTokens: 5 } }], @@ -109,7 +109,7 @@ test('agent 返回文本结果并计数', async () => { expect(ctx.resources.agentCountBox.value).toBe(1) }) -test('agent skipped → null 且不计数', async () => { +test('agent skipped → null and not counted', async () => { const { hooks } = buildCtx({ agentResults: new Map([['hi', { kind: 'skipped' }]]), }) @@ -123,9 +123,9 @@ test('agent dead → null', async () => { expect(await hooks.agent('hi')).toBeNull() }) -// 重试:dead 或 非 abort throw 都给一次重试机会;WorkflowAbortedError(kill)不重试。 -// 重试仍失败:dead 保持 dead;throw 降级为 dead(不击穿 workflow,hooks.agent 返 null)。 -test('agent dead → 重试一次成功 → ok', async () => { +// Retry: dead or non-abort throw both get one retry chance; WorkflowAbortedError (kill) is not retried. +// Retry still fails: dead stays dead; throw degrades to dead (does not break the workflow, hooks.agent returns null). +test('agent dead → retry once succeeds → ok', async () => { let calls = 0 const { hooks } = buildCtx({ runner: async () => { @@ -143,7 +143,7 @@ test('agent dead → 重试一次成功 → ok', async () => { expect(calls).toBe(2) }) -test('agent dead → 重试仍 dead → 最终 null(dead 保持 dead)', async () => { +test('agent dead → retry still dead → final null (dead stays dead)', async () => { let calls = 0 const { hooks } = buildCtx({ runner: async () => { @@ -156,7 +156,7 @@ test('agent dead → 重试仍 dead → 最终 null(dead 保持 dead)', asyn expect(calls).toBe(2) }) -test('agent 非 abort throw → 重试一次成功 → ok', async () => { +test('agent non-abort throw → retry once succeeds → ok', async () => { let calls = 0 const { hooks } = buildCtx({ runner: async () => { @@ -174,7 +174,7 @@ test('agent 非 abort throw → 重试一次成功 → ok', async () => { expect(calls).toBe(2) }) -test('agent 非 abort throw → 重试仍 throw → 降级 dead(返 null,不击穿 workflow)', async () => { +test('agent non-abort throw → retry still throws → degrade to dead (returns null, does not break workflow)', async () => { let calls = 0 const { hooks } = buildCtx({ runner: async () => { @@ -187,7 +187,7 @@ test('agent 非 abort throw → 重试仍 throw → 降级 dead(返 null,不 expect(calls).toBe(2) }) -test('agent throw WorkflowAbortedError → 不重试,直接 rethrow(kill 不容许重试)', async () => { +test('agent throw WorkflowAbortedError → no retry, rethrow directly (kill does not allow retry)', async () => { let calls = 0 const { hooks } = buildCtx({ runner: async () => { @@ -199,7 +199,7 @@ test('agent throw WorkflowAbortedError → 不重试,直接 rethrow(kill 不 expect(calls).toBe(1) }) -test('agent ok → 不重试(calls=1,省一次 backend 往返)', async () => { +test('agent ok → no retry (calls=1, saves a backend round-trip)', async () => { let calls = 0 const { hooks } = buildCtx({ runner: async () => { @@ -215,7 +215,7 @@ test('agent ok → 不重试(calls=1,省一次 backend 往返)', async () expect(calls).toBe(1) }) -test('agent skipped → 不重试(用户主动 skip,不重试)', async () => { +test('agent skipped → no retry (user actively skips, no retry)', async () => { let calls = 0 const { hooks } = buildCtx({ runner: async () => { @@ -227,7 +227,7 @@ test('agent skipped → 不重试(用户主动 skip,不重试)', async () expect(calls).toBe(1) }) -test('agent journal 命中时不调用 runner', async () => { +test('agent journal hit does not call runner', async () => { let called = 0 const { emitter } = createBufferingEmitter() const ports: WorkflowPorts = { @@ -280,13 +280,13 @@ test('agent journal 命中时不调用 runner', async () => { expect(called).toBe(0) }) -test('agent 超过总数上限抛错', async () => { +test('agent exceeding total cap throws', async () => { const { hooks, ctx } = buildCtx() ctx.resources.agentCountBox.value = 1000 await expect(hooks.agent('hi')).rejects.toThrow(WorkflowError) }) -test('parallel 单项抛错 → null,其余保留', async () => { +test('parallel single item throws → null, others kept', async () => { const { hooks } = buildCtx() const out = await hooks.parallel([ async () => 'a', @@ -298,7 +298,7 @@ test('parallel 单项抛错 → null,其余保留', async () => { expect(out).toEqual(['a', null, 'c']) }) -test('parallel 单项抛错 → logger.warn 记录失败原因', async () => { +test('parallel single item throws → logger.warn records the failure reason', async () => { const warns: string[] = [] const { hooks } = buildCtx({ loggerWarn: msg => warns.push(msg) }) await hooks.parallel([ @@ -312,7 +312,7 @@ test('parallel 单项抛错 → logger.warn 记录失败原因', async () => { expect(warns[0]).toMatch(/boom-x/) }) -test('pipeline 逐 stage 链式,stage 抛错 → null', async () => { +test('pipeline chains stage by stage, stage throws → null', async () => { const { hooks } = buildCtx() const out = await hooks.pipeline( [1, 2], @@ -328,7 +328,7 @@ test('pipeline 逐 stage 链式,stage 抛错 → null', async () => { expect(out2).toEqual([null]) }) -test('pipeline stage 抛错 → logger.warn 记录失败原因', async () => { +test('pipeline stage throws → logger.warn records the failure reason', async () => { const warns: string[] = [] const { hooks } = buildCtx({ loggerWarn: msg => warns.push(msg) }) await hooks.pipeline( @@ -340,14 +340,14 @@ test('pipeline stage 抛错 → logger.warn 记录失败原因', async () => { expect(warns[0]).toMatch(/stage-boom/) }) -test('pipeline 超 4096 抛错', async () => { +test('pipeline over 4096 throws', async () => { const { hooks } = buildCtx() await expect( hooks.pipeline(Array(4097), () => Promise.resolve(1)), ).rejects.toThrow(WorkflowError) }) -test('phase 切换发射 phase_started/done;log 发射 log', () => { +test('phase switch emits phase_started/done; log emits log', () => { const { hooks, events } = buildCtx() hooks.phase('A') hooks.log('hello') @@ -364,9 +364,9 @@ test('phase 切换发射 phase_started/done;log 发射 log', () => { ) }) -// ---- 边界与错误路径 ---- +// ---- boundary and error paths ---- -test('agent dead 也计入 agentCountBox', async () => { +test('agent dead also counts in agentCountBox', async () => { const { hooks, ctx } = buildCtx({ agentResults: new Map([['x', { kind: 'dead' }]]), }) @@ -374,7 +374,7 @@ test('agent dead 也计入 agentCountBox', async () => { expect(ctx.resources.agentCountBox.value).toBe(1) }) -test('agent pendingAction=skip → null、不调 runner、不计数', async () => { +test('agent pendingAction=skip → null, does not call runner, not counted', async () => { let called = 0 const { hooks, ctx } = buildCtx({ pending: { kind: 'skip' }, @@ -388,7 +388,7 @@ test('agent pendingAction=skip → null、不调 runner、不计数', async () = expect(ctx.resources.agentCountBox.value).toBe(0) }) -test('agent journal key 发散 → invalidate 并 truncate', async () => { +test('agent journal key diverges → invalidate and truncate', async () => { const truncated: string[] = [] const { hooks, ctx } = buildCtx({ runner: async () => ({ @@ -411,7 +411,7 @@ test('agent journal key 发散 → invalidate 并 truncate', async () => { expect(ctx.journalInvalidated).toBe(true) }) -test('agent 预算耗尽时抛错', async () => { +test('agent throws when budget exhausted', async () => { const { hooks, ctx } = buildCtx({ budgetTotal: 10, runner: async () => ({ @@ -424,27 +424,27 @@ test('agent 预算耗尽时抛错', async () => { await expect(hooks.agent('x')).rejects.toThrow() }) -test('agent 预算检查在 semaphore 临界区内(queued waiter 看到最新 spent)', async () => { - // 当 semaphore capacity < parallel agent 数时,部分 agent 会排队。 - // 旧 bug:assertCanSpend 在 acquire 之前,所有 waiter 入队时 spent=0 都过检; - // 后续 permit 释放后 waiter 直接跑 runner、扣预算,不再 re-check → 全部超支。 - // 修复:assertCanSpend 移入临界区,waiter 被唤醒后先看 spent 再决定是否跑。 - // 强制 capacity=1(serializing semaphore)确保 N>1 个 agent 必须排队。 +test('agent budget check inside semaphore critical section (queued waiter sees latest spent)', async () => { + // When semaphore capacity < parallel agent count, some agents will queue. + // Old bug: assertCanSpend was before acquire, all waiters entered the queue with spent=0 and passed the check; + // after permits released waiters ran the runner and deducted the budget without re-checking → all over-spent. + // Fix: assertCanSpend moved into the critical section; waiters check spent after being woken before deciding to run. + // Force capacity=1 (serializing semaphore) to ensure N>1 agents must queue. const { hooks, ctx } = buildCtx({ budgetTotal: 10, runner: async () => { - // 让 runner 慢一点,确保 waiter 真的排队 + // make the runner a bit slow to ensure waiters truly queue await new Promise(r => { setTimeout(r, 5) }) return { kind: 'ok', output: 'x', - usage: { outputTokens: 6 }, // 每次 6 token,2 次即超 10 + usage: { outputTokens: 6 }, // 6 tokens each, 2 runs exceed 10 } }, }) - // 用单 permit semaphore 替换默认的,强制序列化 + // replace the default semaphore with a single-permit one, forcing serialization ctx.resources.semaphore = new Semaphore(1) const results = await hooks.parallel([ () => hooks.agent('a'), @@ -452,9 +452,9 @@ test('agent 预算检查在 semaphore 临界区内(queued waiter 看到最新 () => hooks.agent('c'), () => hooks.agent('d'), ]) - // 至少 1 个 agent 被 parallel catch 成 null(assertCanSpend 抛错) + // at least 1 agent is caught as null by parallel (assertCanSpend throws) expect(results.some(r => r === null)).toBe(true) - // 不应 4 个全跑扣 24;上限是 at-most-one-over(前两个扣 12,后两个被拦) + // not all 4 should run and spend 24; the cap is at-most-one-over (first two spend 12, last two blocked) expect(ctx.resources.budget.spent()).toBeLessThanOrEqual(12) }) @@ -472,20 +472,20 @@ test('agent signal aborted → WorkflowAbortedError', async () => { await expect(hooks.agent('x')).rejects.toThrow(WorkflowAbortedError) }) -test('parallel 超过 4096 项抛错', async () => { +test('parallel over 4096 items throws', async () => { const { hooks } = buildCtx() await expect( hooks.parallel(Array.from({ length: 4097 }, () => async () => 1)), ).rejects.toThrow(WorkflowError) }) -test('workflow() 嵌套超过一层抛错', async () => { +test('workflow() nesting beyond one level throws', async () => { const { hooks, ctx } = buildCtx() ctx.resources.depth = 1 await expect(hooks.workflow('child')).rejects.toThrow(WorkflowError) }) -test('agent 并发受 semaphore 限制(不超 maxConcurrency)', async () => { +test('agent concurrency bounded by semaphore (does not exceed maxConcurrency)', async () => { let active = 0 let peak = 0 const { hooks } = buildCtx({ @@ -503,7 +503,7 @@ test('agent 并发受 semaphore 限制(不超 maxConcurrency)', async () => expect(peak).toBeLessThanOrEqual(maxConcurrency()) }) -test('agentAdapterRegistry 优先于 agentRunner(按路由分发到 adapter)', async () => { +test('agentAdapterRegistry takes priority over agentRunner (dispatched to adapter by route)', async () => { const called: string[] = [] const registry = new AgentAdapterRegistry() .register({ @@ -530,8 +530,8 @@ test('agentAdapterRegistry 优先于 agentRunner(按路由分发到 adapter) expect(called).toEqual(['adapter']) }) -test('agentAdapterRegistry resolve 抛错 → agent 上抛(workflow failed)', async () => { - const registry = new AgentAdapterRegistry().default('missing') // 未注册 +test('agentAdapterRegistry resolve throws → agent rethrows (workflow failed)', async () => { + const registry = new AgentAdapterRegistry().default('missing') // not registered const { hooks } = buildCtx({ agentAdapterRegistry: registry, runner: async () => ({ @@ -543,17 +543,17 @@ test('agentAdapterRegistry resolve 抛错 → agent 上抛(workflow failed)' await expect(hooks.agent('x')).rejects.toThrow() }) -// service.kill(runId, agentId) 桥接:hooks.agent 必须把 taskRegistrar 的 -// registerAgentAbort/unregisterAgentAbort 注入 adapterCtx(绑定当前 runId)。 -// backend 据此把 agentAbort controller 塞进 Map,service.kill 据 agentId 精确 abort。 -test('agentAdapter ctx 注入 registerAgentAbort/unregisterAgentAbort(绑定 runId 转发 taskRegistrar)', async () => { +// service.kill(runId, agentId) bridge: hooks.agent must inject taskRegistrar's +// registerAgentAbort/unregisterAgentAbort into adapterCtx (bound to the current runId). +// The backend puts the agentAbort controller into a Map based on this; service.kill aborts precisely by agentId. +test('agentAdapter ctx injects registerAgentAbort/unregisterAgentAbort (bound to runId, forwards to taskRegistrar)', async () => { const registered: Array<{ runId: string agentId: number controller: AbortController }> = [] const unregistered: Array<{ runId: string; agentId: number }> = [] - // 捕获 hooks 传给 adapter 的 ctx(验证 register/unregister 已注入且绑定 runId) + // capture the ctx hooks pass to the adapter (verify register/unregister are injected and bound to runId) let capturedCtx: { registerAgentAbort?: (id: number, ac: AbortController) => void unregisterAgentAbort?: (id: number) => void @@ -578,12 +578,12 @@ test('agentAdapter ctx 注入 registerAgentAbort/unregisterAgentAbort(绑定 r unregistered.push({ runId, agentId }), }) await hooks.agent('x') - // ctx 含 register/unregister(闭包绑定 runId='r1') + // ctx contains register/unregister (closure bound to runId='r1') expect(capturedCtx).not.toBeNull() expect(typeof capturedCtx!.registerAgentAbort).toBe('function') expect(typeof capturedCtx!.unregisterAgentAbort).toBe('function') - // 模拟 backend 调用:注入的闭包把 (agentId, controller) 转发到 taskRegistrar, - // 并自动补 runId='r1'(backend 不需要知道 runId) + // simulate backend call: the injected closure forwards (agentId, controller) to taskRegistrar, + // and auto-fills runId='r1' (backend does not need to know runId) const ac = new AbortController() capturedCtx!.registerAgentAbort!(7, ac) capturedCtx!.unregisterAgentAbort!(7) @@ -591,9 +591,9 @@ test('agentAdapter ctx 注入 registerAgentAbort/unregisterAgentAbort(绑定 r expect(unregistered).toEqual([{ runId: 'r1', agentId: 7 }]) }) -test('taskRegistrar 未提供 registerAgentAbort → adapterCtx 也不含(hooks 不报错)', async () => { - // 不传 registerAgentAbort/unregisterAgentAbort overrides → buildCtx 也不注入 taskRegistrar - // hooks 用 optional chaining 跳过,adapterCtx 不含这两个字段 +test('taskRegistrar does not provide registerAgentAbort → adapterCtx also lacks it (hooks do not error)', async () => { + // without registerAgentAbort/unregisterAgentAbort overrides → buildCtx does not inject taskRegistrar either + // hooks skip via optional chaining; adapterCtx lacks these two fields let capturedCtx: object | null = null const registry = new AgentAdapterRegistry() .register({ diff --git a/packages/workflow-engine/src/__tests__/index.test.ts b/packages/workflow-engine/src/__tests__/index.test.ts index 87d361fe0..c4151ca07 100644 --- a/packages/workflow-engine/src/__tests__/index.test.ts +++ b/packages/workflow-engine/src/__tests__/index.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'bun:test' import * as wf from '../index.js' -test('引擎核心 API 完整导出', () => { +test('engine core API fully exported', () => { expect(typeof wf.runWorkflow).toBe('function') expect(typeof wf.parseScript).toBe('function') expect(typeof wf.extractMeta).toBe('function') @@ -10,13 +10,13 @@ test('引擎核心 API 完整导出', () => { expect(typeof wf.createSharedResources).toBe('function') }) -test('端口 / host API 完整导出', () => { +test('ports / host API fully exported', () => { expect(typeof wf.createHostHandle).toBe('function') expect(typeof wf.isHostHandle).toBe('function') expect(typeof wf.unwrapHostHandle).toBe('function') }) -test('持久化 / 结构化 / 命名 workflow / 进度 API 完整导出', () => { +test('persistence / structured output / named workflow / progress API fully exported', () => { expect(typeof wf.createFileJournalStore).toBe('function') expect(typeof wf.agentCallKey).toBe('function') expect(typeof wf.validateAgainstSchema).toBe('function') @@ -26,7 +26,7 @@ test('持久化 / 结构化 / 命名 workflow / 进度 API 完整导出', () => expect(typeof wf.createProgressEmitter).toBe('function') }) -test('并发 / 预算 / 错误类完整导出', () => { +test('concurrency / budget / error classes fully exported', () => { expect(typeof wf.Semaphore).toBe('function') expect(typeof wf.maxConcurrency).toBe('function') expect(typeof wf.clampMaxConcurrency).toBe('function') @@ -37,13 +37,13 @@ test('并发 / 预算 / 错误类完整导出', () => { expect(typeof wf.ScriptError).toBe('function') }) -test('工具描述符与输入 schema 导出', () => { +test('tool descriptor and input schema exported', () => { expect(typeof wf.createWorkflowTool).toBe('function') expect(typeof wf.workflowInputSchema).toBe('object') expect(wf.WORKFLOW_TOOL_NAME).toBe('Workflow') }) -test('引擎常量值稳定', () => { +test('engine constant values are stable', () => { expect(wf.WORKFLOW_DIR_NAME).toBe('.claude/workflows') expect(wf.WORKFLOW_RUNS_DIR).toBe('.claude/workflow-runs') expect(wf.WORKFLOW_TOOL_NAME).toBe('Workflow') @@ -54,7 +54,7 @@ test('引擎常量值稳定', () => { expect(wf.WORKFLOW_SCRIPT_EXTENSIONS).toEqual(['.ts', '.js', '.mjs']) }) -test('createWorkflowTool 返回完整描述符形状', () => { +test('createWorkflowTool returns complete descriptor shape', () => { const tool = wf.createWorkflowTool({ agentRunner: { runAgentToResult: async () => ({ kind: 'dead' }) }, progressEmitter: { emit: () => {} }, diff --git a/packages/workflow-engine/src/__tests__/integration.test.ts b/packages/workflow-engine/src/__tests__/integration.test.ts index 6d87fce1c..bfc70e69a 100644 --- a/packages/workflow-engine/src/__tests__/integration.test.ts +++ b/packages/workflow-engine/src/__tests__/integration.test.ts @@ -1,7 +1,7 @@ /** - * 集成测试:用忠实 mock adapter 跑「规范 workflow 脚本」(来自 Workflow 工具定义的 - * canonical 模式:pipeline 无屏障 + parallel 屏障 + agent(schema) + phase)。 - * 验证引擎与真实 workflow 脚本语义兼容。 + * Integration test: runs the canonical workflow script (canonical pattern from the Workflow tool definition: + * pipeline without barrier + parallel barrier + agent(schema) + phase) with a faithful mock adapter. + * Verifies the engine is semantically compatible with real workflow scripts. */ import { expect, test } from 'bun:test' import { mkdtemp, rm } from 'node:fs/promises' @@ -64,7 +64,7 @@ function canonicalPorts(runsDir: string): { return { ports, events, agentCalls } } -// 规范 review 模式(pipeline→parallel→verify→synthesize),逐字采用 Workflow 工具定义的写法。 +// canonical review pattern (pipeline→parallel→verify→synthesize), verbatim from the Workflow tool definition. const CANONICAL_REVIEW_SCRIPT = ` export const meta = { name: 'review-changes', @@ -94,7 +94,7 @@ const confirmed = all.filter(f => f.verdict && f.verdict.isReal) return { confirmed, total: all.length } ` -test('canonical review 脚本端到端兼容', async () => { +test('canonical review script end-to-end compatibility', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-int-')) try { const { ports, events, agentCalls } = canonicalPorts(dir) @@ -110,10 +110,10 @@ test('canonical review 脚本端到端兼容', async () => { expect(result.status).toBe('completed') const ret = result.returnValue as { confirmed: unknown[]; total: number } - // 2 维度 × 1 finding,全部 isReal=true → confirmed=2, total=2 + // 2 dimensions × 1 finding, all isReal=true → confirmed=2, total=2 expect(ret.total).toBe(2) expect(ret.confirmed).toHaveLength(2) - // 2 个 review agent + 2 个 verify agent = 4 + // 2 review agents + 2 verify agents = 4 expect(agentCalls).toHaveLength(4) expect(agentCalls.filter(c => c.prompt.startsWith('review-'))).toHaveLength( 2, @@ -121,7 +121,7 @@ test('canonical review 脚本端到端兼容', async () => { expect(agentCalls.filter(c => c.prompt.startsWith('verify'))).toHaveLength( 2, ) - // 进度事件:run_started/done + phase Review/Verify + agent started/done + // progress events: run_started/done + phase Review/Verify + agent started/done expect( events.some( e => e.type === 'run_started' && e.workflowName === 'review-changes', @@ -130,7 +130,7 @@ test('canonical review 脚本端到端兼容', async () => { expect( events.some(e => e.type === 'run_done' && e.status === 'completed'), ).toBe(true) - // 脚本显式调用一次 phase('Review');verify agent 的 phase:'Verify' 是展示标签,不发 phase_started + // script explicitly calls phase('Review') once; the verify agent's phase:'Verify' is a display label, does not emit phase_started expect( events.filter(e => e.type === 'phase_started' && e.phase === 'Review'), ).toHaveLength(1) @@ -140,7 +140,7 @@ test('canonical review 脚本端到端兼容', async () => { } }) -test('loop-until-dry 模式:连续两轮无新发现即收敛', async () => { +test('loop-until-dry pattern: two consecutive rounds with no new findings converges', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-int-')) try { let round = 0 @@ -151,7 +151,7 @@ test('loop-until-dry 模式:连续两轮无新发现即收敛', async () => { p: AgentRunParams, ): Promise => { round++ - // 第 1-2 轮返回发现,第 3 轮起返回空 → 收敛 + // rounds 1-2 return findings, round 3+ returns empty → converges const found = round <= 2 ? [{ b: round }] : [] return { kind: 'ok', @@ -202,10 +202,10 @@ test('loop-until-dry 模式:连续两轮无新发现即收敛', async () => { }) expect(result.status).toBe('completed') const ret = result.returnValue as { confirmed: { b: number }[] } - // 第1轮发现{b:1},第2轮发现{b:2}(fresh,因 seen=[1]),第3轮 found{b:3}? - // mock 按 round 计数:round1→{b:1}, round2→{b:2}, round3→[](found空) - // 但 round2 found=[{b:2}], seen=[1], fresh=[{b:2}] → confirmed=[{b:1},{b:2}], dry=0 - // round3 found=[] → fresh=[] → dry=1; round4 found=[] → dry=2 → 退出 + // round1 finds {b:1}, round2 finds {b:2} (fresh, since seen=[1]), round3 found{b:3}? + // mock counts by round: round1→{b:1}, round2→{b:2}, round3→[] (found empty) + // but round2 found=[{b:2}], seen=[1], fresh=[{b:2}] → confirmed=[{b:1},{b:2}], dry=0 + // round3 found=[] → fresh=[] → dry=1; round4 found=[] → dry=2 → exits expect(ret.confirmed).toHaveLength(2) expect( events.some(e => e.type === 'run_done' && e.status === 'completed'), @@ -215,7 +215,7 @@ test('loop-until-dry 模式:连续两轮无新发现即收敛', async () => { } }) -test('resume 兼容:二次运行 journal 命中,agent 不重跑', async () => { +test('resume compatibility: second run hits journal, agents do not re-run', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-int-')) try { let calls = 0 @@ -249,7 +249,7 @@ test('resume 兼容:二次运行 journal 命中,agent 不重跑', async () = const b = await agent('do-b') return { a, b } ` - // 第一次运行:2 个 agent 现场跑 + // first run: 2 agents run live const first = await runWorkflow({ script, runId: 'int-3', @@ -262,7 +262,7 @@ test('resume 兼容:二次运行 journal 命中,agent 不重跑', async () = expect(first.status).toBe('completed') expect(calls).toBe(2) - // resume 同 runId:journal 命中,不重跑 + // resume same runId: journal hit, no re-run calls = 0 const resumed = await runWorkflow({ script, diff --git a/packages/workflow-engine/src/__tests__/journal.test.ts b/packages/workflow-engine/src/__tests__/journal.test.ts index dc821eb98..a35225c9d 100644 --- a/packages/workflow-engine/src/__tests__/journal.test.ts +++ b/packages/workflow-engine/src/__tests__/journal.test.ts @@ -7,21 +7,21 @@ import type { AgentRunParams } from '../types.js' const base: AgentRunParams = { prompt: 'do something' } -test('agentCallKey 对相同 prompt+params 稳定', () => { +test('agentCallKey stable for same prompt+params', () => { expect(agentCallKey('p', base)).toBe(agentCallKey('p', base)) }) -test('agentCallKey 随 prompt 变化', () => { +test('agentCallKey varies with prompt', () => { expect(agentCallKey('p1', base)).not.toBe(agentCallKey('p2', base)) }) -test('agentCallKey 忽略纯展示字段 label/phase', () => { +test('agentCallKey ignores display-only fields label/phase', () => { const a = agentCallKey('p', { ...base, label: 'A', phase: 'ph1' }) const b = agentCallKey('p', { ...base, label: 'B', phase: 'ph2' }) expect(a).toBe(b) }) -test('FileJournalStore append → read 保序,truncate 清空', async () => { +test('FileJournalStore append → read preserves order, truncate clears', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-journal-')) try { const store = createFileJournalStore(dir) @@ -44,10 +44,10 @@ test('FileJournalStore append → read 保序,truncate 清空', async () => { } }) -test('FileJournalStore read 按 seq 排序——parallel 完成顺序≠调用顺序时 resume 稳定', async () => { - // 并发完成顺序不确定:append 落盘 = completion 顺序;resume 时按调用顺序 - // 匹配 key。无 seq 排序 → 不同 run 的 key 顺序不同 → 几乎所有 key mismatch → - // 全重跑,journal 失效。修复:read() 按 seq 升序整理后再返回。 +test('FileJournalStore read sorts by seq — resume stable when parallel completion order ≠ call order', async () => { + // Concurrent completion order is non-deterministic: append-to-disk = completion order; on resume, key matching uses call order. + // Without seq sorting → different runs have different key orders → nearly all keys mismatch → + // everything re-runs, journal becomes useless. Fix: read() re-orders by ascending seq before returning. const dir = await mkdtemp(join(tmpdir(), 'wf-journal-sort-')) try { const store = createFileJournalStore(dir) @@ -74,7 +74,7 @@ test('FileJournalStore read 按 seq 排序——parallel 完成顺序≠调用 } }) -test('agentCallKey 随 schema 变化', () => { +test('agentCallKey varies with schema', () => { const k0 = agentCallKey('p', { prompt: 'p' }) const k1 = agentCallKey('p', { prompt: 'p', schema: { type: 'object' } }) const k2 = agentCallKey('p', { prompt: 'p', schema: { type: 'array' } }) @@ -82,13 +82,13 @@ test('agentCallKey 随 schema 变化', () => { expect(k1).not.toBe(k2) }) -test('agentCallKey 随 model 变化', () => { +test('agentCallKey varies with model', () => { expect(agentCallKey('p', { prompt: 'p', model: 'sonnet' })).not.toBe( agentCallKey('p', { prompt: 'p', model: 'opus' }), ) }) -test('agentCallKey 对 params 字段顺序稳定(canonical 排序)', () => { +test('agentCallKey stable across params field order (canonical sort)', () => { const a = agentCallKey('p', { prompt: 'p', model: 'm', @@ -102,7 +102,7 @@ test('agentCallKey 对 params 字段顺序稳定(canonical 排序)', () => { expect(a).toBe(b) }) -test('FileJournalStore read 不存在的 run → []', async () => { +test('FileJournalStore read for non-existent run → []', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-journal-')) try { const store = createFileJournalStore(dir) diff --git a/packages/workflow-engine/src/__tests__/namedWorkflows.test.ts b/packages/workflow-engine/src/__tests__/namedWorkflows.test.ts index 2d74f6c89..3281623ea 100644 --- a/packages/workflow-engine/src/__tests__/namedWorkflows.test.ts +++ b/packages/workflow-engine/src/__tests__/namedWorkflows.test.ts @@ -7,7 +7,7 @@ import { resolveNamedWorkflow, } from '../engine/namedWorkflows.js' -test('按扩展名优先级解析命名 workflow', async () => { +test('resolves named workflow by extension priority', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-named-')) try { await writeFile( @@ -25,19 +25,19 @@ test('按扩展名优先级解析命名 workflow', async () => { expect(await resolveNamedWorkflow(dir, 'missing')).toBeNull() const names = await listNamedWorkflows(dir) - expect(names).toEqual(['a', 'b', 'c']) // 不含 .md + expect(names).toEqual(['a', 'b', 'c']) // excludes .md } finally { await rm(dir, { recursive: true, force: true }) } }) -test('listNamedWorkflows 不存在目录返回空数组', async () => { +test('listNamedWorkflows returns empty array for non-existent directory', async () => { expect( await listNamedWorkflows(join(tmpdir(), 'wf-nope-' + Date.now())), ).toEqual([]) }) -test('resolveNamedWorkflow 在 .ts 缺失时降级到 .js/.mjs', async () => { +test('resolveNamedWorkflow falls back to .js/.mjs when .ts is missing', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-named-')) try { await writeFile(join(dir, 'onlyjs.js'), 'return 1') @@ -55,7 +55,7 @@ test('resolveNamedWorkflow 在 .ts 缺失时降级到 .js/.mjs', async () => { } }) -test('listNamedWorkflows 返回排序后的名字', async () => { +test('listNamedWorkflows returns sorted names', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-named-')) try { await writeFile(join(dir, 'zeta.ts'), 'return 1') diff --git a/packages/workflow-engine/src/__tests__/paths.test.ts b/packages/workflow-engine/src/__tests__/paths.test.ts index 9f0d71d2e..0b8c808b5 100644 --- a/packages/workflow-engine/src/__tests__/paths.test.ts +++ b/packages/workflow-engine/src/__tests__/paths.test.ts @@ -3,54 +3,54 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { containsPath, sanitizeWorkflowName } from '../engine/paths.js' -test('containsPath: target 等于 base → true', () => { +test('containsPath: target equals base → true', () => { const base = join(tmpdir(), 'a') expect(containsPath(base, base)).toBe(true) }) -test('containsPath: target 在 base 内 → true', () => { +test('containsPath: target inside base → true', () => { const base = join(tmpdir(), 'a') const target = join(base, 'b', 'c.ts') expect(containsPath(base, target)).toBe(true) }) -test('containsPath: target 在 base 之外(前缀假阳)→ false', () => { - // /tmp/foobar 不应被认为是 /tmp/foo 的子路径 +test('containsPath: target outside base (prefix false positive) → false', () => { + // /tmp/foobar should not be considered a subpath of /tmp/foo const base = join(tmpdir(), 'foo') const target = join(tmpdir(), 'foobar', 'x.ts') expect(containsPath(base, target)).toBe(false) }) -test('containsPath: target 用 .. 越界 → false', () => { +test('containsPath: target using .. out of bounds → false', () => { const base = join(tmpdir(), 'a', 'b') const target = join(base, '..', 'outside.ts') expect(containsPath(base, target)).toBe(false) }) -test('containsPath: 相对 target 相对 base 解析', () => { +test('containsPath: relative target resolved against base', () => { const base = join(tmpdir(), 'a') expect(containsPath(base, 'sub/file.ts')).toBe(true) expect(containsPath(base, '../b/file.ts')).toBe(false) }) -test('sanitizeWorkflowName: 合法标识符 → 原值', () => { +test('sanitizeWorkflowName: valid identifier → original value', () => { expect(sanitizeWorkflowName('release')).toBe('release') expect(sanitizeWorkflowName('my-workflow')).toBe('my-workflow') expect(sanitizeWorkflowName('my_workflow_2')).toBe('my_workflow_2') }) -test('sanitizeWorkflowName: 含路径分隔符 → null', () => { +test('sanitizeWorkflowName: contains path separators → null', () => { expect(sanitizeWorkflowName('foo/bar')).toBeNull() expect(sanitizeWorkflowName('foo\\bar')).toBeNull() expect(sanitizeWorkflowName('/abs/path')).toBeNull() }) -test('sanitizeWorkflowName: . / .. / 空 → null', () => { +test('sanitizeWorkflowName: . / .. / empty → null', () => { expect(sanitizeWorkflowName('.')).toBeNull() expect(sanitizeWorkflowName('..')).toBeNull() expect(sanitizeWorkflowName('')).toBeNull() }) -test('sanitizeWorkflowName: 含 null 字节 → null', () => { +test('sanitizeWorkflowName: contains null byte → null', () => { expect(sanitizeWorkflowName('evil\0.ts')).toBeNull() }) diff --git a/packages/workflow-engine/src/__tests__/persistInline.test.ts b/packages/workflow-engine/src/__tests__/persistInline.test.ts index 64347951b..e5c75c949 100644 --- a/packages/workflow-engine/src/__tests__/persistInline.test.ts +++ b/packages/workflow-engine/src/__tests__/persistInline.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { persistInlineScript } from '../tool/persistInline.js' -test('持久化到 /.claude/workflow-runs//script.js 并返回路径', async () => { +test('persists to /.claude/workflow-runs//script.js and returns path', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-pi-')) try { const path = await persistInlineScript('return 1', 'r1', dir) @@ -16,7 +16,7 @@ test('持久化到 /.claude/workflow-runs//script.js 并返回路径 } }) -test('同 runId 重复写覆盖(mkdir 幂等,不抛错)', async () => { +test('same runId repeated writes overwrite (mkdir idempotent, no error)', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-pi-')) try { await persistInlineScript('first', 'r2', dir) @@ -27,7 +27,7 @@ test('同 runId 重复写覆盖(mkdir 幂等,不抛错)', async () => { } }) -test('不同 runId 互不干扰(各自独立子目录)', async () => { +test('different runId do not interfere (independent subdirectories)', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-pi-')) try { const p1 = await persistInlineScript('a', 'run-a', dir) diff --git a/packages/workflow-engine/src/__tests__/ports.test.ts b/packages/workflow-engine/src/__tests__/ports.test.ts index 184901d4a..b8b87f059 100644 --- a/packages/workflow-engine/src/__tests__/ports.test.ts +++ b/packages/workflow-engine/src/__tests__/ports.test.ts @@ -1,21 +1,21 @@ import { expect, test } from 'bun:test' import { createHostHandle, isHostHandle, unwrapHostHandle } from '../ports.js' -test('createHostHandle 包装任意 bundle 且对外不透明', () => { +test('createHostHandle wraps any bundle and is opaque externally', () => { const bundle = { secret: 'ctx', nested: { a: 1 } } const handle = createHostHandle(bundle) expect(isHostHandle(handle)).toBe(true) - // 包内不暴露 bundle —— handle 只有符号标记 + // bundle is not exposed externally — handle only has a symbol marker expect(Object.keys(handle)).toHaveLength(0) }) -test('普通对象不是 HostHandle', () => { +test('plain object is not a HostHandle', () => { expect(isHostHandle({} as unknown)).toBe(false) expect(isHostHandle(null)).toBe(false) }) -test('端口对象满足最小形状', () => { - // 编译期形状校验:以下赋值通过即说明端口契约自洽 +test('ports object satisfies the minimal shape', () => { + // compile-time shape validation: the assignment below passing means the ports contract is self-consistent const noop = (): void => {} const ports = { agentRunner: { runAgentToResult: noop }, @@ -48,13 +48,13 @@ test('端口对象满足最小形状', () => { expect(ports.hostFactory().toolUseId).toBe('tu-1') }) -test('unwrapHostHandle 取回原始 bundle(同引用)', () => { +test('unwrapHostHandle retrieves the original bundle (same reference)', () => { const bundle = { secret: 'ctx', nested: { a: 1 } } const handle = createHostHandle(bundle) expect(unwrapHostHandle(handle)).toBe(bundle) }) -test('createHostHandle(null) 不透明且解包为 null', () => { +test('createHostHandle(null) is opaque and unwraps to null', () => { const handle = createHostHandle(null) expect(isHostHandle(handle)).toBe(true) expect(unwrapHostHandle(handle)).toBeNull() diff --git a/packages/workflow-engine/src/__tests__/runWorkflow.test.ts b/packages/workflow-engine/src/__tests__/runWorkflow.test.ts index 2fda8f3bf..d282bc73d 100644 --- a/packages/workflow-engine/src/__tests__/runWorkflow.test.ts +++ b/packages/workflow-engine/src/__tests__/runWorkflow.test.ts @@ -70,7 +70,7 @@ function portsWithEvents( } } -test('端到端:脚本返回 agent 结果,状态 completed', async () => { +test('end-to-end: script returns agent result, status completed', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { const ports = portsWith( @@ -95,7 +95,7 @@ test('端到端:脚本返回 agent 结果,状态 completed', async () => { } }) -test('脚本语法错误 → failed', async () => { +test('script syntax error → failed', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { const ports = portsWith(dir, new Map()) @@ -115,7 +115,7 @@ test('脚本语法错误 → failed', async () => { } }) -test('resume:journal 命中则不调用 runner', async () => { +test('resume: journal hit skips runner call', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { let called = 0 @@ -192,7 +192,7 @@ test('abort → killed', async () => { } }) -test('workflow() 嵌套(一层)共享计数', async () => { +test('workflow() nesting (one level) shares counts', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { await mkdir(join(dir, '.claude', 'workflows'), { recursive: true }) @@ -225,9 +225,9 @@ test('workflow() 嵌套(一层)共享计数', async () => { } }) -// ---- 边界与事件 ---- +// ---- boundary and events ---- -test('scriptChanged=true → truncate journal 并全量现场跑', async () => { +test('scriptChanged=true → truncate journal and run all live', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { let called = 0 @@ -275,7 +275,7 @@ test('scriptChanged=true → truncate journal 并全量现场跑', async () => { expect(result.status).toBe('completed') expect(result.returnValue).toBe('live') expect(called).toBe(1) - // truncate 清空了旧 cached journal,现场 agent append 新 entry(live) + // truncate cleared the old cached journal, live agent appends a new entry const final = await ports.journalStore.read('run-chg') expect(final).toHaveLength(1) expect((final[0]!.result as { output: string }).output).toBe('live') @@ -284,7 +284,7 @@ test('scriptChanged=true → truncate journal 并全量现场跑', async () => { } }) -test('脚本运行时抛错(非语法错)→ failed', async () => { +test('script runtime throw (non-syntax error) → failed', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { const ports = portsWith(dir, new Map()) @@ -304,7 +304,7 @@ test('脚本运行时抛错(非语法错)→ failed', async () => { } }) -test('发射 run_started(含 workflowName)与 run_done 事件', async () => { +test('emits run_started (with workflowName) and run_done events', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { const { ports, events } = portsWithEvents( @@ -332,9 +332,9 @@ test('发射 run_started(含 workflowName)与 run_done 事件', async () => } }) -// 终态前补发当前 phase 的 phase_done:hook.phase 只在切换时 emit 上一个的 done, -// 最后一个 phase 无后续切换 → UI 左栏会永远显示 running。验证三路径都补发。 -test('终态前补发 currentPhase 的 phase_done(completed 路径)', async () => { +// Emit phase_done for currentPhase before terminal state: hook.phase only emits the previous phase's done on switch, +// the last phase has no subsequent switch → the UI left panel would show running forever. Verify all three paths re-emit. +test('re-emit phase_done for currentPhase before terminal state (completed path)', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { const { ports, events } = portsWithEvents( @@ -350,14 +350,14 @@ test('终态前补发 currentPhase 的 phase_done(completed 路径)', async cwd: dir, budgetTotal: null, }) - // Review 的 phase_started + phase_done 都应存在(done 来自终态前补发) + // Both phase_started and phase_done for Review should be present (done from re-emit before terminal) expect( events.some(e => e.type === 'phase_started' && e.phase === 'Review'), ).toBe(true) expect( events.some(e => e.type === 'phase_done' && e.phase === 'Review'), ).toBe(true) - // 顺序:phase_done 必须在 run_done 之前(reducer 不依赖顺序,但事件流语义清晰) + // Order: phase_done must precede run_done (reducer is order-independent, but the event stream is clearer this way) const lastPhaseDone = Math.max( 0, ...events.map((e, i) => (e.type === 'phase_done' ? i : -1)), @@ -370,7 +370,7 @@ test('终态前补发 currentPhase 的 phase_done(completed 路径)', async } }) -test('终态前补发 currentPhase 的 phase_done(killed 路径)', async () => { +test('re-emit phase_done for currentPhase before terminal state (killed path)', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { const { ports, events } = portsWithEvents( @@ -399,7 +399,7 @@ test('终态前补发 currentPhase 的 phase_done(killed 路径)', async () } }) -test('无 phase() 调用 → 终态不补发 phase_done(currentPhase 为 null)', async () => { +test('no phase() call → terminal does not re-emit phase_done (currentPhase is null)', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { const { ports, events } = portsWithEvents( @@ -415,7 +415,7 @@ test('无 phase() 调用 → 终态不补发 phase_done(currentPhase 为 null cwd: dir, budgetTotal: null, }) - // 没有 phase() → currentPhase 为 null → 终态不补发 phase_done + // No phase() → currentPhase is null → terminal does not re-emit phase_done expect(events.some(e => e.type === 'phase_done')).toBe(false) expect(events.some(e => e.type === 'phase_started')).toBe(false) expect( @@ -426,7 +426,7 @@ test('无 phase() 调用 → 终态不补发 phase_done(currentPhase 为 null } }) -test('未传 workflowName 时从 meta.name 推导', async () => { +test('derives workflowName from meta.name when not passed', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { const { ports, events } = portsWithEvents(dir, new Map()) @@ -449,7 +449,7 @@ test('未传 workflowName 时从 meta.name 推导', async () => { } }) -test('budgetTotal 耗尽 → failed', async () => { +test('budgetTotal exhausted → failed', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { const ports = portsWith( @@ -474,7 +474,7 @@ test('budgetTotal 耗尽 → failed', async () => { } }) -test('maxConcurrency 透传:并行 agent 受 run 级并发槽位限制', async () => { +test('maxConcurrency passthrough: parallel agents bounded by run-level concurrency slots', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { let active = 0 @@ -525,7 +525,7 @@ test('maxConcurrency 透传:并行 agent 受 run 级并发槽位限制', async } }) -test('workflow() 引用语法错的子脚本 → failed', async () => { +test('workflow() references a syntactically broken sub-script → failed', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { await mkdir(join(dir, '.claude', 'workflows'), { recursive: true }) @@ -541,13 +541,13 @@ test('workflow() 引用语法错的子脚本 → failed', async () => { budgetTotal: null, }) expect(result.status).toBe('failed') - expect(result.error).toMatch(/子 workflow|脚本错误/) + expect(result.error).toMatch(/Sub-workflow|script error/i) } finally { await rm(dir, { recursive: true, force: true }) } }) -test('workflow() 引用不存在的 name → failed', async () => { +test('workflow() references a non-existent name → failed', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { const ports = portsWith(dir, new Map()) @@ -561,7 +561,7 @@ test('workflow() 引用不存在的 name → failed', async () => { budgetTotal: null, }) expect(result.status).toBe('failed') - expect(result.error).toMatch(/子 workflow|未找到/) + expect(result.error).toMatch(/Sub-workflow|not found/i) } finally { await rm(dir, { recursive: true, force: true }) } diff --git a/packages/workflow-engine/src/__tests__/schema.test.ts b/packages/workflow-engine/src/__tests__/schema.test.ts index ae282f835..4a0b49ab3 100644 --- a/packages/workflow-engine/src/__tests__/schema.test.ts +++ b/packages/workflow-engine/src/__tests__/schema.test.ts @@ -1,11 +1,11 @@ import { expect, test } from 'bun:test' import { workflowInputSchema } from '../tool/schema.js' -test('空对象通过(所有字段 optional)', () => { +test('empty object passes (all fields optional)', () => { expect(workflowInputSchema.safeParse({}).success).toBe(true) }) -test('全部已知字段可填', () => { +test('all known fields can be filled', () => { const r = workflowInputSchema.safeParse({ script: 'return 1', name: 'release', @@ -19,19 +19,19 @@ test('全部已知字段可填', () => { expect(r.success).toBe(true) }) -test('args 接受任意 JSON 值(对象/数组/字符串/数字/布尔/null)', () => { +test('args accepts any JSON value (object/array/string/number/boolean/null)', () => { for (const args of [{ a: 1 }, [1, 2], 's', 42, true, null]) { expect(workflowInputSchema.safeParse({ args }).success).toBe(true) } }) -test('类型错误被拒(script/name/scriptPath 非字符串)', () => { +test('type errors rejected (script/name/scriptPath not strings)', () => { expect(workflowInputSchema.safeParse({ script: 123 }).success).toBe(false) expect(workflowInputSchema.safeParse({ name: 42 }).success).toBe(false) expect(workflowInputSchema.safeParse({ scriptPath: {} }).success).toBe(false) }) -test('resumeFromRunId/description/title 必须为字符串', () => { +test('resumeFromRunId/description/title must be strings', () => { expect(workflowInputSchema.safeParse({ resumeFromRunId: 1 }).success).toBe( false, ) @@ -39,12 +39,12 @@ test('resumeFromRunId/description/title 必须为字符串', () => { expect(workflowInputSchema.safeParse({ title: 1 }).success).toBe(false) }) -test('未知字段被 strip(zod 默认非 strict,safeParse 成功)', () => { +test('unknown fields are stripped (zod default non-strict, safeParse succeeds)', () => { const r = workflowInputSchema.safeParse({ script: 'x', extra: 1 }) expect(r.success).toBe(true) }) -test('maxConcurrency:1–16 整数合法;0/17/小数/非数字被拒', () => { +test('maxConcurrency: integers 1-16 valid; 0/17/decimal/non-number rejected', () => { for (const n of [1, 3, 5, 16]) { expect(workflowInputSchema.safeParse({ maxConcurrency: n }).success).toBe( true, @@ -57,6 +57,6 @@ test('maxConcurrency:1–16 整数合法;0/17/小数/非数字被拒', () => } }) -test('maxConcurrency optional(省略时 safeParse 成功)', () => { +test('maxConcurrency optional (safeParse succeeds when omitted)', () => { expect(workflowInputSchema.safeParse({ script: 'x' }).success).toBe(true) }) diff --git a/packages/workflow-engine/src/__tests__/script.test.ts b/packages/workflow-engine/src/__tests__/script.test.ts index 40d851d8f..acadc4640 100644 --- a/packages/workflow-engine/src/__tests__/script.test.ts +++ b/packages/workflow-engine/src/__tests__/script.test.ts @@ -24,7 +24,7 @@ const stubHooks: WorkflowHooks = { workflow: async () => null, } -test('extractMeta 提取纯字面量并剥离语句', () => { +test('extractMeta extracts plain literals and strips the statement', () => { const src = `export const meta = { name: 'x', description: 'y' }\nreturn 1` const { meta, body } = extractMeta(src) expect(meta?.name).toBe('x') @@ -33,39 +33,39 @@ test('extractMeta 提取纯字面量并剥离语句', () => { expect(body).toContain('return 1') }) -test('extractMeta 无 meta 返回 null 且 body 不变', () => { +test('extractMeta returns null when no meta and body unchanged', () => { const src = `return 42` const { meta, body } = extractMeta(src) expect(meta).toBeNull() expect(body).toBe(src) }) -test('extractMeta 拒绝非纯字面量(引用变量)', () => { +test('extractMeta rejects non-plain literals (variable references)', () => { const src = `const x = 1\nexport const meta = { name: 'x', description: y }\nreturn 1` expect(() => extractMeta(src)).toThrow(ScriptError) }) -test('parseScript 执行 body 顶层 return', async () => { +test('parseScript executes top-level return of body', async () => { const { execute } = parseScript(`return args.n + 1`) const out = await execute(stubHooks, { n: 41 }, { total: null }) expect(out).toBe(42) }) -test('脚本中 Date.now() 抛非确定性错误', async () => { +test('Date.now() in script throws non-determinism error', async () => { const { execute } = parseScript(`return Date.now()`) await expect(execute(stubHooks, {}, { total: null })).rejects.toThrow( /Date\.now/, ) }) -test('脚本中 Math.random() 抛非确定性错误', async () => { +test('Math.random() in script throws non-determinism error', async () => { const { execute } = parseScript(`return Math.random()`) await expect(execute(stubHooks, {}, { total: null })).rejects.toThrow( /Math\.random/, ) }) -test('无参 new Date() 抛,有参 new Date() 可用', async () => { +test('no-arg new Date() throws, but new Date(arg) is allowed', async () => { const bad = parseScript(`return new Date()`) await expect(bad.execute(stubHooks, {}, { total: null })).rejects.toThrow( /new Date/, @@ -76,33 +76,33 @@ test('无参 new Date() 抛,有参 new Date() 可用', async () => { await expect(good.execute(stubHooks, {}, { total: null })).resolves.toBe(2020) }) -// ---- meta 校验错误分支与嵌套 ---- +// ---- meta validation error branches and nesting ---- -test('extractMeta meta 为数组 → ScriptError', () => { +test('extractMeta meta is array → ScriptError', () => { expect(() => extractMeta('export const meta = [1, 2]\nreturn 1')).toThrow( ScriptError, ) }) -test('extractMeta meta 缺 name → ScriptError', () => { +test('extractMeta meta missing name → ScriptError', () => { expect(() => extractMeta('export const meta = { description: "d" }\nreturn 1'), ).toThrow(ScriptError) }) -test('extractMeta meta 缺 description → ScriptError', () => { +test('extractMeta meta missing description → ScriptError', () => { expect(() => extractMeta('export const meta = { name: "n" }\nreturn 1'), ).toThrow(ScriptError) }) -test('extractMeta meta 大括号未闭合 → ScriptError', () => { +test('extractMeta meta unclosed braces → ScriptError', () => { expect(() => extractMeta('export const meta = { name: "n", description: "d"\nreturn 1'), ).toThrow(ScriptError) }) -test('extractMeta 支持嵌套对象(phases 数组)', () => { +test('extractMeta supports nested objects (phases array)', () => { const src = `export const meta = { name: 'x', description: 'y', phases: [{ title: 'A' }, { title: 'B' }] }\nreturn 1` const { meta } = extractMeta(src) expect(meta?.name).toBe('x') @@ -111,11 +111,11 @@ test('extractMeta 支持嵌套对象(phases 数组)', () => { expect(meta?.phases?.[1]?.title).toBe('B') }) -test('parseScript 语法错 → ScriptError', () => { +test('parseScript syntax error → ScriptError', () => { expect(() => parseScript('return ((')).toThrow(ScriptError) }) -test('parseScript 检测 import → 带指引的 ScriptError(不落泛化语法错)', () => { +test('parseScript detects import → guided ScriptError (not a generic syntax error)', () => { expect(() => parseScript( `import { foo } from 'bar'\nexport const meta = { name: 'n', description: 'd' }\nreturn foo()`, @@ -125,10 +125,10 @@ test('parseScript 检测 import → 带指引的 ScriptError(不落泛化语 parseScript( `import { foo } from 'bar'\nexport const meta = { name: 'n', description: 'd' }\nreturn foo()`, ), - ).toThrow(/不支持 import/) + ).toThrow(/import is not supported/) }) -test('parseScript 检测 meta 之外的多余 export → 带指引的 ScriptError', () => { +test('parseScript detects extra export beyond meta → guided ScriptError', () => { expect(() => parseScript( `export const meta = { name: 'n', description: 'd' }\nexport const X = 1\nreturn X`, @@ -138,17 +138,17 @@ test('parseScript 检测 meta 之外的多余 export → 带指引的 ScriptErro parseScript( `export const meta = { name: 'n', description: 'd' }\nexport const X = 1\nreturn X`, ), - ).toThrow(/只允许一处 export const meta/) + ).toThrow(/allow only one export const meta/) }) -test('parseScript 正常纯 JS 脚本(无 import/无多余 export)不被误拦', () => { +test('parseScript does not misfire on normal plain JS scripts (no import / no extra export)', () => { const { execute } = parseScript( `export const meta = { name: 'n', description: 'd' }\nconst r = await agent('hi')\nreturn r`, ) expect(typeof execute).toBe('function') }) -test('parseScript 检测动态 import(...) → 带指引的 ScriptError(沙箱防逃逸)', () => { +test('parseScript detects dynamic import(...) → guided ScriptError (sandbox anti-escape)', () => { expect(() => parseScript( `const cp = await import('node:child_process')\nreturn cp.execSync('id').toString()`, @@ -159,8 +159,8 @@ test('parseScript 检测动态 import(...) → 带指引的 ScriptError(沙箱 ).toThrow(/import/) }) -test('parseScript 检测行中含 import 字符串字面量时不误拦(如 prompt 里出现 "import")', () => { - // 字符串里的 import 不应被静态 regex 拦——允许 prompt 包含 "import" 词 +test('parseScript does not misfire when a line contains the import string literal (e.g. prompt contains "import")', () => { + // import inside a string should not be caught by the static regex — prompt may contain the word "import" const { execute } = parseScript( `export const meta = { name: 'n', description: 'd' }\nconst r = await agent('please import this module')\nreturn r`, ) diff --git a/packages/workflow-engine/src/__tests__/structuredOutput.test.ts b/packages/workflow-engine/src/__tests__/structuredOutput.test.ts index 440264623..71c760041 100644 --- a/packages/workflow-engine/src/__tests__/structuredOutput.test.ts +++ b/packages/workflow-engine/src/__tests__/structuredOutput.test.ts @@ -11,7 +11,7 @@ const schema = { additionalProperties: false, } -test('合法对象通过', () => { +test('valid object passes', () => { const { valid, errors } = validateAgainstSchema( { name: 'a', count: 1 }, schema, @@ -20,20 +20,20 @@ test('合法对象通过', () => { expect(errors).toEqual([]) }) -test('缺字段失败', () => { +test('missing field fails', () => { const { valid, errors } = validateAgainstSchema({ name: 'a' }, schema) expect(valid).toBe(false) expect(errors.length).toBeGreaterThan(0) }) -test('类型错误失败', () => { +test('type error fails', () => { const { valid } = validateAgainstSchema({ name: 'a', count: 'x' }, schema) expect(valid).toBe(false) }) -test('同一 schema 复用缓存', () => { +test('same schema reuses cache', () => { validateAgainstSchema({ name: 'a', count: 1 }, schema) - // 第二次用同一 schema 对象应命中缓存(不抛错即可) + // second use of the same schema object should hit cache (not throwing is enough) expect(validateAgainstSchema({ name: 'b', count: 2 }, schema).valid).toBe( true, ) diff --git a/packages/workflow-engine/src/__tests__/types.test.ts b/packages/workflow-engine/src/__tests__/types.test.ts index 5ca2b19bf..e1c7d9f94 100644 --- a/packages/workflow-engine/src/__tests__/types.test.ts +++ b/packages/workflow-engine/src/__tests__/types.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'bun:test' -// 直接构造类型形状,验证 JSON 往返(resume 持久化的核心要求)。 -test('AgentRunResult ok 分支可 JSON 往返', () => { +// Directly construct type shapes to verify JSON round-trip (core requirement for resume persistence). +test('AgentRunResult ok branch can JSON round-trip', () => { const result = { kind: 'ok' as const, output: { confirmed: true }, @@ -12,15 +12,15 @@ test('AgentRunResult ok 分支可 JSON 往返', () => { expect(round.kind).toBe('ok') }) -test('AgentRunResult skipped/dead 分支可 JSON 往返', () => { +test('AgentRunResult skipped/dead branch can JSON round-trip', () => { for (const kind of ['skipped', 'dead'] as const) { const round = JSON.parse(JSON.stringify({ kind })) expect(round.kind).toBe(kind) } }) -// dead 携带可选 reason/detail:journal 持久化后能保留死因,事后审计/面板展示用。 -test('AgentRunResult dead 带 reason/detail 可 JSON 往返', () => { +// dead carries optional reason/detail: journal persistence preserves cause of death for post-hoc audit / panel display. +test('AgentRunResult dead with reason/detail can JSON round-trip', () => { const dead = { kind: 'dead' as const, reason: 'no-structured-output' as const, @@ -32,8 +32,8 @@ test('AgentRunResult dead 带 reason/detail 可 JSON 往返', () => { expect(round.reason).toBe('no-structured-output') }) -// 兼容旧 journal:reason/detail 都可选,缺失时仍是合法 dead。 -test('AgentRunResult dead 无 reason 仍合法(兼容旧 journal)', () => { +// Backward compatible with old journals: reason/detail both optional, missing is still valid dead. +test('AgentRunResult dead without reason is still valid (backward compatible with old journal)', () => { const legacy = { kind: 'dead' as const } const round = JSON.parse(JSON.stringify(legacy)) expect(round.kind).toBe('dead') @@ -41,7 +41,7 @@ test('AgentRunResult dead 无 reason 仍合法(兼容旧 journal)', () => { expect(round.detail).toBeUndefined() }) -test('JournalEntry 形状稳定', () => { +test('JournalEntry shape is stable', () => { const entry = { key: 'abc123', result: { kind: 'ok', output: 'text', usage: { outputTokens: 1 } }, diff --git a/packages/workflow-engine/src/agentAdapter.ts b/packages/workflow-engine/src/agentAdapter.ts index 7639414ea..464e04f0f 100644 --- a/packages/workflow-engine/src/agentAdapter.ts +++ b/packages/workflow-engine/src/agentAdapter.ts @@ -1,5 +1,5 @@ -// Agent 后端适配器抽象。引擎通过 registry 取 adapter 再调 run,不关心具体实现 -// (Anthropic SDK / 核心 runAgent / OpenAI / 本地模型 / mock 均为 adapter 的实现)。 +// Agent backend adapter abstraction. The engine takes an adapter from the registry via resolve then calls run; it does not care about the concrete implementation +// (Anthropic SDK / core runAgent / OpenAI / local model / mock are all adapter implementations). import type { AgentProgressUpdate, AgentRunParams, @@ -7,68 +7,68 @@ import type { } from './types.js' import type { HostHandle } from './ports.js' -/** adapter 能力声明。引擎/脚本据此降级(如后端不支持 schema 则改文本 + 解析)。 */ +/** Adapter capability declaration. The engine/script degrades based on this (e.g. if the backend does not support schema, switch to text + parse). */ export type AgentAdapterCapabilities = { - /** 支持 schema 结构化输出(agent(schema) 直接返回对象)。 */ + /** Supports schema structured output (agent(schema) returns an object directly). */ structuredOutput: boolean - /** 支持工具调用(仅核心 agent 后端有)。 */ + /** Supports tool calling (only the core agent backend has this). */ tools?: boolean - /** 支持流式(v1 引擎不消费,预留)。 */ + /** Supports streaming (the v1 engine does not consume it; reserved). */ stream?: boolean } -/** adapter.run 的上下文。 */ +/** Context for adapter.run. */ export type AgentAdapterContext = { - /** 透传的不透明 host 句柄(核心 adapter 用;独立后端忽略)。 */ + /** Opaque host handle passed through (used by the core adapter; ignored by standalone backends). */ host: HostHandle - /** 取消信号(与 workflow signal 一致)。 */ + /** Cancellation signal (same as the workflow signal). */ signal: AbortSignal - /** 当前 workflow runId(日志/追踪用)。 */ + /** Current workflow runId (for logging/tracing). */ runId: string /** - * 引擎层 agent 序号(hooks.agentIdSeq 递增;面板 RunProgress.agents[].id 同源)。 - * 注意:与 backend 内部创建的 core AgentId(字符串,子 agent 跟踪用)是两个不同概念, - * 不可混用。本字段用于 registerAgentAbort/unregisterAgentAbort 的 key,让 service - * .kill(runId, agentId) 能精确路由到 backend 创建的 AbortController。 + * Engine-layer agent sequence number (incremented by hooks.agentIdSeq; same source as panel RunProgress.agents[].id). + * Note: this is a different concept from the core AgentId (a string, used for sub-agent tracking) created internally by the backend; + * do not mix them. This field is the key for registerAgentAbort/unregisterAgentAbort, so that service + * .kill(runId, agentId) can precisely route to the AbortController created by the backend. */ agentId: number /** - * 运行中进度上报(后端循环累计 token/tool 时调用)。可选:独立后端可不实现; - * 引擎据此发 agent_progress 事件(闭包带 agentId/runId 关联),面板实时刷新。 + * In-progress reporting (called by the backend loop as it accumulates tokens/tools). Optional: standalone backends may not implement it; + * the engine emits the agent_progress event based on this (closure carries agentId/runId for correlation), and the panel refreshes in real time. */ onProgress?: (update: AgentProgressUpdate) => void /** - * 注册 agent 级 AbortController(可选)。后端创建 controller 后调此注入 Map, - * 让 service.kill(runId, agentId) 能精确中断单个 agent 而不影响其他。 - * 由 hooks.agent 在 backend.run 调用前注入。 + * Register an agent-level AbortController (optional). The backend calls this after creating the controller to inject it into a Map, + * so that service.kill(runId, agentId) can precisely abort a single agent without affecting others. + * Injected by hooks.agent before backend.run is called. */ registerAgentAbort?: (agentId: number, ac: AbortController) => void /** - * 注销 agent 级 AbortController(agent 完成或失败时调;幂等)。 - * 与 registerAgentAbort 配对。 + * Unregister an agent-level AbortController (called when the agent completes or fails; idempotent). + * Paired with registerAgentAbort. */ unregisterAgentAbort?: (agentId: number) => void } /** - * Agent 后端适配器。引擎只依赖此接口;具体后端实现它并注册到 registry。 - * initialize/dispose 为可选生命周期(连接池/资源管理),由调用方通过 - * registry.initializeAll/disposeAll 触发。 + * Agent backend adapter. The engine only depends on this interface; concrete backends implement it and register into the registry. + * initialize/dispose are optional lifecycle hooks (connection pool / resource management), triggered by the caller via + * registry.initializeAll/disposeAll. */ export interface AgentAdapter { - /** 唯一标识(registry 路由 / 日志)。 */ + /** Unique identifier (registry routing / logging). */ readonly id: string - /** 能力声明。 */ + /** Capability declaration. */ readonly capabilities: AgentAdapterCapabilities - /** 执行一次 agent 调用。 */ + /** Execute one agent call. */ run(params: AgentRunParams, ctx: AgentAdapterContext): Promise - /** 初始化(由 registry.initializeAll 触发)。 */ + /** Initialize (triggered by registry.initializeAll). */ initialize?(): Promise - /** 销毁(由 registry.disposeAll 触发)。 */ + /** Dispose (triggered by registry.disposeAll). */ dispose?(): Promise } -/** 路由规则:决定哪些 params 走哪个 adapter。按添加顺序匹配,先命中先用。 */ +/** Routing rule: decides which params go to which adapter. Matched in insertion order; first hit wins. */ export type AdapterRouteRule = | { kind: 'agentType'; agentType: string; adapter: string } | { kind: 'model'; pattern: string; adapter: string } @@ -78,7 +78,7 @@ export type AdapterRouteRule = adapter: string } -/** registry 找不到匹配 adapter 时抛出。 */ +/** Thrown when the registry cannot find a matching adapter. */ export class AdapterNotFoundError extends Error { constructor(message: string) { super(message) @@ -87,28 +87,28 @@ export class AdapterNotFoundError extends Error { } /** - * 多后端 registry。register 注册 adapter,route/default 配路由,resolve 按 - * 规则顺序匹配选 adapter。adapter 的 lifecycle(initialize/dispose)通过 - * initializeAll/disposeAll 统一触发(由调用方在运行前后调)。 + * Multi-backend registry. register registers an adapter, route/default configure routing, and resolve picks an adapter by + * matching rules in order. The adapter lifecycle (initialize/dispose) is triggered uniformly via + * initializeAll/disposeAll (called by the caller before/after the run). */ export class AgentAdapterRegistry { private readonly adapters = new Map() private readonly rules: AdapterRouteRule[] = [] private defaultId: string | null = null - /** 注册一个 adapter(id 重复则覆盖)。链式。 */ + /** Register an adapter (duplicate id overwrites). Chainable. */ register(adapter: AgentAdapter): this { this.adapters.set(adapter.id, adapter) return this } - /** 设默认 adapter(无规则命中时用)。链式。 */ + /** Set the default adapter (used when no rule matches). Chainable. */ default(adapterId: string): this { this.defaultId = adapterId return this } - /** 加一条路由规则(按添加顺序匹配)。链式。 */ + /** Add a routing rule (matched in insertion order). Chainable. */ route(rule: AdapterRouteRule): this { this.rules.push(rule) return this @@ -122,7 +122,7 @@ export class AgentAdapterRegistry { return this.adapters.get(id) } - /** 按规则匹配;第一个命中返回;无命中走 default;都没有抛 AdapterNotFoundError。 */ + /** Match by rules; return the first hit; if no hit, go to default; if neither, throw AdapterNotFoundError. */ resolve(params: AgentRunParams): AgentAdapter { for (const rule of this.rules) { if (matchRule(rule, params)) { @@ -135,18 +135,18 @@ export class AgentAdapterRegistry { if (fallback) return fallback } throw new AdapterNotFoundError( - `无 adapter 匹配(rules=${this.rules.length}, default=${this.defaultId ?? '无'})`, + `No adapter matched (rules=${this.rules.length}, default=${this.defaultId ?? 'none'})`, ) } - /** 触发所有 adapter 的 initialize(跳过未实现的)。 */ + /** Trigger initialize on all adapters (skips unimplemented ones). */ async initializeAll(): Promise { for (const a of this.adapters.values()) { await a.initialize?.() } } - /** 触发所有 adapter 的 dispose(跳过未实现的)。 */ + /** Trigger dispose on all adapters (skips unimplemented ones). */ async disposeAll(): Promise { for (const a of this.adapters.values()) { await a.dispose?.() @@ -161,5 +161,5 @@ function matchRule(rule: AdapterRouteRule, params: AgentRunParams): boolean { typeof params.model === 'string' && params.model.startsWith(rule.pattern) ) } - return rule.match(params) // custom + return rule.match(params) // custom rule } diff --git a/packages/workflow-engine/src/constants.ts b/packages/workflow-engine/src/constants.ts index d031982ca..dec8c7c5b 100644 --- a/packages/workflow-engine/src/constants.ts +++ b/packages/workflow-engine/src/constants.ts @@ -1,32 +1,32 @@ -// 引擎级常量。无运行时依赖。 +// Engine-level constants. No runtime dependencies. /** - * Workflow 工具名。PascalCase 与系统其他工具(Agent/Bash/CronCreate…)一致, - * 否则大小写敏感的 toolMatchesName 会让模型自然的 select:Workflow 匹配失败。 + * Workflow tool name. PascalCase matches the system's other tools (Agent/Bash/CronCreate…), + * otherwise the case-sensitive toolMatchesName would fail on the model's natural select:Workflow. */ export const WORKFLOW_TOOL_NAME = 'Workflow' -/** 用户命名 workflow 文件目录(相对项目根)。 */ +/** Directory for user-named workflow files (relative to project root). */ export const WORKFLOW_DIR_NAME = '.claude/workflows' -/** workflow run 持久化目录(journal + run 记录)。 */ +/** Persistence directory for workflow runs (journal + run records). */ export const WORKFLOW_RUNS_DIR = '.claude/workflow-runs' -/** 命名 workflow 支持的脚本扩展名(按优先级)。 */ +/** Supported script extensions for named workflows (in priority order). */ export const WORKFLOW_SCRIPT_EXTENSIONS = ['.ts', '.js', '.mjs'] as const /** - * 并发:每个 workflow run 默认 semaphore 许可数。 - * 历史:曾用 min(CAP, cpuCores - 2);改为固定默认 3——避免在多核机器上一次铺开十几个 agent。 - * 单次 run 可经 Workflow 工具的 maxConcurrency 入参覆盖(仍受 CAP 钳制)。 + * Concurrency: default semaphore permits per workflow run. + * History: previously used min(CAP, cpuCores - 2); changed to a fixed default of 3 — to avoid fanning out a dozen agents at once on multi-core machines. + * A single run can override this via the Workflow tool's maxConcurrency input (still clamped by CAP). */ export const DEFAULT_MAX_CONCURRENCY = 3 -/** 用户传入 maxConcurrency 的绝对上限(防滥用)。 */ +/** Absolute cap on user-supplied maxConcurrency (anti-abuse). */ export const MAX_CONCURRENCY_CAP = 16 -/** 单个 workflow 生命周期内 agent() 总数上限。 */ +/** Total cap on agent() calls within a single workflow lifecycle. */ export const MAX_TOTAL_AGENTS = 1000 -/** 单次 parallel()/pipeline() 调用的 items 上限。 */ +/** Items cap per single parallel()/pipeline() call. */ export const MAX_ITEMS_PER_CALL = 4096 diff --git a/packages/workflow-engine/src/engine/budget.ts b/packages/workflow-engine/src/engine/budget.ts index 653076033..2cf966f80 100644 --- a/packages/workflow-engine/src/engine/budget.ts +++ b/packages/workflow-engine/src/engine/budget.ts @@ -1,13 +1,13 @@ export class BudgetExhaustedError extends Error { constructor() { - super('workflow token budget 已耗尽(budget.total 达到上限)') + super('workflow token budget exhausted (budget.total reached the cap)') this.name = 'BudgetExhaustedError' } } /** - * Token 预算累加器。脚本通过 `budget.total / budget.spent() / budget.remaining()` - * 读取;agent() 调用前 assertCanSpend() 强制硬上限。 + * Token budget accumulator. The script reads via `budget.total / budget.spent() / budget.remaining()`; + * assertCanSpend() enforces a hard cap before each agent() call. */ export class Budget { private spentTokens = 0 diff --git a/packages/workflow-engine/src/engine/concurrency.ts b/packages/workflow-engine/src/engine/concurrency.ts index 4c107e290..0e49a6bd7 100644 --- a/packages/workflow-engine/src/engine/concurrency.ts +++ b/packages/workflow-engine/src/engine/concurrency.ts @@ -1,11 +1,11 @@ import { DEFAULT_MAX_CONCURRENCY, MAX_CONCURRENCY_CAP } from '../constants.js' /** - * 异步信号量。acquire() 返回一个 release 函数;permit 在 release 时直接 - * 转移给下一个等待者(available 不变),无等待者时才归还。permit 总数守恒。 + * Async semaphore. acquire() returns a release function; on release the permit is transferred + * directly to the next waiter (available stays unchanged), and only returned when there is no waiter. The total number of permits is conserved. * - * acquire(signal?) 支持取消:signal 已 aborted 或在等待期间 abort 时立即 reject, - * waiter 从队列移除、不消耗 permit(避免被取消的 agent 占用并发槽)。 + * acquire(signal?) supports cancellation: when the signal is already aborted or aborts while waiting, it rejects immediately, + * the waiter is removed from the queue, and no permit is consumed (to avoid a canceled agent holding a concurrency slot). */ export class Semaphore { private available: number @@ -48,24 +48,24 @@ export class Semaphore { private release(): void { const next = this.waiters.shift() if (next) { - next.wake() // 直接转移 permit + next.wake() // transfer the permit directly } else { this.available += 1 } } } -/** 当前进程默认并发(向下兼容入口;具体 run 请用 clampMaxConcurrency 处理用户入参)。 */ +/** Default concurrency for the current process (backward-compatible entry; for a specific run, use clampMaxConcurrency to handle user input). */ export function maxConcurrency(): number { return DEFAULT_MAX_CONCURRENCY } /** - * 把"用户传入的 maxConcurrency"归一到合法 permits。 + * Normalize the "user-supplied maxConcurrency" to legal permits. * - undefined / NaN → DEFAULT_MAX_CONCURRENCY - * - <1 → 1(至少 1 个并发槽,否则 workflow 无法推进) + * - <1 → 1 (at least one concurrency slot, otherwise the workflow cannot progress) * - >MAX_CONCURRENCY_CAP → MAX_CONCURRENCY_CAP - * - 否则取整后原值 + * - otherwise the truncated original value */ export function clampMaxConcurrency(n: number | undefined): number { if (n === undefined || Number.isNaN(n)) return DEFAULT_MAX_CONCURRENCY diff --git a/packages/workflow-engine/src/engine/context.ts b/packages/workflow-engine/src/engine/context.ts index 8006059a6..a528b6add 100644 --- a/packages/workflow-engine/src/engine/context.ts +++ b/packages/workflow-engine/src/engine/context.ts @@ -4,19 +4,19 @@ import { Budget } from './budget.js' import { Semaphore, clampMaxConcurrency } from './concurrency.js' /** - * 可被子 workflow 共享的资源。嵌套时 semaphore/budget/agentCountBox 按引用共享, - * depth 在执行子 workflow 时临时 +1。 + * Resources that can be shared by sub-workflows. When nesting, semaphore/budget/agentCountBox are shared by reference, + * and depth is temporarily +1 while executing a sub-workflow. */ export type SharedResources = { semaphore: Semaphore budget: Budget agentCountBox: { value: number } - /** agent() 调用的递增序号,盖戳 agent_started/agent_done 供进度精确关联。子 workflow 共享。 */ + /** Increasing sequence number for agent() calls; stamps agent_started/agent_done for precise progress correlation. Shared across sub-workflows. */ agentIdSeq: { value: number } depth: number } -/** 单次 workflow 运行的执行上下文。 */ +/** Execution context for a single workflow run. */ export type EngineContext = { ports: WorkflowPorts host: HostHandle @@ -52,7 +52,7 @@ export function createEngineContext(opts: { workflowName: string cwd: string budgetTotal: number | null - /** 单次 run 的并发槽位;undefined → DEFAULT_MAX_CONCURRENCY。经 clampMaxConcurrency 钳制。 */ + /** Concurrency slots for a single run; undefined → DEFAULT_MAX_CONCURRENCY. Clamped by clampMaxConcurrency. */ maxConcurrency?: number journal?: JournalEntry[] }): EngineContext { diff --git a/packages/workflow-engine/src/engine/errors.ts b/packages/workflow-engine/src/engine/errors.ts index 9429e4183..7a5658e38 100644 --- a/packages/workflow-engine/src/engine/errors.ts +++ b/packages/workflow-engine/src/engine/errors.ts @@ -1,4 +1,4 @@ -/** 引擎级可预期错误(脚本错、上限、嵌套)。 */ +/** Engine-level expected errors (script errors, caps, nesting). */ export class WorkflowError extends Error { constructor(message: string) { super(message) @@ -6,10 +6,10 @@ export class WorkflowError extends Error { } } -/** workflow 被 abort(kill)。 */ +/** workflow was aborted (killed). */ export class WorkflowAbortedError extends Error { constructor() { - super('workflow 已被取消(abort)') + super('workflow has been aborted') this.name = 'WorkflowAbortedError' } } diff --git a/packages/workflow-engine/src/engine/hooks.ts b/packages/workflow-engine/src/engine/hooks.ts index a202a3917..1e1f380bd 100644 --- a/packages/workflow-engine/src/engine/hooks.ts +++ b/packages/workflow-engine/src/engine/hooks.ts @@ -11,7 +11,7 @@ import { WorkflowAbortedError, WorkflowError } from './errors.js' import { agentCallKey } from './journal.js' import type { WorkflowHooks } from './script.js' -/** workflow() 钩子的子 workflow 执行器(由 runWorkflow 注入,避免循环依赖)。 */ +/** Sub-workflow executor for the workflow() hook (injected by runWorkflow to avoid circular dependencies). */ export type SubWorkflowRunner = (opts: { name?: string scriptPath?: string @@ -44,7 +44,7 @@ export function makeHooks( ctx: EngineContext, runSubWorkflow: SubWorkflowRunner, ): WorkflowHooks { - // 所有进度事件自动注入 runId,供 adapter 路由到对应 task(多并发 workflow) + // All progress events auto-inject runId so the adapter can route them to the corresponding task (multiple concurrent workflows) const emit = (init: HookProgressInit): void => { ctx.ports.progressEmitter.emit({ runId: ctx.runId, @@ -56,11 +56,11 @@ export function makeHooks( const r = ctx.resources if (r.agentCountBox.value >= MAX_TOTAL_AGENTS) { throw new WorkflowError( - `workflow 超过 agent 总数上限 (${MAX_TOTAL_AGENTS})`, + `workflow exceeds total agent cap (${MAX_TOTAL_AGENTS})`, ) } - // 每次 agent() 调用分配唯一 id(含 journal 命中),盖戳 started/done 供 reducer 精确关联 + // Assign a unique id to each agent() call (including journal hits); stamp started/done so the reducer can associate them precisely const agentId = r.agentIdSeq.value++ const params: AgentRunParams = { prompt, ...opts } @@ -69,7 +69,7 @@ export function makeHooks( const phase = (opts.phase as string | undefined) ?? ctx.currentPhase ?? undefined - // journal 命中 → 直接返回缓存 + // Journal hit -> return cached result directly if (!ctx.journalInvalidated && ctx.journalIndex < ctx.journal.length) { const entry = ctx.journal[ctx.journalIndex]! if (entry.key === key) { @@ -83,7 +83,7 @@ export function makeHooks( }) return resultToOutput(entry.result) } - // 发散:丢弃后续 journal,后续全部现场跑 + // Divergence: discard subsequent journal entries; everything from here on runs live ctx.journalInvalidated = true ctx.journal = ctx.journal.slice(0, ctx.journalIndex) await ctx.ports.journalStore.truncate(ctx.runId) @@ -93,14 +93,14 @@ export function makeHooks( try { release = await ctx.resources.semaphore.acquire(ctx.signal) } catch { - // abort 期间在队列中等待:semaphore 已把 waiter 移除、未消耗 permit + // Queued wait during abort: the semaphore already removed the waiter and did not consume a permit throw new WorkflowAbortedError() } try { if (ctx.signal.aborted) throw new WorkflowAbortedError() - // 预算检查在 semaphore 临界区内:queued waiter 被唤醒后看到最新 spent, - // 否则 N 个 waiter 入队时 spent=0 全过检,唤醒后无 re-check 全部超支。 - // journal 命中路径不扣预算,无需检查。 + // Budget check inside the semaphore critical section: a queued waiter sees the latest spent when woken, + // otherwise N waiters enqueued while spent=0 all pass the check and overspend on wake-up without re-check. + // Journal-hit path does not charge budget and needs no check. r.budget.assertCanSpend() const pending = ctx.ports.taskRegistrar.pendingAction(ctx.runId) @@ -113,14 +113,14 @@ export function makeHooks( ctx.resources.agentCountBox.value++ emit({ type: 'agent_started', agentId, label, phase }) const registry = ctx.ports.agentAdapterRegistry - // onProgress 闭包:后端循环累计 token/tool → 发 agent_progress 事件(带 agentId 关联) + // onProgress closure: the backend loop accumulates token/tool counts -> emits an agent_progress event (carrying agentId for association) const onProgress = (update: AgentProgressUpdate): void => { emit({ type: 'agent_progress', agentId, label, phase, ...update }) } - // 注入 agent 级 AbortController 注册/注销:backend 创建 controller 后调 - // registerAgentAbort 注入 ports 层 bindings,service.kill(runId, agentId) 据此 - // 精确中断单个 agent。registry 不存在(agentRunner 兜底路径)时无 backend 中间层, - // ports 层 agentAbortControllers 永远空——单 agent kill 在该路径降级为 no-op。 + // Inject agent-level AbortController register/unregister: the backend creates the controller then calls + // registerAgentAbort to inject ports-layer bindings; service.kill(runId, agentId) uses this to + // precisely abort a single agent. When the registry is absent (agentRunner fallback path), there is no backend middle layer, + // and agentAbortControllers at the ports layer is always empty — single-agent kill degrades to a no-op on this path. const adapterCtx = registry ? { host: ctx.host, @@ -154,22 +154,22 @@ export function makeHooks( : {}), } : null - // resolve 在 try 外:配置错(AdapterNotFoundError 等)直接上抛,不走重试—— - // 这是 workflow 配置问题而非 backend 临时故障,重试无意义且掩盖 bug。 + // resolve is outside the try: configuration errors (e.g. AdapterNotFoundError) propagate directly without retry — + // this is a workflow configuration problem, not a transient backend failure; retrying is meaningless and would mask the bug. const adapter = registry ? registry.resolve(params) : null const invokeBackend = (): Promise => adapter ? adapter.run(params, adapterCtx!) : ctx.ports.agentRunner.runAgentToResult(params, ctx.host) - // 失败一次自动重试:dead(terminal API error after retries)或 非 abort 抛错 - // 都给一次重试机会;WorkflowAbortedError(kill)不重试——是用户意图。 - // 重试仍失败:dead 保持 dead;throw 降级为 dead(不让一个 agent 击穿 workflow)。 - // budget 不重复扣:dead 不 addOutputTokens;重试 ok 才扣一次(最终 ok 时)。 - // dead.reason 透传到日志:no-structured-output(agent 最终文本块没产 plain-object JSON) - // 是高频死因;detail 进日志能立刻看到 agent 最后说了什么。 - // detail 用 String() 包裹防御:旧 journal 或第三方 adapter 可能写入非 string(损坏数据), - // 直接 .slice 会抛 TypeError 击穿日志路径。 + // Auto-retry once on failure: dead (terminal API error after retries) or a non-abort throw + // both get one retry chance; WorkflowAbortedError (kill) is not retried — it is the user's intent. + // If retry still fails: dead stays dead; a throw degrades to dead (one agent must not take down the workflow). + // budget is not double-charged: dead does not call addOutputTokens; retry-ok charges once (at the final ok). + // dead.reason is passed through to the log: no-structured-output (the agent's final text block did not produce plain-object JSON) + // is a high-frequency cause of death; logging detail lets you immediately see what the agent last said. + // detail is wrapped with String() defensively: old journals or third-party adapters may write non-strings (corrupted data), + // and calling .slice directly would throw a TypeError that pierces the logging path. let result: AgentRunResult try { result = await invokeBackend() @@ -194,7 +194,7 @@ export function makeHooks( result = await invokeBackend() } catch (e2) { if (e2 instanceof WorkflowAbortedError) throw e2 - // 重试仍抛:降级 dead(保持 workflow 继续;hooks.agent 返 null) + // Retry still threw: degrade to dead (keep the workflow going; hooks.agent returns null) result = { kind: 'dead', reason: 'runagent-threw', @@ -208,8 +208,8 @@ export function makeHooks( emit({ type: 'agent_done', agentId, label, phase, result }) const entry: JournalEntry = { key, seq: agentId, result } - // 关键:push 顺序 = 完成顺序(非调用顺序);read() 已按 seq 重排, - // 因此 resume 时调用顺序与 journal 顺序对齐,key 索引稳定。 + // Key point: push order = completion order (not call order); read() already re-sorts by seq, + // so during resume the call order aligns with the journal order and the key index stays stable. ctx.journal.push(entry) ctx.journalIndex++ await ctx.ports.journalStore.append(ctx.runId, entry) @@ -222,7 +222,7 @@ export function makeHooks( const parallel: WorkflowHooks['parallel'] = async thunks => { if (thunks.length > MAX_ITEMS_PER_CALL) { throw new WorkflowError( - `parallel 超过单次调用 items 上限 (${MAX_ITEMS_PER_CALL})`, + `parallel exceeds the per-call items cap (${MAX_ITEMS_PER_CALL})`, ) } return Promise.all( @@ -230,7 +230,7 @@ export function makeHooks( try { return await t() } catch (e) { - // "null on error"契约不变,但应 log——否则 workflow 作者无法定位为何 agent 失败 + // The "null on error" contract is unchanged, but it should log — otherwise the workflow author cannot locate why an agent failed ctx.ports.logger.warn?.( `parallel thunk #${i} failed: ${(e as Error).message}`, ) @@ -248,7 +248,7 @@ export function makeHooks( ): Promise> => { if (items.length > MAX_ITEMS_PER_CALL) { throw new WorkflowError( - `pipeline 超过单次调用 items 上限 (${MAX_ITEMS_PER_CALL})`, + `pipeline exceeds the per-call items cap (${MAX_ITEMS_PER_CALL})`, ) } return Promise.all( @@ -283,7 +283,7 @@ export function makeHooks( const workflow: WorkflowHooks['workflow'] = async (nameOrRef, args) => { if (ctx.resources.depth >= 1) { - throw new WorkflowError('workflow() 嵌套仅允许一层') + throw new WorkflowError('workflow() nesting allows only one level') } const sub: Parameters[0] = typeof nameOrRef === 'string' diff --git a/packages/workflow-engine/src/engine/journal.ts b/packages/workflow-engine/src/engine/journal.ts index df7b2a07a..24bf20927 100644 --- a/packages/workflow-engine/src/engine/journal.ts +++ b/packages/workflow-engine/src/engine/journal.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import type { JournalStore } from '../ports.js' import type { AgentRunParams, JournalEntry } from '../types.js' -/** 去掉纯展示字段后的规范化参数字符串。 */ +/** Canonical parameter string after removing display-only fields. */ function canonicalParams(params: AgentRunParams): string { const { label: _label, phase: _phase, ...rest } = params const keys = Object.keys(rest).sort() @@ -13,14 +13,14 @@ function canonicalParams(params: AgentRunParams): string { return JSON.stringify(sorted) } -/** agent() 调用的确定性 key(prompt + 规范化 params 的 sha256)。 */ +/** Determinism key for an agent() call (sha256 of prompt + canonical params). */ export function agentCallKey(prompt: string, params: AgentRunParams): string { return createHash('sha256') .update(prompt + '\n' + canonicalParams(params)) .digest('hex') } -/** 文件式 JournalStore(jsonl,每个 run 一个目录)。纯 fs,无核心依赖。 */ +/** File-based JournalStore (jsonl, one directory per run). Pure fs, no core dependencies. */ export function createFileJournalStore(runsDir: string): JournalStore { const pathOf = (runId: string) => join(runsDir, runId, 'journal.jsonl') @@ -32,8 +32,8 @@ export function createFileJournalStore(runsDir: string): JournalStore { .split('\n') .filter(line => line.trim().length > 0) .map(line => JSON.parse(line) as JournalEntry) - // parallel 完成顺序 ≠ 调用顺序;按 seq 重排,使 resume 期间 key 索引稳定。 - // 缺 seq 的旧 entry 视为 0(保持向前兼容,最坏情况下退化为文件顺序)。 + // parallel completion order ≠ call order; re-sort by seq so the key index is stable during resume. + // Old entries missing seq are treated as 0 (forward compatibility; worst case degrades to file order). return entries.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0)) } catch { return [] diff --git a/packages/workflow-engine/src/engine/namedWorkflows.ts b/packages/workflow-engine/src/engine/namedWorkflows.ts index 3f65e31e3..3a42d637b 100644 --- a/packages/workflow-engine/src/engine/namedWorkflows.ts +++ b/packages/workflow-engine/src/engine/namedWorkflows.ts @@ -11,25 +11,25 @@ function isScriptExt(ext: string): ext is Ext { ) } -/** 按 .ts → .js → .mjs 优先级解析命名 workflow 文件。 */ +/** Resolve a named workflow file by priority .ts → .js → .mjs. */ export async function resolveNamedWorkflow( workflowDir: string, name: string, ): Promise<{ path: string; content: string } | null> { for (const ext of WORKFLOW_SCRIPT_EXTENSIONS) { const p = resolve(workflowDir, name + ext) - // 双保险:防止上层 sanitize 漏掉的边界 case 把路径遍历到 workflowDir 之外 + // Double safeguard: prevents edge cases missed by the upper-layer sanitize from traversing paths outside workflowDir if (!containsPath(workflowDir, p)) return null try { return { path: p, content: await readFile(p, 'utf-8') } } catch { - // 试下一个扩展名 + // try the next extension } } return null } -/** 列出目录下所有命名 workflow(不含非脚本文件)。 */ +/** List all named workflows in the directory (excluding non-script files). */ export async function listNamedWorkflows( workflowDir: string, ): Promise { diff --git a/packages/workflow-engine/src/engine/paths.ts b/packages/workflow-engine/src/engine/paths.ts index 7744473d2..ca4f90af0 100644 --- a/packages/workflow-engine/src/engine/paths.ts +++ b/packages/workflow-engine/src/engine/paths.ts @@ -1,9 +1,9 @@ import { resolve, sep } from 'node:path' /** - * 判断 target 解析后是否位于 base 之内(含等于 base)。 - * 相对 target 会相对 base 解析(不依赖 process.cwd)。 - * 用 `sep` 边界避免前缀假阳(如 `/foo` 不是 `/foobar` 的父目录)。 + * Determine whether target, after resolution, is within base (including equal to base). + * Relative targets are resolved against base (does not depend on process.cwd). + * Uses the `sep` boundary to avoid false prefix positives (e.g. `/foo` is not the parent of `/foobar`). */ export function containsPath(base: string, target: string): boolean { const resolvedBase = resolve(base) @@ -13,9 +13,9 @@ export function containsPath(base: string, target: string): boolean { } /** - * 校验命名 workflow 的 name 是否为合法标识符(拒绝路径遍历)。 - * 拒绝:含路径分隔符、null 字节、`.` / `..`。 - * 返回清洗后的 name,或 null 表示非法。 + * Validate whether the named workflow name is a legal identifier (reject path traversal). + * Rejects: path separators, null bytes, `.` / `..`. + * Returns the sanitized name, or null for illegal. */ export function sanitizeWorkflowName(name: string): string | null { if (typeof name !== 'string' || name.length === 0) return null diff --git a/packages/workflow-engine/src/engine/runWorkflow.ts b/packages/workflow-engine/src/engine/runWorkflow.ts index efb80944b..78d29c874 100644 --- a/packages/workflow-engine/src/engine/runWorkflow.ts +++ b/packages/workflow-engine/src/engine/runWorkflow.ts @@ -10,7 +10,7 @@ import { resolveNamedWorkflow } from './namedWorkflows.js' import { parseScript, type ParsedScript } from './script.js' export type RunWorkflowOptions = { - /** 已解析好的脚本源码。 */ + /** Already-resolved script source code. */ script: string args?: unknown runId: string @@ -20,11 +20,11 @@ export type RunWorkflowOptions = { signal: AbortSignal cwd: string budgetTotal: number | null - /** 单次 run 的并发槽位;undefined → DEFAULT_MAX_CONCURRENCY。 */ + /** Concurrency slots for a single run; undefined → DEFAULT_MAX_CONCURRENCY. */ maxConcurrency?: number - /** resume:true 时载入既有 journal 重放。 */ + /** resume: when true, load the existing journal and replay. */ resume?: boolean - /** resume 时脚本源码 hash 是否变化。true 则忽略 journal 全重跑。 */ + /** Whether the script source hash changed on resume. When true, ignore the journal and re-run everything. */ scriptChanged?: boolean } @@ -49,7 +49,7 @@ export async function runWorkflow( const workflowName = opts.workflowName ?? parsed.meta?.name ?? 'workflow' - // 载入 journal(仅 resume 且脚本未变) + // Load the journal (only on resume and when the script is unchanged) let journal: JournalEntry[] = [] let journalInvalidated = false if (opts.resume && !opts.scriptChanged) { @@ -79,14 +79,16 @@ export async function runWorkflow( meta: parsed.meta, }) - // 子 workflow 执行器:复用同一 ctx(共享 journal/并发/预算/计数),临时 +1 depth + // Sub-workflow executor: reuses the same ctx (sharing journal/concurrency/budget/counters), temporarily +1 depth const runSubWorkflow: SubWorkflowRunner = async sub => { const script = await resolveSubScript(sub, opts.cwd) let subParsed: ParsedScript try { subParsed = parseScript(script) } catch (e) { - throw new WorkflowError(`子 workflow 脚本错误:${(e as Error).message}`) + throw new WorkflowError( + `Sub-workflow script error: ${(e as Error).message}`, + ) } const prevDepth = ctx.resources.depth ctx.resources.depth += 1 @@ -100,9 +102,9 @@ export async function runWorkflow( const hooks = makeHooks(ctx, runSubWorkflow) - // hook.phase 只在切换 phase 时 emit 上一个 phase 的 phase_done;脚本结束时 - // currentPhase 是最后一个 phase,没有任何后续 phase() 触发其 phase_done → UI 左栏 - // 会永远显示 running(agent 列表已 ✓ done)。终态前补一条,所有 path 共用。 + // hook.phase only emits phase_done for the previous phase when switching phases; when the script ends, + // currentPhase is the last phase, and there is no subsequent phase() to trigger its phase_done → the left pane of the UI + // would stay running forever (the agent list already shows ✓ done). Emit one before the terminal state — shared by all paths. const emitTerminalPhaseDone = (): void => { if (!ctx.currentPhase) return ports.progressEmitter.emit({ @@ -147,8 +149,8 @@ async function resolveSubScript( join(cwd, WORKFLOW_DIR_NAME), sub.name, ) - if (!found) throw new WorkflowError(`子 workflow "${sub.name}" 未找到`) + if (!found) throw new WorkflowError(`Sub-workflow "${sub.name}" not found`) return found.content } - throw new WorkflowError('workflow() 需要 name 或 scriptPath') + throw new WorkflowError('workflow() requires name or scriptPath') } diff --git a/packages/workflow-engine/src/engine/script.ts b/packages/workflow-engine/src/engine/script.ts index db6be1b8a..6cf4cb85a 100644 --- a/packages/workflow-engine/src/engine/script.ts +++ b/packages/workflow-engine/src/engine/script.ts @@ -7,7 +7,7 @@ export class ScriptError extends Error { } } -/** 引擎注入脚本的钩子函数形状。 */ +/** Shape of the hook functions the engine injects into a script. */ export type WorkflowHooks = { agent: (prompt: string, opts?: Record) => Promise parallel: (thunks: Array<() => Promise>) => Promise> @@ -28,8 +28,8 @@ export type WorkflowHooks = { const META_RE = /export\s+const\s+meta\s*=\s*/ /** - * 提取 `export const meta = { ... }` 纯字面量。返回 meta 对象与剥离后的 body。 - * 字面量用无参 Function 求值——任何标识符引用都会抛 ReferenceError → 报「非纯字面量」。 + * Extract the `export const meta = { ... }` pure literal. Returns the meta object and the stripped body. + * The literal is evaluated with a parameter-less Function — any identifier reference throws ReferenceError → reported as "not a plain literal". */ export function extractMeta(source: string): { meta: WorkflowMeta | null @@ -41,10 +41,10 @@ export function extractMeta(source: string): { let i = match.index + match[0].length while (i < source.length && /\s/.test(source[i]!)) i++ if (source[i] !== '{') { - throw new ScriptError('meta 必须是对象字面量 `{ ... }`') + throw new ScriptError('meta must be an object literal `{ ... }`') } - // 大括号匹配(处理字符串/转义/嵌套) + // Brace matching (handles strings / escapes / nesting) let depth = 0 const start = i let inStr: string | null = null @@ -71,21 +71,21 @@ export function extractMeta(source: string): { } } } - if (depth !== 0) throw new ScriptError('meta 字面量大括号未闭合') + if (depth !== 0) throw new ScriptError('meta literal braces are not closed') const literal = source.slice(start, i) let metaObj: unknown try { - // 无参 Function:纯字面量可求值;引用任何标识符 → ReferenceError + // Parameter-less Function: a plain literal can be evaluated; referencing any identifier → ReferenceError metaObj = new Function(`return (${literal})`)() } catch (e) { throw new ScriptError( - `meta 必须是纯字面量(无变量/函数调用/插值):${(e as Error).message}`, + `meta must be a plain literal (no variable/function calls/interpolation): ${(e as Error).message}`, ) } const meta = validateMeta(metaObj) - // 剥离 meta 语句(含尾随分号与多余空行) + // Strip the meta statement (including trailing semicolon and extra blank lines) const body = (source.slice(0, match.index) + source.slice(i)).replace( /[ \t]*;[ \t]*\n/, '\n', @@ -95,20 +95,20 @@ export function extractMeta(source: string): { function validateMeta(v: unknown): WorkflowMeta { if (typeof v !== 'object' || v === null || Array.isArray(v)) { - throw new ScriptError('meta 必须是对象') + throw new ScriptError('meta must be an object') } const o = v as Record if (typeof o.name !== 'string' || typeof o.description !== 'string') { - throw new ScriptError('meta 必须含字符串 name 与 description') + throw new ScriptError('meta must include string name and description') } return o as unknown as WorkflowMeta } -// ---- 非确定性沙箱 shim ---- +// ---- Non-determinism sandbox shim ---- class NonDeterministicError extends Error { constructor(fn: string) { super( - `${fn} 在 workflow 脚本中不可用(会破坏 resume 的确定性)。请通过 args 传入时间戳/随机种子。`, + `${fn} is not available in workflow scripts (would break resume determinism). Pass timestamps/random seeds via args.`, ) this.name = 'NonDeterministicError' } @@ -157,32 +157,32 @@ export type ParsedScript = { ) => Promise } -/** 校验 + 包装脚本为可执行 async 函数(Date/Math 被 shim 覆盖)。 */ +/** Validate + wrap the script as an executable async function (Date/Math are shimmed). */ /** - * 检测脚本 body 的常见违例(import / 多余 export),给出带指引的精准错误。 - * 否则会落到 AsyncFunction 的泛化「语法错误」,模型/用户难定位根因 - * (脚本是非 ESM 函数体、钩子已注入、引擎不转译 TS)。 + * Detect common violations in the script body (import / extra export) and produce precise errors with guidance. + * Otherwise it would fall through to AsyncFunction's generic "syntax error", making it hard for the model/user to pinpoint the root cause + * (the script is a non-ESM function body, hooks are already injected, and the engine does not transpile TS). */ function assertScriptBody(body: string): void { if (/^\s*import\b/m.test(body)) { throw new ScriptError( - 'workflow 脚本是 new AsyncFunction 的函数体(非 ESM 模块),不支持 import。' + - 'agent / parallel / pipeline / phase / log / workflow / args / budget 已作为形参注入,直接使用。', + 'workflow scripts are the body of new AsyncFunction (not ESM modules); import is not supported. ' + + 'agent / parallel / pipeline / phase / log / workflow / args / budget are injected as parameters — use them directly.', ) } - // 动态 import(...) 调用:沙箱仅保 resume 确定性不保安全,但应阻止明显的逃逸尝试。 - // 不锚定行首以捕获 `await import(...)`、`return import(...)` 等位置;要求 `import` 后紧跟 `(` 才拦截, - // 避免误伤字符串字面量里出现 "import" 词(如 agent('please import this module'))。 + // Dynamic import(...) calls: the sandbox only preserves resume determinism, not security, but obvious escape attempts should be blocked. + // Not anchored to the start of a line so it can catch `await import(...)`, `return import(...)`, etc.; requires `import` followed by `(` to intercept, + // avoiding false positives where the word "import" appears inside a string literal (e.g. agent('please import this module')). if (/\bimport\s*\(/m.test(body)) { throw new ScriptError( - 'workflow 脚本中禁止动态 import(...):会绕过 Date/Math 沙箱,破坏 resume 确定性。' + - '沙箱不保安全(与 LLM 同级信任),但禁止显式逃逸。需要外部依赖时通过 args 注入。', + 'dynamic import(...) is forbidden in workflow scripts: it bypasses the Date/Math sandbox and breaks resume determinism. ' + + 'The sandbox does not guarantee security (same trust level as the LLM), but explicit escapes are prohibited. Inject external dependencies via args.', ) } if (/^\s*export\b/m.test(body)) { throw new ScriptError( - 'workflow 脚本只允许一处 export const meta = {...}(已被引擎提取)。' + - '请删除其余 export / export default;用顶层 return 返回结果。', + 'workflow scripts allow only one export const meta = {...} (already extracted by the engine). ' + + 'Remove other export / export default statements; use top-level return for the result.', ) } } @@ -206,7 +206,7 @@ export function parseScript(source: string): ParsedScript { body, ) } catch (e) { - throw new ScriptError(`脚本语法错误:${(e as Error).message}`) + throw new ScriptError(`Script syntax error: ${(e as Error).message}`) } const sandboxedDate = sandboxDate() const sandboxedMath = sandboxMath() diff --git a/packages/workflow-engine/src/engine/structuredOutput.ts b/packages/workflow-engine/src/engine/structuredOutput.ts index 950a54018..6cb4abb9f 100644 --- a/packages/workflow-engine/src/engine/structuredOutput.ts +++ b/packages/workflow-engine/src/engine/structuredOutput.ts @@ -3,8 +3,8 @@ import { Ajv, type ValidateFunction } from 'ajv' const cache = new WeakMap() /** - * 用 JSON Schema 校验 agent 输出(Ajv,编译结果按 schema 对象缓存)。 - * 引擎对 adapter 返回的 schema 结果做二次校验,并用于测试。 + * Validate agent output against a JSON Schema (Ajv, compilation result cached by schema object). + * The engine performs secondary validation on the schema result returned by the adapter, and uses it for tests. */ export function validateAgainstSchema( value: unknown, diff --git a/packages/workflow-engine/src/index.ts b/packages/workflow-engine/src/index.ts index 629ef4a4d..5d790123d 100644 --- a/packages/workflow-engine/src/index.ts +++ b/packages/workflow-engine/src/index.ts @@ -1,5 +1,5 @@ // @claude-code-best/workflow-engine -// 确定性 JS 脚本编排引擎。零核心层运行时依赖,通过端口适配与世界对话。 +// Deterministic JS script orchestration engine. Zero core-layer runtime dependencies; talks to the world via port adapters. export * from './types.js' export * from './constants.js' diff --git a/packages/workflow-engine/src/ports.ts b/packages/workflow-engine/src/ports.ts index 140355c9e..a0066d15d 100644 --- a/packages/workflow-engine/src/ports.ts +++ b/packages/workflow-engine/src/ports.ts @@ -7,9 +7,9 @@ import type { } from './types.js' /** - * 不透明 host 句柄。核心侧每次工具调用构造一个,内含 toolUseContext/ - * canUseTool/parentMessage 等。包内绝不检视其内部,只透传给 AgentRunner。 - * 这是包与核心层之间唯一的耦合缝隙,且是不透明的。 + * Opaque host handle. The core side constructs one per tool call, containing toolUseContext/ + * canUseTool/parentMessage, etc. The package never inspects its internals; it only passes it through to the AgentRunner. + * This is the only coupling seam between the package and the core layer, and it is opaque. */ const HOST_HANDLE = Symbol('workflow.hostHandle') @@ -17,12 +17,12 @@ export type HostBundle = unknown export type HostHandle = { readonly [HOST_HANDLE]: HostBundle } -/** 核心 side hostFactory 用:把任意 bundle 包成不透明句柄。 */ +/** Used by the core-side hostFactory: wraps any bundle into an opaque handle. */ export function createHostHandle(bundle: HostBundle): HostHandle { return { [HOST_HANDLE]: bundle } as HostHandle } -/** 类型守卫。 */ +/** Type guard. */ export function isHostHandle(value: unknown): value is HostHandle { return ( typeof value === 'object' && @@ -31,12 +31,12 @@ export function isHostHandle(value: unknown): value is HostHandle { ) } -/** 核心 side adapter 用:解包(仅 adapter 应调用)。 */ +/** Used by the core-side adapter: unwraps (only the adapter should call this). */ export function unwrapHostHandle(handle: HostHandle): HostBundle { return (handle as { [k: symbol]: HostBundle })[HOST_HANDLE] } -/** agent() 钩子的后端。 */ +/** Backend for the agent() hook. */ export type AgentRunner = { runAgentToResult( params: AgentRunParams, @@ -44,16 +44,16 @@ export type AgentRunner = { ): Promise } -/** 进度事件发射。 */ +/** Progress event emitter. */ export type ProgressEmitter = { emit(event: ProgressEvent): void } -/** 后台任务生命周期。 */ +/** Background task lifecycle. */ export type TaskRegistrar = { /** - * 注册后台任务。adapter 创建 AbortController 并存入 task 状态, - * 返回 runId 与 signal(供引擎 detached 执行 + kill 中止用)。 + * Register a background task. The adapter creates an AbortController and stores it in task state, + * returning runId and signal (for the engine to execute detached + kill to abort). */ register( opts: { @@ -61,7 +61,7 @@ export type TaskRegistrar = { workflowFile?: string summary?: string toolUseId?: string - /** resume 时复用既有 runId(读其 journal)。省略则生成新 id。 */ + /** On resume, reuse the existing runId (read its journal). Omit to generate a new id. */ runId?: string }, host: HostHandle, @@ -70,61 +70,61 @@ export type TaskRegistrar = { fail(runId: string, error: string): void kill(runId: string): void /** - * 注册 agent 级 AbortController。backend 启动 agent 时调用,让 service - * .kill(runId, agentId) 能精确中断单个 agent(不影响同 run 其他 agent)。 - * 幂等:同 agentId 重复注册覆盖。 + * Register an agent-level AbortController. Called by the backend when starting an agent, so that service + * .kill(runId, agentId) can precisely abort a single agent (without affecting other agents in the same run). + * Idempotent: re-registering with the same agentId overwrites. */ registerAgentAbort?(runId: string, agentId: number, ac: AbortController): void /** - * 注销 agent 级 AbortController(agent 完成/失败时调;幂等)。 + * Unregister an agent-level AbortController (called when the agent completes/fails; idempotent). */ unregisterAgentAbort?(runId: string, agentId: number): void /** - * 中断单个 agent。返回是否命中(false = agent 已完成/不存在)。 - * 不影响同 run 其他 agent,workflow 继续跑(被中断 agent 返回 dead → null)。 + * Abort a single agent. Returns whether it hit (false = agent already completed/does not exist). + * Does not affect other agents in the same run; the workflow continues (the aborted agent returns dead → null). */ killAgent?(runId: string, agentId: number): boolean - /** 返回当前待处理的 skip/retry 动作,或 null。 */ + /** Returns the current pending skip/retry action, or null. */ pendingAction(runId: string): { kind: 'skip' | 'retry' } | null } -/** journal 持久化。 */ +/** Journal persistence. */ export type JournalStore = { read(runId: string): Promise append(runId: string, entry: JournalEntry): Promise truncate(runId: string): Promise } -/** 取消/权限门。 */ +/** Cancellation / permission gate. */ export type PermissionGate = { isAborted(host: HostHandle): boolean } -/** 日志 + 遥测。 */ +/** Logging + telemetry. */ export type Logger = { debug(msg: string): void event(name: string, metadata?: Record): void /** - * 警告级日志(如 parallel/pipeline 单项失败被吞掉的错误)。 - * Optional:旧 ports 实现可省略;hooks 用 `?.()` 容错。 + * Warning-level log (e.g. errors swallowed when a single parallel/pipeline item fails). + * Optional: old ports implementations may omit it; hooks tolerate it with `?.()`. */ warn?(msg: string): void } -/** 引擎从 host 提取的可直接使用上下文(句柄 + 基本字段)。 */ +/** Ready-to-use context the engine extracts from the host (handle + basic fields). */ export type WorkflowHostContext = { - /** 透传给 AgentRunner 的不透明句柄(内含 toolUseContext/canUseTool/parentMessage)。 */ + /** Opaque handle passed through to the AgentRunner (contains toolUseContext/canUseTool/parentMessage). */ handle: HostHandle cwd: string - /** token 预算上限,null 表示无限制。 */ + /** Token budget cap; null means unlimited. */ budgetTotal: number | null - /** 核心 side 的工具调用 ID(透传给 task 注册)。 */ + /** Core-side tool-use id (passed through to task registration). */ toolUseId?: string } /** - * 核心 side 提供:从工具调用的核心上下文构造 WorkflowHostContext。 - * 参数对包是不透明的(unknown);核心侧 hostFactory 知道真实类型。 + * Provided by the core side: constructs a WorkflowHostContext from the tool call's core context. + * The arguments are opaque to the package (unknown); the core-side hostFactory knows the real types. */ export type HostFactory = (args: { context: unknown @@ -132,12 +132,12 @@ export type HostFactory = (args: { parentMessage: unknown }) => WorkflowHostContext -/** 所有端口的聚合。createWorkflowTool(ports) 注入。 */ +/** Aggregate of all ports. Injected into createWorkflowTool(ports). */ export type WorkflowPorts = { agentRunner: AgentRunner /** - * 多后端 adapter registry。提供时优先于 agentRunner——hooks.agent 按 registry - * 路由到 adapter.run;省略则回退 agentRunner(兼容旧用法)。 + * Multi-backend adapter registry. When provided, takes precedence over agentRunner — hooks.agent routes + * to adapter.run via the registry; when omitted, falls back to agentRunner (backward compatibility). */ agentAdapterRegistry?: AgentAdapterRegistry progressEmitter: ProgressEmitter diff --git a/packages/workflow-engine/src/progress/events.ts b/packages/workflow-engine/src/progress/events.ts index b3e4e15e1..4ac6a54a2 100644 --- a/packages/workflow-engine/src/progress/events.ts +++ b/packages/workflow-engine/src/progress/events.ts @@ -3,14 +3,14 @@ import type { ProgressEvent } from '../types.js' export type { ProgressEvent } -/** 从单个回调构造 ProgressEmitter。 */ +/** Construct a ProgressEmitter from a single callback. */ export function createProgressEmitter( onEvent: (e: ProgressEvent) => void, ): ProgressEmitter { return { emit: onEvent } } -/** 收集所有事件到数组(测试用)。 */ +/** Collect all events into an array (for tests). */ export function createBufferingEmitter(): { emitter: ProgressEmitter events: ProgressEvent[] diff --git a/packages/workflow-engine/src/tool/WorkflowTool.ts b/packages/workflow-engine/src/tool/WorkflowTool.ts index 72f284a9f..f15607c2b 100644 --- a/packages/workflow-engine/src/tool/WorkflowTool.ts +++ b/packages/workflow-engine/src/tool/WorkflowTool.ts @@ -11,7 +11,7 @@ import type { WorkflowRunResult } from '../types.js' import { workflowInputSchema, type WorkflowInput } from './schema.js' import { persistInlineScript } from './persistInline.js' -/** 自包含工具描述符(核心 wiring 用 buildTool 包装它)。零核心层依赖。 */ +/** Self-contained tool descriptor (core wiring wraps it with buildTool). Zero core-layer dependencies. */ export type WorkflowToolDescriptor = { name: string inputSchema: z.ZodType @@ -66,7 +66,7 @@ export function createWorkflowTool( isReadOnly: () => false, async description() { - return '执行一个 workflow 脚本,编排多个子 agent 完成任务' + return 'Execute a workflow script that orchestrates multiple subagents to complete a task' }, async prompt() { @@ -84,7 +84,7 @@ export function createWorkflowTool( async call(input, context, canUseTool, parentMessage) { const host = ports.hostFactory({ context, canUseTool, parentMessage }) - // 解析脚本源 + // Resolve the script source let script: string let workflowFile: string | undefined try { @@ -95,12 +95,14 @@ export function createWorkflowTool( return { data: { output: `Error: ${(e as Error).message}` } } } - // 快速校验(meta + 语法),失败直接返错给模型,不进后台 + // Quick validation (meta + syntax): on failure return an error to the model directly, do not enter the background try { parseScript(script) } catch (e) { return { - data: { output: `Error: 脚本校验失败:${(e as Error).message}` }, + data: { + output: `Error: script validation failed: ${(e as Error).message}`, + }, } } @@ -116,9 +118,9 @@ export function createWorkflowTool( host.handle, ) - // inline 入口持久化脚本到 run 目录,返回可复用路径(ultracode skill 承诺的 - // inline → 持久化 → 编辑 → scriptPath 重提迭代循环)。写盘失败降级为占位符 - // + warn,不阻断 run(script 已在内存)。 + // Inline entry: persist the script to the run directory and return a reusable path (the + // inline -> persist -> edit -> resubmit-as-scriptPath iteration loop promised by the ultracode skill). + // On write failure degrade to a placeholder + warn, do not abort the run (script is already in memory). if (!workflowFile && input.script) { try { workflowFile = await persistInlineScript( @@ -133,7 +135,7 @@ export function createWorkflowTool( } } - // detached 执行 + // Detached execution void runWorkflow({ script, ...(input.args !== undefined @@ -158,12 +160,12 @@ export function createWorkflowTool( return { data: { output: [ - 'Workflow 已启动(后台执行)。', + 'Workflow started (running in the background).', `run_id: ${runId}`, `workflow: ${workflowName}`, `script: ${scriptPath}`, '', - '完成时会自动通知。用 /workflows 查看实时进度。', + 'You will be notified on completion. Use /workflows to view live progress.', ].join('\n'), }, } @@ -207,8 +209,8 @@ function formatValue(v: unknown): string { } /** - * 防御性归一化 args:旧 `z.string()` 契约下模型可能发送字符串化的 JSON 对象。 - * 仅当字符串能 JSON.parse 出对象/数组时归一化;纯字符串、数字等保留原值。 + * Defensively normalize args: under the legacy `z.string()` contract the model may send a stringified JSON object. + * Only normalize when the string JSON.parses to an object/array; plain strings, numbers, etc. are preserved as-is. */ function normalizeArgs(raw: unknown): unknown { if (typeof raw !== 'string') return raw @@ -230,7 +232,7 @@ async function resolveScriptSource( const resolved = resolve(cwd, input.scriptPath) if (!containsPath(cwd, resolved)) { throw new Error( - `scriptPath "${input.scriptPath}" 越界(resolve 后 ${resolved} 不在 cwd ${cwd} 之内)`, + `scriptPath "${input.scriptPath}" is out of bounds (after resolve, ${resolved} is not within cwd ${cwd})`, ) } return { @@ -241,7 +243,7 @@ async function resolveScriptSource( if (input.name) { if (sanitizeWorkflowName(input.name) === null) { throw new Error( - `命名 workflow 名字 "${input.name}" 非法(含路径分隔符或为 . / ..)`, + `Named workflow name "${input.name}" is invalid (contains path separators or is . / ..)`, ) } const found = await resolveNamedWorkflow( @@ -250,10 +252,10 @@ async function resolveScriptSource( ) if (!found) { throw new Error( - `命名 workflow "${input.name}" 未找到(查找目录 ${WORKFLOW_DIR_NAME}/)`, + `Named workflow "${input.name}" not found (looked in ${WORKFLOW_DIR_NAME}/)`, ) } return { script: found.content, workflowFile: found.path } } - throw new Error('必须提供 script、name 或 scriptPath 之一') + throw new Error('One of script, name, or scriptPath must be provided') } diff --git a/packages/workflow-engine/src/tool/schema.ts b/packages/workflow-engine/src/tool/schema.ts index ab47783ad..a2b46e421 100644 --- a/packages/workflow-engine/src/tool/schema.ts +++ b/packages/workflow-engine/src/tool/schema.ts @@ -1,28 +1,34 @@ import { z } from 'zod/v4' -/** Workflow 工具输入 schema。args 为任意 JSON 值(对象/数组/字符串等)。 */ +/** Workflow tool input schema. args is any JSON value (object/array/string/etc.). */ export const workflowInputSchema = z.object({ script: z .string() .optional() - .describe('自包含的 workflow 脚本源码(inline)'), + .describe('Self-contained workflow script source (inline)'), name: z .string() .optional() - .describe('命名 workflow,解析到 .claude/workflows/.ts|js|mjs'), - scriptPath: z.string().optional().describe('已有脚本文件的绝对路径'), + .describe('Named workflow, resolved to .claude/workflows/.ts|js|mjs'), + scriptPath: z + .string() + .optional() + .describe('Absolute path to an existing script file'), args: z .unknown() .optional() .describe( - '透传给脚本的 args 全局变量。传真实 JSON 值(对象/数组/字符串),不要传 JSON 字符串。', + 'The args global variable passed through to the script. Pass a real JSON value (object/array/string), not a JSON string.', ), resumeFromRunId: z .string() .optional() - .describe('resume 指定 run,重放 journal'), - description: z.string().optional().describe('本次调用的简短描述(3-5 词)'), - title: z.string().optional().describe('进度查看器标题'), + .describe('Resume the specified run, replaying the journal'), + description: z + .string() + .optional() + .describe('A short description of this invocation (3-5 words)'), + title: z.string().optional().describe('Progress viewer title'), maxConcurrency: z .number() .int() @@ -30,17 +36,17 @@ export const workflowInputSchema = z.object({ .max(16) .optional() .describe( - '并发 agent() 上限。默认 3(最大 16)。当 workflow 包含大量 parallel/pipeline fan-out 时,可在启动前用 AskUserQuestion 与用户确认期望并发。', + 'Concurrency cap for agent(). Defaults to 3 (max 16). When the workflow contains heavy parallel/pipeline fan-out, you may confirm the desired concurrency with the user via AskUserQuestion before launching.', ), }) /** - * Workflow 工具输入类型——从 schema 派生,避免手工 type 与 schema 漂移。 - * 旧实现里 {@link WorkflowInput} 在 types.ts 手写、schema 在 schema.ts, - * 中间靠 `as unknown as z.ZodType` 双重断言连接——schema 改字段 - * 但 type 没动时 TS 不会报错。z.infer 后 schema/type 永远同步。 + * Workflow tool input type — derived from the schema to avoid hand-written type/schema drift. + * In the old implementation {@link WorkflowInput} was hand-written in types.ts and the schema in schema.ts, + * bridged by a `as unknown as z.ZodType` double assertion — when the schema changed fields + * but the type did not, TS would not flag it. With z.infer, schema/type stay in sync forever. */ export type WorkflowInput = z.infer -/** schema 的 typeof 类型(用于"以 schema 为准"的精确签名)。 */ +/** typeof type of the schema (used for "schema is the source of truth" precise signatures). */ export type WorkflowInputSchema = typeof workflowInputSchema diff --git a/packages/workflow-engine/src/types.ts b/packages/workflow-engine/src/types.ts index 33f95ffe9..638a87df7 100644 --- a/packages/workflow-engine/src/types.ts +++ b/packages/workflow-engine/src/types.ts @@ -1,7 +1,7 @@ -// 纯类型定义。无运行时依赖。 -// WorkflowInput 已迁移到 tool/schema.ts,用 z.infer 派生避免与 schema 漂移。 +// Pure type definitions. No runtime dependencies. +// WorkflowInput has been migrated to tool/schema.ts and derived via z.infer to avoid drift from the schema. -/** 脚本 `export const meta = {...}` 的形状(必须是纯字面量)。 */ +/** Shape of the script's `export const meta = {...}` (must be a plain literal). */ export type WorkflowMeta = { name: string description: string @@ -9,77 +9,77 @@ export type WorkflowMeta = { phases?: Array<{ title: string; detail?: string }> } -/** agent() 传给 AgentRunner 的参数。 */ +/** Parameters passed by agent() to the AgentRunner. */ export type AgentRunParams = { prompt: string - /** JSON Schema;提供时 agent 返回校验对象而非文本。 */ + /** JSON Schema; when provided, agent returns a validated object instead of text. */ schema?: object model?: string - /** 输出 token 上限(透传给 agent 后端,如 LLM 的 max_tokens)。 */ + /** Output token cap (passed through to the agent backend, e.g. LLM max_tokens). */ maxTokens?: number - /** 自定义子 agent 类型(从 registry 解析)。 */ + /** Custom subagent type (resolved from the registry). */ agentType?: string isolation?: 'worktree' allowedTools?: string[] - /** 仅展示用,不计入 journal key。 */ + /** Display-only; not part of the journal key. */ label?: string - /** 仅展示用,不计入 journal key。 */ + /** Display-only; not part of the journal key. */ phase?: string } -/** agent 运行中进度快照(onProgress 回调载荷;后端循环累计 token/tool)。 */ +/** Progress snapshot while the agent is running (onProgress callback payload; backend loop accumulates tokens/tools). */ export type AgentProgressUpdate = { tokenCount: number toolCount: number } /** - * AgentRunner 返回。ok 变体携带 model/toolCount 供面板展示(可选,独立后端可不填)。 + * Returned by AgentRunner. The ok variant carries model/toolCount for panel display (optional; standalone backends may leave them blank). * - * dead 携带可选 reason/detail:journal 历史只记 `{kind:"dead"}` 无信息, - * 调试时无法区分"agent 跑完没产 StructuredOutput"还是"runAgent 抛错"。 - * reason 让 hooks 重试日志、面板、事后审计能立刻看到死因。 + * dead carries optional reason/detail: the journal history only records `{kind:"dead"}` with no info, + * so during debugging you cannot distinguish "agent finished but produced no StructuredOutput" from "runAgent threw". + * reason lets the hooks retry log, the panel, and post-hoc auditing see the cause of death immediately. */ export type AgentRunResult = | { kind: 'ok' output: string | object usage: { outputTokens: number } - /** 实际解析后的 model id(展示用)。 */ + /** The actually-resolved model id (display-only). */ model?: string - /** agent 运行期间工具调用次数。 */ + /** Number of tool calls during the agent run. */ toolCount?: number - /** 完成时的 context 总 token 数(展示用;与 agent_progress 实时口径一致)。 */ + /** Total context tokens at completion (display-only; same basis as the real-time agent_progress). */ tokenCount?: number } | { kind: 'skipped' } | { kind: 'dead' /** - * 死因分类,方便日志聚合 / 事后审计。可选以兼容旧 journal。 - * - no-structured-output:agent 完成但 finalize content 无 StructuredOutput(既没调工具也没在文本里产 JSON) - * - runagent-threw:runAgent 抛非 abort 错误(API 故障 / context 溢出 / runtime 错误) - * - worktree-failed:isolation:'worktree' 创建失败(fail-closed 退化) - * - unknown:未分类(兼容旧 backend / 第三方 adapter) + * Cause-of-death classification for log aggregation / post-hoc auditing. Optional for backward compatibility with old journals. + * - no-structured-output: agent finished but finalize content has no StructuredOutput (neither called tools nor produced JSON in text) + * - runagent-threw: runAgent threw a non-abort error (API failure / context overflow / runtime error) + * - worktree-failed: isolation:'worktree' creation failed (fail-closed degradation) + * - unknown: unclassified (compatible with old backends / third-party adapters) */ reason?: | 'no-structured-output' | 'runagent-threw' | 'worktree-failed' | 'unknown' - /** 详细信息(错误 message / 文本预览),用于日志,不展示给最终用户。 */ + /** Detail (error message / text preview) for logs; not shown to end users. */ detail?: string } -/** journal 中单条记录。seq = agent() 调用序号,read() 据此重排以稳定 resume。 */ +/** A single record in the journal. seq = agent() call sequence number; read() re-sorts by it to stabilize resume. */ export type JournalEntry = { key: string - /** agent() 调用顺序(来自 agentIdSeq,跨 sub-workflow 单调递增)。 */ + /** agent() call order (from agentIdSeq; monotonically increasing across sub-workflows). */ seq: number result: AgentRunResult } -/** 进度事件。所有变体携带 runId,供 adapter 路由到对应 task(多并发 workflow)。 */ +/** Progress events. All variants carry runId so the adapter can route to the corresponding task (multiple concurrent workflows). */ export type ProgressEvent = | { type: 'run_started' @@ -122,7 +122,7 @@ export type ProgressEvent = error?: string } -/** 引擎运行结果。 */ +/** Engine run result. */ export type WorkflowRunResult = { status: 'completed' | 'failed' | 'killed' returnValue?: unknown diff --git a/src/workflow/__tests__/WorkflowsPanel.test.tsx b/src/workflow/__tests__/WorkflowsPanel.test.tsx index 071fe1571..6026ea8e8 100644 --- a/src/workflow/__tests__/WorkflowsPanel.test.tsx +++ b/src/workflow/__tests__/WorkflowsPanel.test.tsx @@ -10,36 +10,36 @@ import { truncateLabel } from '../panel/AgentList.js'; import { STATUS_DOT } from '../panel/status.js'; import { __resetWorkflowServiceForTests, getWorkflowService } from '../service.js'; -// 纯函数:选中夹紧到有效区间(与面板内 clampSelected 同源)。 -test('clampSelected:空列表→0;越界→末位;负/NaN→0;正常→原值', () => { +// Pure function: clamp selection to valid range (same source as clampSelected inside the panel). +test('clampSelected: empty list → 0; out of bounds → last; negative/NaN → 0; normal → original', () => { expect(clampSelected(5, 0)).toBe(0); expect(clampSelected(5, 3)).toBe(2); expect(clampSelected(-3, 3)).toBe(0); expect(clampSelected(1, 3)).toBe(1); expect(clampSelected(0, 1)).toBe(0); - // NaN(如未初始化状态)安全回落到 0 + // NaN (e.g. uninitialized state) safely falls back to 0 expect(clampSelected(Number.NaN, 3)).toBe(0); }); -// truncateLabel:短 label 原样;含 `#数字` 后缀时保留后缀,前缀截断 + 省略号; -// 无后缀则从右切。让 audit workflow 的 verify:${dim}#${idx} 多 finding 仍可区分。 -test('truncateLabel:短 label 原样;含 #数字 后缀保留后缀前缀截断;无后缀从右切', () => { - // 短 label 原样 +// truncateLabel: short label as-is; with `#number` suffix keep suffix, truncate prefix + ellipsis; +// without suffix, cut from the right. Lets audit workflow's verify:${dim}#${idx} multi-finding still be distinguishable. +test('truncateLabel: short label as-is; with #number suffix keep suffix and truncate prefix; without suffix cut from right', () => { + // short label as-is expect(truncateLabel('agent-1', 18)).toBe('agent-1'); expect(truncateLabel('review:bugs', 18)).toBe('review:bugs'); - // 刚好 max 长度(边界) + // exactly max length (boundary) expect(truncateLabel('review:correctness', 18)).toBe('review:correctness'); - // 超 max + 含 #数字 后缀:保留后缀,前缀截断 + 省略号 + // over max + with #number suffix: keep suffix, truncate prefix + ellipsis expect(truncateLabel('verify:correctness#0', 18)).toBe('verify:correctn…#0'); expect(truncateLabel('verify:architecture#15', 18)).toBe('verify:archite…#15'); - // 多位 #idx 也能区分 + // multi-digit #idx also distinguishable expect(truncateLabel('verify:correctness#2', 18)).toBe('verify:correctn…#2'); - // 无 #数字 后缀:从右切(旧行为) + // without #number suffix: cut from right (legacy behavior) expect(truncateLabel('a-very-long-label-no-suffix', 18)).toBe('a-very-long-label-'); }); -// STATUS_DOT 覆盖四种状态,且均为可见圆点字符。 -test('STATUS_DOT 覆盖 running/completed/failed/killed 且为非空字符', () => { +// STATUS_DOT covers four states, all visible dot characters. +test('STATUS_DOT covers running/completed/failed/killed and is non-empty character', () => { const statuses = ['running', 'completed', 'failed', 'killed'] as const; for (const s of statuses) { expect(STATUS_DOT[s]).toBeTruthy(); @@ -47,9 +47,9 @@ test('STATUS_DOT 覆盖 running/completed/failed/killed 且为非空字符', () } }); -// 进度数据形态契约:面板读取的字段在典型 RunProgress 上存在/可读, -// 防止 store.ts 结构漂移悄悄破坏面板渲染。 -test('RunProgress 字段契约:面板读取的 key 均存在', () => { +// Progress data shape contract: fields read by the panel exist/are readable on a typical RunProgress, +// preventing silent panel render breakage from store.ts structural drift. +test('RunProgress field contract: keys read by panel all exist', () => { const run: RunProgress = { runId: 'r1', workflowName: 'review', @@ -62,7 +62,7 @@ test('RunProgress 字段契约:面板读取的 key 均存在', () => { startedAt: 1, updatedAt: 1, }; - // 面板 WorkflowList/Detail 读取的路径 + // paths read by panel WorkflowList/Detail expect(run.status).toBe('running'); expect(STATUS_DOT[run.status]).toBe('●'); expect(run.currentPhase).toBe('Review'); @@ -72,8 +72,8 @@ test('RunProgress 字段契约:面板读取的 key 均存在', () => { expect(run.agents[0]?.label).toBe('review:api'); }); -// 完成/失败形态:returnValue / error 在非 running 时才显示。 -test('RunProgress 完成/失败形态:returnValue/error 可选', () => { +// Completed/failed shape: returnValue / error only shown when not running. +test('RunProgress completed/failed shape: returnValue/error optional', () => { const completed: RunProgress = { runId: 'r2', workflowName: 'w', @@ -108,9 +108,9 @@ test('RunProgress 完成/失败形态:returnValue/error 可选', () => { expect(STATUS_DOT['failed']).toBe('✗'); }); -// 修复 M:useSyncExternalStore / listNamed / 子组件抛错时不应击穿 REPL。 -// panelCall 必须把 WorkflowsPanel 包在 SentryErrorBoundary 里。 -test('panelCall 用 SentryErrorBoundary 包裹 WorkflowsPanel(修复 M 回归)', async () => { +// Fix M: useSyncExternalStore / listNamed / child component throwing should not break through REPL. +// panelCall must wrap WorkflowsPanel in SentryErrorBoundary. +test('panelCall wraps WorkflowsPanel in SentryErrorBoundary (fix M regression)', async () => { const element = (await (panelCall as unknown as (a: unknown, b: unknown, c: unknown) => Promise)( () => {}, { canUseTool: undefined }, @@ -126,12 +126,12 @@ test('panelCall 用 SentryErrorBoundary 包裹 WorkflowsPanel(修复 M 回归 expect(typeof child.props.onDone).toBe('function'); }); -// ---- Task 6: 面板 mount 触发一次 loadPersistedRuns ---- -// 验证 WorkflowsPanel mount 时调 svc.loadPersistedRuns() 恰好一次。 -// service 内部 persistedLoaded flag 守护幂等;重渲染/重 mount 不重复调用。 -// 用 spy 替换单例的 loadPersistedRuns,渲染到 PassThrough 流,等 useEffect 触发。 +// ---- Task 6: panel mount triggers loadPersistedRuns once ---- +// Verify that WorkflowsPanel mount calls svc.loadPersistedRuns() exactly once. +// The persistedLoaded flag inside service guards idempotency; re-render / re-mount does not repeat the call. +// Use a spy to replace the singleton's loadPersistedRuns, render to a PassThrough stream, wait for useEffect to trigger. -test('WorkflowsPanel mount 触发一次 loadPersistedRuns', async () => { +test('WorkflowsPanel mount triggers loadPersistedRuns once', async () => { __resetWorkflowServiceForTests(); const svc = getWorkflowService(); let calls = 0; @@ -141,7 +141,7 @@ test('WorkflowsPanel mount 触发一次 loadPersistedRuns', async () => { }; const stdout = new PassThrough(); - // 消费 data 避免 buffer 撑爆(render 会写多帧) + // consume data to avoid buffer overflow (render writes multiple frames) stdout.on('data', () => {}); let instance: { unmount: () => void; waitUntilExit: () => Promise } | undefined; try { @@ -152,7 +152,7 @@ test('WorkflowsPanel mount 触发一次 loadPersistedRuns', async () => { }), { stdout: stdout as unknown as NodeJS.WriteStream, patchConsole: false }, ); - // mount 后 useEffect 异步触发;等 tick 让 React commit + effect 跑完 + // after mount useEffect triggers asynchronously; wait a tick for React commit + effect to complete await new Promise(r => setTimeout(r, 30)); expect(calls).toBe(1); @@ -163,35 +163,35 @@ test('WorkflowsPanel mount 触发一次 loadPersistedRuns', async () => { } }); -// focused run 从 running 转 terminal 时面板自动 onDone()(800ms 延迟让用户看到终态)。 -// 仅同 runId 的状态转换触发:切到已完成 tab 不退出;打开历史面板也不退出。 -// 转换判定逻辑抽成 isRunTerminatedTransition 纯函数,便于离线单测(Ink test 模式不 -// 自动 pump concurrent 状态更新,集成测试不可靠)。 -test('isRunTerminatedTransition:同 runId running → terminal 触发;其它情况不触发', () => { +// When the focused run transitions from running to terminal, the panel auto onDone() (800ms delay lets the user see the terminal state). +// Only same-runId state transitions trigger: switching to a completed tab does not exit; opening history panel does not exit either. +// Transition detection logic is extracted into the isRunTerminatedTransition pure function for offline unit testing (Ink test mode does not +// auto-pump concurrent state updates, integration tests are unreliable). +test('isRunTerminatedTransition: same runId running → terminal triggers; other cases do not trigger', () => { const running = { runId: 'r1', status: 'running' as const }; const completed = { runId: 'r1', status: 'completed' as const }; const failed = { runId: 'r1', status: 'failed' as const }; const killed = { runId: 'r1', status: 'killed' as const }; - // 同 run running → terminal:三种 terminal 都触发 + // same run running → terminal: all three terminal states trigger expect(isRunTerminatedTransition(running, completed)).toBe(true); expect(isRunTerminatedTransition(running, failed)).toBe(true); expect(isRunTerminatedTransition(running, killed)).toBe(true); - // prev=null(打开历史面板):不触发 + // prev=null (open history panel): does not trigger expect(isRunTerminatedTransition(null, completed)).toBe(false); - // curr=null(runs 清空):不触发 + // curr=null (runs cleared): does not trigger expect(isRunTerminatedTransition(running, null)).toBe(false); - // 不同 runId(切 tab):不触发 + // different runId (switch tab): does not trigger expect(isRunTerminatedTransition({ runId: 'r1', status: 'running' }, { runId: 'r2', status: 'completed' })).toBe( false, ); - // 同 run 但 prev 非 running(已是 terminal 又重渲染):不触发 + // same run but prev not running (already terminal and re-rendered): does not trigger expect(isRunTerminatedTransition(completed, completed)).toBe(false); expect(isRunTerminatedTransition(killed, completed)).toBe(false); - // 同 run running → running(无变化):不触发 + // same run running → running (no change): does not trigger expect(isRunTerminatedTransition(running, running)).toBe(false); }); diff --git a/src/workflow/__tests__/claudeCodeBackend.test.ts b/src/workflow/__tests__/claudeCodeBackend.test.ts index 6f19c7399..6cb77df25 100644 --- a/src/workflow/__tests__/claudeCodeBackend.test.ts +++ b/src/workflow/__tests__/claudeCodeBackend.test.ts @@ -1,8 +1,8 @@ import { expect, test, mock } from 'bun:test' -// 注意:mock specifier 必须解析到 impl 实际 import 的同一模块(bun mock.module -// 按解析后模块匹配)。impl 用 '@claude-code-best/builtin-tools/...' 与 'src/*' 别名 -// 路径导入,此处用相同 specifier。 +// Note: mock specifier must resolve to the same module that impl actually imports (bun mock.module +// matches by resolved module). impl uses '@claude-code-best/builtin-tools/...' and 'src/*' alias +// path imports, so the same specifier is used here. mock.module( '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js', () => ({ @@ -43,10 +43,10 @@ mock.module('src/utils/uuid.js', () => ({ createAgentId: () => 'agent-1' })) mock.module('src/services/analytics/index.js', () => ({ logEvent: () => {} })) mock.module('src/utils/debug.js', () => ({ logForDebugging: () => {} })) -// isolation:'worktree' 测试用:mock worktree 三件套(避免真跑 git worktree add)。 -// 注意 mock.module 是 process-global;worktreeState 在工厂外定义供测试重置。 -// 不 mock cwd.js:runWithCwdOverride 真跑 AsyncLocalStorage 对 mock runAgent 无害, -// 且避免污染同进程其他依赖 pwd/getCwd 的测试。 +// isolation:'worktree' tests: mock worktree trio (to avoid actually running git worktree add). +// Note mock.module is process-global; worktreeState is defined outside the factory for test reset. +// Do not mock cwd.js: runWithCwdOverride actually running AsyncLocalStorage is harmless to mocked runAgent, +// and avoids polluting other tests in the same process that depend on pwd/getCwd. const worktreeState = { shouldThrow: false, hasChanges: false, @@ -104,7 +104,7 @@ function ctx() { }), } as never, canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never, - // run() 不读 parentMessage;用空对象占位满足 WorkflowHostBundle 类型。 + // run() does not read parentMessage; use an empty object placeholder to satisfy the WorkflowHostBundle type. parentMessage: {} as never, }), signal: new AbortController().signal, @@ -113,20 +113,20 @@ function ctx() { } } -test('文本 agent → ok + token/tool/model 计量', async () => { +test('text agent → ok + token/tool/model accounting', async () => { const res = await claudeCodeBackend.run({ prompt: 'do it' }, ctx()) expect(res.kind).toBe('ok') if (res.kind === 'ok') { expect(res.output).toBe('agent-text') expect(res.usage.outputTokens).toBe(42) - // 面板展示字段:tokenCount(=totalTokens) / toolCount / model(fallback mainLoopModel 'm') + // panel display fields: tokenCount(=totalTokens) / toolCount / model (fallback mainLoopModel 'm') expect(res.tokenCount).toBe(42) expect(res.toolCount).toBe(3) expect(res.model).toBe('m') } }) -test('isolation:worktree → 创建 worktree + 无变更自动清理;slug 匹配清理正则', async () => { +test('isolation:worktree → create worktree + auto-cleanup on no changes; slug matches cleanup regex', async () => { worktreeState.shouldThrow = false worktreeState.hasChanges = false worktreeState.created = [] @@ -138,13 +138,13 @@ test('isolation:worktree → 创建 worktree + 无变更自动清理;slug 匹 ) expect(res.kind).toBe('ok') expect(worktreeState.created).toHaveLength(1) - // slug 必须匹配 cleanupStaleAgentWorktrees 的清理正则 ^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$ + // slug must match cleanupStaleAgentWorktrees cleanup regex ^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$ expect(worktreeState.created[0]).toMatch(/^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$/) expect(worktreeState.changesCalls).toBe(1) - expect(worktreeState.removed).toHaveLength(1) // 无变更 → auto-remove + expect(worktreeState.removed).toHaveLength(1) // no changes → auto-remove }) -test('isolation:worktree 有变更 → 保留 worktree(不 remove)', async () => { +test('isolation:worktree has changes → keep worktree (no remove)', async () => { worktreeState.hasChanges = true worktreeState.created = [] worktreeState.removed = [] @@ -154,11 +154,11 @@ test('isolation:worktree 有变更 → 保留 worktree(不 remove)', async ( ctx(), ) expect(res.kind).toBe('ok') - expect(worktreeState.removed).toHaveLength(0) // 有变更 → 保留 + expect(worktreeState.removed).toHaveLength(0) // has changes → keep expect(worktreeState.changesCalls).toBe(1) }) -test('isolation:worktree 创建失败 → fail-closed 返 dead(不静默退化共享 cwd)', async () => { +test('isolation:worktree creation fails → fail-closed returns dead (does not silently degrade to shared cwd)', async () => { worktreeState.shouldThrow = true const res = await claudeCodeBackend.run( { prompt: 'do', isolation: 'worktree' }, @@ -168,19 +168,19 @@ test('isolation:worktree 创建失败 → fail-closed 返 dead(不静默退化 worktreeState.shouldThrow = false }) -test('无 isolation → 不创建 worktree', async () => { +test('no isolation → no worktree created', async () => { worktreeState.created = [] const res = await claudeCodeBackend.run({ prompt: 'do' }, ctx()) expect(res.kind).toBe('ok') expect(worktreeState.created).toHaveLength(0) }) -test('runAgent 抛错 → dead', async () => { - // 覆盖 mock 让 runAgent 抛(last-write-wins) +test('runAgent throws → dead', async () => { + // override mock so runAgent throws (last-write-wins) mock.module( '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js', () => ({ - // biome-ignore lint/correctness/useYield: 故意抛错以测试 dead 分支(不 yield) + // biome-ignore lint/correctness/useYield: intentionally throws to test dead branch (no yield) runAgent: async function* () { throw new Error('boom') }, @@ -190,12 +190,12 @@ test('runAgent 抛错 → dead', async () => { expect(res.kind).toBe('dead') }) -// 下面三组测试覆盖 'x' 无效修复:backend 必须把 ctx.signal 桥接到 runAgent.override -// .abortController,并把 AbortError 识别为 abort(throw WorkflowAbortedError,而非吞成 dead)。 -// 还要验证 registerAgentAbort 注入,让 service.kill(runId, agentId) 能精确中断单个 agent。 +// The next three groups of tests cover the 'x' invalid fix: backend must bridge ctx.signal to runAgent.override +// .abortController, and recognize AbortError as abort (throw WorkflowAbortedError, not swallow as dead). +// Also verify registerAgentAbort injection so service.kill(runId, agentId) can precisely abort a single agent. -test('ctx.signal 预 abort → backend 桥接:override.abortController.signal.aborted=true', async () => { - // 用 capturedOverride 暴露 backend 创建的 agentAbort(mock 收到的 override.abortController) +test('ctx.signal pre-abort → backend bridge: override.abortController.signal.aborted=true', async () => { + // use capturedOverride to expose the agentAbort created by backend (the override.abortController received by mock) let capturedController: AbortController | undefined mock.module( '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js', @@ -213,8 +213,8 @@ test('ctx.signal 预 abort → backend 桥接:override.abortController.signal. ) const parentAbort = new AbortController() parentAbort.abort() - // mock 不抛 → backend 走正常返回路径;但桥接 `if (ctx.signal.aborted) agentAbort.abort()` - // 已同步触发,capturedController.signal.aborted 必为 true(kill 桥接根因) + // mock does not throw → backend takes the normal return path; but the bridge `if (ctx.signal.aborted) agentAbort.abort()` + // has already triggered synchronously, capturedController.signal.aborted must be true (root cause of kill bridge) await claudeCodeBackend.run( { prompt: 'pre-aborted' }, { ...ctx(), signal: parentAbort.signal }, @@ -222,11 +222,11 @@ test('ctx.signal 预 abort → backend 桥接:override.abortController.signal. expect(capturedController?.signal.aborted).toBe(true) }) -test('runAgent 抛 AbortError → backend throw WorkflowAbortedError(不吞成 dead)', async () => { +test('runAgent throws AbortError → backend throws WorkflowAbortedError (not swallowed as dead)', async () => { mock.module( '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js', () => ({ - // biome-ignore lint/correctness/useYield: 故意抛 AbortError 测识别分支 + // biome-ignore lint/correctness/useYield: intentionally throws AbortError to test recognition branch runAgent: async function* () { const e = new Error('aborted by parent') e.name = 'AbortError' @@ -239,8 +239,8 @@ test('runAgent 抛 AbortError → backend throw WorkflowAbortedError(不吞成 ).rejects.toBeInstanceOf(WorkflowAbortedError) }) -test('registerAgentAbort/unregisterAgentAbort 注入:key=ctx.agentId(数字),controller 来自桥接', async () => { - // 恢复默认 mock(上一个测试把它改成抛 AbortError 了) +test('registerAgentAbort/unregisterAgentAbort injection: key=ctx.agentId (number), controller from bridge', async () => { + // restore default mock (previous test changed it to throw AbortError) mock.module( '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js', () => ({ @@ -264,40 +264,40 @@ test('registerAgentAbort/unregisterAgentAbort 注入:key=ctx.agentId(数字 }, ) expect(registered).toHaveLength(1) - expect(registered[0]?.id).toBe(42) // 引擎数字 agentId(非 coreAgentId 字符串) + expect(registered[0]?.id).toBe(42) // engine numeric agentId (not coreAgentId string) expect(registered[0]?.controller).toBeInstanceOf(AbortController) - expect(unregistered).toEqual([42]) // finally 清理幂等 + expect(unregistered).toEqual([42]) // finally cleanup idempotent }) -test('id 与 capabilities 形状', () => { +test('id and capabilities shape', () => { expect(claudeCodeBackend.id).toBe('claude-code') expect(claudeCodeBackend.capabilities.structuredOutput).toBe(true) expect(claudeCodeBackend.capabilities.tools).toBe(true) }) -test('resolveAgentDefinition:无 agentType → WORKFLOW_AGENT 兜底', () => { +test('resolveAgentDefinition: no agentType → WORKFLOW_AGENT fallback', () => { const tuc = { options: { agentDefinitions: { activeAgents: [] } }, } as never expect(resolveAgentDefinition(undefined, tuc)).toBe(WORKFLOW_AGENT) }) -test('resolveAgentDefinition:命中 activeAgents', () => { +test('resolveAgentDefinition: hits activeAgents', () => { const fake = { agentType: 'Explore', permissionMode: 'plan' } as never const tuc = { options: { agentDefinitions: { activeAgents: [fake] } }, } as never expect(resolveAgentDefinition('Explore', tuc)).toBe(fake) - // 未命中仍兜底 + // miss still falls back expect(resolveAgentDefinition('Nope', tuc)).toBe(WORKFLOW_AGENT) }) -test('mapWorkflowModel 直传', () => { +test('mapWorkflowModel passthrough', () => { expect(mapWorkflowModel(undefined)).toBeUndefined() expect(mapWorkflowModel('claude-haiku-*')).toBe('claude-haiku-*') }) -test('extractStructuredOutput:合法 JSON 提取;非法返回 null', () => { +test('extractStructuredOutput: valid JSON extracted; invalid returns null', () => { expect( extractStructuredOutput([ { type: 'text', text: 'prefix {"a":1,"b":2} suffix' }, @@ -309,7 +309,7 @@ test('extractStructuredOutput:合法 JSON 提取;非法返回 null', () => { expect(extractStructuredOutput([])).toBeNull() }) -test('extractStructuredOutput:fenced code block(剥围栏 + 剥语言标签)', () => { +test('extractStructuredOutput: fenced code block (strip fence + strip language tag)', () => { expect( extractStructuredOutput([ { @@ -318,13 +318,13 @@ test('extractStructuredOutput:fenced code block(剥围栏 + 剥语言标签 }, ]), ).toEqual({ findings: [{ title: 'x' }] }) - // 无语言标签 + // no language tag expect( extractStructuredOutput([{ type: 'text', text: '```\n{"a":1}\n```' }]), ).toEqual({ a: 1 }) }) -test('extractStructuredOutput:嵌套对象(括号平衡扫描,原版 indexOf/lastIndexOf 会跨块拼接)', () => { +test('extractStructuredOutput: nested object (bracket-balanced scan; legacy indexOf/lastIndexOf would cross-block concat)', () => { const text = 'Result: {"outer":{"inner":{"deep":true}},"n":3} trailing' expect(extractStructuredOutput([{ type: 'text', text }])).toEqual({ outer: { inner: { deep: true } }, @@ -332,8 +332,8 @@ test('extractStructuredOutput:嵌套对象(括号平衡扫描,原版 index }) }) -test('extractStructuredOutput:字符串里的括号不当配对计', () => { - // 字符串内的 } 不会让 depth 归零,扫描能跳到真正的配对 } +test('extractStructuredOutput: brackets inside strings are not counted as pairing', () => { + // } inside a string does not zero out depth, scan can skip to the real pairing } const text = '{"note":"this } char is in a string","ok":true}' expect(extractStructuredOutput([{ type: 'text', text }])).toEqual({ note: 'this } char is in a string', @@ -341,7 +341,7 @@ test('extractStructuredOutput:字符串里的括号不当配对计', () => { }) }) -test('extractStructuredOutput:转义引号不破字符串边界', () => { +test('extractStructuredOutput: escaped quotes do not break string boundary', () => { const text = '{"escaped":"he said \\"hi\\"","n":1}' expect(extractStructuredOutput([{ type: 'text', text }])).toEqual({ escaped: 'he said "hi"', @@ -349,13 +349,13 @@ test('extractStructuredOutput:转义引号不破字符串边界', () => { }) }) -test('extractStructuredOutput:多个 JSON 块 → 返回第一个 parse 成功的', () => { - // 第一个不平衡(无配对 }),跳到第二个 +test('extractStructuredOutput: multiple JSON blocks → return first parse success', () => { + // first one unbalanced (no pairing }), skip to the second const text = 'broken { stuff\n{"real":1}\n{"ignored":2}' expect(extractStructuredOutput([{ type: 'text', text }])).toEqual({ real: 1 }) }) -test('extractStructuredOutput:array / number / string / null 不算 object', () => { +test('extractStructuredOutput: array / number / string / null do not count as object', () => { expect( extractStructuredOutput([{ type: 'text', text: '[1,2,3]' }]), ).toBeNull() @@ -366,7 +366,7 @@ test('extractStructuredOutput:array / number / string / null 不算 object', ( expect(extractStructuredOutput([{ type: 'text', text: 'null' }])).toBeNull() }) -test('extractStructuredOutput:多 text block → 跨块找第一个成功', () => { +test('extractStructuredOutput: multiple text blocks → cross-block find first success', () => { expect( extractStructuredOutput([ { type: 'text', text: 'no json' }, @@ -375,13 +375,13 @@ test('extractStructuredOutput:多 text block → 跨块找第一个成功', () ).toEqual({ k: 'v' }) }) -test('extractStructuredOutput:损坏 JSON 返回 null(不抛)', () => { +test('extractStructuredOutput: broken JSON returns null (does not throw)', () => { expect( extractStructuredOutput([ { type: 'text', text: '{broken: missing quotes}' }, ]), ).toBeNull() expect( - extractStructuredOutput([{ type: 'text', text: '{"a":1,}' }]), // 尾逗号——不做语法修复 + extractStructuredOutput([{ type: 'text', text: '{"a":1,}' }]), // trailing comma — no syntax repair ).toBeNull() }) diff --git a/src/workflow/__tests__/notifications.test.ts b/src/workflow/__tests__/notifications.test.ts index c00afbf98..c16f27529 100644 --- a/src/workflow/__tests__/notifications.test.ts +++ b/src/workflow/__tests__/notifications.test.ts @@ -54,7 +54,7 @@ function makeRun( } describe('installWorkflowNotifications', () => { - test('running → completed 触发通知(含 workflow 名)', async () => { + test('running → completed triggers notification (incl. workflow name)', async () => { const { installWorkflowNotifications } = await import('../notifications.js') const { service, emit, setRuns } = makeMockService([ makeRun('r1', 'running'), @@ -64,7 +64,7 @@ describe('installWorkflowNotifications', () => { calls.push(msg), ) - // 第一次 emit:listener 记录初始 running 状态,不发通知 + // first emit: listener records initial running state, no notification emit() expect(calls.length).toBe(0) @@ -78,7 +78,7 @@ describe('installWorkflowNotifications', () => { unsubscribe() }) - test('running → failed 触发通知,含 error 文字', async () => { + test('running → failed triggers notification, includes error text', async () => { const { installWorkflowNotifications } = await import('../notifications.js') const { service, emit, setRuns } = makeMockService([ makeRun('r1', 'running'), @@ -86,7 +86,7 @@ describe('installWorkflowNotifications', () => { const calls: string[] = [] installWorkflowNotifications(service, msg => calls.push(msg)) - emit() // 记录初始 running + emit() // record initial running setRuns([makeRun('r1', 'failed', { error: 'agent X boom' })]) emit() @@ -95,7 +95,7 @@ describe('installWorkflowNotifications', () => { expect(calls[0]).toMatch(/agent X boom/) }) - test('running → killed 触发通知', async () => { + test('running → killed triggers notification', async () => { const { installWorkflowNotifications } = await import('../notifications.js') const { service, emit, setRuns } = makeMockService([ makeRun('r1', 'running'), @@ -103,7 +103,7 @@ describe('installWorkflowNotifications', () => { const calls: string[] = [] installWorkflowNotifications(service, msg => calls.push(msg)) - emit() // 记录初始 running + emit() // record initial running setRuns([makeRun('r1', 'killed')]) emit() @@ -111,20 +111,20 @@ describe('installWorkflowNotifications', () => { expect(calls[0]).toMatch(/was stopped/) }) - test('初次见到 run(无 prev)不发通知(避免启动时通知历史 run)', async () => { + test('first time seeing run (no prev) does not notify (avoid notifying historical runs on startup)', async () => { const { installWorkflowNotifications } = await import('../notifications.js') const { service, emit, setRuns } = makeMockService([]) const calls: string[] = [] installWorkflowNotifications(service, msg => calls.push(msg)) - // 启动后第一次 emit,看到 r1 已 completed——不应通知(不是从 running 转换来) + // first emit after startup, sees r1 already completed — should not notify (not a transition from running) setRuns([makeRun('r1', 'completed')]) emit() expect(calls.length).toBe(0) }) - test('running → running 不发通知', async () => { + test('running → running does not notify', async () => { const { installWorkflowNotifications } = await import('../notifications.js') const { service, emit, setRuns } = makeMockService([ makeRun('r1', 'running'), @@ -132,14 +132,14 @@ describe('installWorkflowNotifications', () => { const calls: string[] = [] installWorkflowNotifications(service, msg => calls.push(msg)) - emit() // 记录初始 running + emit() // record initial running setRuns([makeRun('r1', 'running', { agentCount: 1 })]) emit() expect(calls.length).toBe(0) }) - test('已 completed 的 run 再次 emit 不重复通知', async () => { + test('already completed run emitting again does not repeat notification', async () => { const { installWorkflowNotifications } = await import('../notifications.js') const { service, emit, setRuns } = makeMockService([ makeRun('r1', 'running'), @@ -147,7 +147,7 @@ describe('installWorkflowNotifications', () => { const calls: string[] = [] installWorkflowNotifications(service, msg => calls.push(msg)) - emit() // 记录初始 running + emit() // record initial running setRuns([makeRun('r1', 'completed')]) emit() expect(calls.length).toBe(1) @@ -156,7 +156,7 @@ describe('installWorkflowNotifications', () => { expect(calls.length).toBe(1) }) - test('unsubscribe 后不再发通知', async () => { + test('after unsubscribe no more notifications', async () => { const { installWorkflowNotifications } = await import('../notifications.js') const { service, emit, setRuns } = makeMockService([ makeRun('r1', 'running'), @@ -166,7 +166,7 @@ describe('installWorkflowNotifications', () => { calls.push(msg), ) - emit() // 记录初始 running + emit() // record initial running unsubscribe() setRuns([makeRun('r1', 'completed')]) emit() diff --git a/src/workflow/__tests__/persistence.test.ts b/src/workflow/__tests__/persistence.test.ts index d976370dc..ea42740c5 100644 --- a/src/workflow/__tests__/persistence.test.ts +++ b/src/workflow/__tests__/persistence.test.ts @@ -33,7 +33,7 @@ function makeRun(over: Partial = {}): RunProgress { } as RunProgress } -test('writeRunState → readRunState 往返一致(returnValue 为对象)', async () => { +test('writeRunState → readRunState round-trip consistent (returnValue is object)', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-')) try { const run = makeRun({ @@ -49,7 +49,7 @@ test('writeRunState → readRunState 往返一致(returnValue 为对象)', a } }) -test('readRunState 缺文件 → null', async () => { +test('readRunState missing file → null', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-')) try { const got = await readRunState(dir, 'never-exists') @@ -59,7 +59,7 @@ test('readRunState 缺文件 → null', async () => { } }) -test('readRunState 损坏 JSON → null', async () => { +test('readRunState corrupt JSON → null', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-')) try { await mkdir(join(dir, 'rX'), { recursive: true }) @@ -71,7 +71,7 @@ test('readRunState 损坏 JSON → null', async () => { } }) -test('readRunState schemaVersion 不符 → null', async () => { +test('readRunState schemaVersion mismatch → null', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-')) try { await mkdir(join(dir, 'rX'), { recursive: true }) @@ -87,7 +87,7 @@ test('readRunState schemaVersion 不符 → null', async () => { } }) -test('writeRunState 原子写:成功后无 tmp 残留', async () => { +test('writeRunState atomic write: no tmp residue after success', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-')) try { await writeRunState(dir, makeRun({ runId: 'rAtom' })) @@ -99,10 +99,10 @@ test('writeRunState 原子写:成功后无 tmp 残留', async () => { } }) -test('listPersistedRuns 扫多子目录、跳过无 state.json 的目录、按 updatedAt 降序', async () => { +test('listPersistedRuns scans multiple subdirs, skips dirs without state.json, sorts by updatedAt desc', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-')) try { - // 三个有效 run + 一个只有 journal 没 state.json 的半残目录 + // three valid runs + one half-broken dir with only journal, no state.json await writeRunState(dir, makeRun({ runId: 'old', updatedAt: 1000 })) await writeRunState(dir, makeRun({ runId: 'mid', updatedAt: 2000 })) await writeRunState(dir, makeRun({ runId: 'new', updatedAt: 3000 })) @@ -115,7 +115,7 @@ test('listPersistedRuns 扫多子目录、跳过无 state.json 的目录、按 u } }) -test('listPersistedRuns 扫到损坏 state.json → 跳过该单个,继续扫其余', async () => { +test('listPersistedRuns scans a corrupt state.json → skip that single one, continue scanning the rest', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-')) try { await writeRunState(dir, makeRun({ runId: 'good' })) @@ -129,7 +129,7 @@ test('listPersistedRuns 扫到损坏 state.json → 跳过该单个,继续扫 } }) -test('writeRunState 不抛 returnValue 为 null/字符串/数组', async () => { +test('writeRunState does not throw when returnValue is null/string/array', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-')) try { await writeRunState(dir, makeRun({ runId: 'n', returnValue: null })) @@ -143,7 +143,7 @@ test('writeRunState 不抛 returnValue 为 null/字符串/数组', async () => { } }) -test('writeRunState 覆盖写:同 runId 二次写覆盖旧内容', async () => { +test('writeRunState overwrite: same runId second write overwrites old content', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-')) try { await writeRunState(dir, makeRun({ runId: 'rOV', status: 'running' })) @@ -155,7 +155,7 @@ test('writeRunState 覆盖写:同 runId 二次写覆盖旧内容', async () => } }) -test('writeRunState 写入完整 AgentProgress(不含 output 内容,含 label/phase/token 等)', async () => { +test('writeRunState writes full AgentProgress (no output content, includes label/phase/token etc.)', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-')) try { const run = makeRun({ @@ -192,8 +192,8 @@ test('writeRunState 写入完整 AgentProgress(不含 output 内容,含 labe } }) -test('getRunsDir 返回 /.claude/workflow-runs 形态', () => { +test('getRunsDir returns /.claude/workflow-runs shape', () => { const dir = getRunsDir() - // 不 hard-code projectRoot(跨机器不同),只校验后缀结构 + // do not hard-code projectRoot (differs across machines), only check suffix structure expect(dir.endsWith(`${join('.claude', 'workflow-runs')}`)).toBe(true) }) diff --git a/src/workflow/__tests__/ports.test.ts b/src/workflow/__tests__/ports.test.ts index 630f3177f..8a1189fcb 100644 --- a/src/workflow/__tests__/ports.test.ts +++ b/src/workflow/__tests__/ports.test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'bun:test' -// 注意:本测试不 mock bootstrap/state、utils/cwd、analytics、debug。 -// 原因:mock.module 是进程全局的(last-write-wins),mock 这些公共模块会污染 -// 同进程其他测试(如 src/commands/__tests__/autonomy.test.ts 经其依赖链 import -// 真实 bootstrap/state)。ports 在测试环境下能正常解析 getProjectRoot/getCwd, -// logEvent/logForDebugging 在 sink 未 attach 时为静默 no-op,无需 mock。 +// Note: this test does not mock bootstrap/state, utils/cwd, analytics, debug. +// Reason: mock.module is process-global (last-write-wins); mocking these common modules would pollute +// other tests in the same process (e.g. src/commands/__tests__/autonomy.test.ts imports the real +// bootstrap/state via its dependency chain). ports can resolve getProjectRoot/getCwd normally in the test env, +// logEvent/logForDebugging are silent no-ops when sink is not attached, no need to mock. import { buildRegistry } from '../registry.js' import { createWorkflowPorts } from '../ports.js' @@ -13,7 +13,7 @@ import { getProjectRoot } from '../../bootstrap/state.js' import type { SetAppState } from '../../Task.js' import type { AppState } from '../../state/AppState.tsx' -test('buildRegistry 注册 claude-code 为默认且 resolve 命中', () => { +test('buildRegistry registers claude-code as default and resolve hits', () => { const reg = buildRegistry() expect(reg.has('claude-code')).toBe(true) expect(reg.resolve({ prompt: 'x' }).id).toBe('claude-code') @@ -22,7 +22,7 @@ test('buildRegistry 注册 claude-code 为默认且 resolve 命中', () => { ) }) -test('createWorkflowPorts 组装完整端口(含 agentAdapterRegistry 与 progressEmitter→bus)', () => { +test('createWorkflowPorts assembles full ports (incl. agentAdapterRegistry and progressEmitter→bus)', () => { const bus = createProgressBus() const store = createProgressStoreFromBus(bus) const ports = createWorkflowPorts({ bus, store }) @@ -34,11 +34,11 @@ test('createWorkflowPorts 组装完整端口(含 agentAdapterRegistry 与 prog expect(typeof ports.taskRegistrar.register).toBe('function') expect(typeof ports.taskRegistrar.kill).toBe('function') expect(typeof ports.hostFactory).toBe('function') - // agentRunner 兜底字段仍存在(WorkflowPorts 必填) + // agentRunner fallback fields still exist (WorkflowPorts required) expect(ports.agentRunner).toBeDefined() expect(typeof ports.agentRunner.runAgentToResult).toBe('function') - // progressEmitter 经 bus → store:发一个 run_started,store 能看到 + // progressEmitter via bus → store: emit a run_started, store can see it ports.progressEmitter.emit({ type: 'run_started', runId: 't', @@ -48,12 +48,12 @@ test('createWorkflowPorts 组装完整端口(含 agentAdapterRegistry 与 prog expect(store.get('t')?.workflowName).toBe('w') }) -test('taskRegistrar.register/complete/kill 经 RunBinding 路由(真 setAppState,无 mock)', () => { +test('taskRegistrar.register/complete/kill routes via RunBinding (real setAppState, no mock)', () => { const bus = createProgressBus() const store = createProgressStoreFromBus(bus) const ports = createWorkflowPorts({ bus, store }) - // 真 setAppState:用一个本地 AppState 对象承载 tasks,registerTask 走真实代码路径。 + // real setAppState: use a local AppState object to hold tasks, registerTask goes through the real code path. const state = { tasks: {} } as unknown as AppState const setAppState: SetAppState = f => { Object.assign(state, f(state)) @@ -81,24 +81,24 @@ test('taskRegistrar.register/complete/kill 经 RunBinding 路由(真 setAppSta expect(typeof runId).toBe('string') expect(signal).toBeInstanceOf(AbortSignal) - // complete/fail/kill 不抛(RunBinding 命中) + // complete/fail/kill do not throw (RunBinding hit) expect(() => ports.taskRegistrar.complete(runId, 'done')).not.toThrow() expect(() => ports.taskRegistrar.kill(runId)).not.toThrow() - // 未知 runId 安全 no-op + // unknown runId safe no-op expect(() => ports.taskRegistrar.complete('nope')).not.toThrow() expect(ports.taskRegistrar.pendingAction('nope')).toBeNull() - // 终态后 binding 回收:再次 complete 同 runId 应安全 no-op(不抛错、不重复调用 workflow task fn) + // after terminal state binding is reclaimed: calling complete on the same runId again should be safe no-op (no throw, no repeated call to workflow task fn) ports.taskRegistrar.complete(runId) ports.taskRegistrar.kill(runId) }) -// agent 级 kill 桥接:register → killAgent 精确中断;kill(runId) 顺带 abort 所有 agent。 -test('taskRegistrar agentAbortControllers:register/killAgent 精确中断;kill(runId) 批量 abort', () => { +// agent-level kill bridge: register → killAgent precisely aborts; kill(runId) aborts all agents. +test('taskRegistrar agentAbortControllers: register/killAgent precise abort; kill(runId) batch abort', () => { const bus = createProgressBus() const store = createProgressStoreFromBus(bus) const ports = createWorkflowPorts({ bus, store }) - // 实现always provides these — cast 把 optional 拍平为 required(避免每行 ! 断言) + // impl always provides these — cast flattens optional to required (avoids per-line ! assertion) const tr = ports.taskRegistrar as Required const state = { tasks: {} } as unknown as AppState @@ -120,7 +120,7 @@ test('taskRegistrar agentAbortControllers:register/killAgent 精确中断;ki hostCtx.handle, ) - // 注册两个 agent 的 AbortController(模拟 backend 在启动 agent 时调用) + // register AbortController for two agents (simulating backend calling when launching agent) const ac1 = new AbortController() const ac2 = new AbortController() tr.registerAgentAbort(runId, 1, ac1) @@ -128,26 +128,26 @@ test('taskRegistrar agentAbortControllers:register/killAgent 精确中断;ki expect(ac1.signal.aborted).toBe(false) expect(ac2.signal.aborted).toBe(false) - // killAgent 精确中断 agent #1:仅 ac1 abort,ac2 不受影响 + // killAgent precisely aborts agent #1: only ac1 aborts, ac2 unaffected expect(tr.killAgent(runId, 1)).toBe(true) expect(ac1.signal.aborted).toBe(true) expect(ac2.signal.aborted).toBe(false) - // 重复 kill 同 agent:controller 已 delete,返回 false(幂等) + // repeat kill on same agent: controller already deleted, returns false (idempotent) expect(tr.killAgent(runId, 1)).toBe(false) - // 未知 agentId / 未知 runId 安全返回 false + // unknown agentId / unknown runId safe returns false expect(tr.killAgent(runId, 999)).toBe(false) expect(tr.killAgent('nope', 1)).toBe(false) - // kill(runId) 批量 abort 剩余 agent(ac2) + // kill(runId) batch aborts remaining agent (ac2) tr.kill(runId) expect(ac2.signal.aborted).toBe(true) - // run 终态后 binding 已回收:再 killAgent 返回 false + // after run terminal state binding is reclaimed: killAgent returns false expect(tr.killAgent(runId, 2)).toBe(false) }) -test('unregisterAgentAbort 从 Map 删除(backend finally 清理幂等)', () => { +test('unregisterAgentAbort deletes from Map (backend finally cleanup idempotent)', () => { const bus = createProgressBus() const store = createProgressStoreFromBus(bus) const ports = createWorkflowPorts({ bus, store }) @@ -173,19 +173,19 @@ test('unregisterAgentAbort 从 Map 删除(backend finally 清理幂等)', () ) const ac = new AbortController() tr.registerAgentAbort(runId, 5, ac) - // 注销后 killAgent 无目标,返 false(不抛) + // after unregister killAgent has no target, returns false (does not throw) tr.unregisterAgentAbort(runId, 5) expect(tr.killAgent(runId, 5)).toBe(false) - // 重复注销幂等(backend finally 不抛) + // repeat unregister idempotent (backend finally does not throw) expect(() => tr.unregisterAgentAbort(runId, 5)).not.toThrow() - // 未知 runId 安全 no-op + // unknown runId safe no-op expect(() => tr.unregisterAgentAbort('nope', 5)).not.toThrow() }) -test('hostFactory.cwd 与 journalStore 同根(getProjectRoot)—— 修复 K 回归', () => { - // 历史 bug:hostFactory.cwd 用 getCwd()、journalStore 用 getProjectRoot(), - // 用户进入 worktree/子目录时两者不同 → 命名 workflow 解析与 journal 落盘不同步。 - // 修复后两者都用 projectRoot,本测试 lock-in 该选择,防止回归。 +test('hostFactory.cwd and journalStore share root (getProjectRoot) — fix K regression', () => { + // historical bug: hostFactory.cwd used getCwd(), journalStore used getProjectRoot(), + // when user enters worktree/subdirectory the two differ → named workflow resolution and journal persist out of sync. + // After fix both use projectRoot, this test locks-in that choice, preventing regression. const bus = createProgressBus() const store = createProgressStoreFromBus(bus) const ports = createWorkflowPorts({ bus, store }) diff --git a/src/workflow/__tests__/progressBus.test.ts b/src/workflow/__tests__/progressBus.test.ts index 7109b025f..c354a96db 100644 --- a/src/workflow/__tests__/progressBus.test.ts +++ b/src/workflow/__tests__/progressBus.test.ts @@ -1,7 +1,7 @@ import { expect, test, mock } from 'bun:test' import { createProgressBus } from '../progress/bus.js' -test('emit 广播给所有订阅者', () => { +test('emit broadcasts to all subscribers', () => { const bus = createProgressBus() const a = mock(() => {}) const b = mock(() => {}) @@ -13,7 +13,7 @@ test('emit 广播给所有订阅者', () => { expect(b).toHaveBeenCalledWith(ev) }) -test('subscribe 返回取消订阅', () => { +test('subscribe returns unsubscribe', () => { const bus = createProgressBus() const fn = mock(() => {}) const unsub = bus.subscribe(fn) diff --git a/src/workflow/__tests__/progressStore.test.ts b/src/workflow/__tests__/progressStore.test.ts index 09c64c4d4..2a45fa8c7 100644 --- a/src/workflow/__tests__/progressStore.test.ts +++ b/src/workflow/__tests__/progressStore.test.ts @@ -17,7 +17,7 @@ function newStore() { return { bus, store: createProgressStoreFromBus(bus) } } -test('run_started 建条目;phase_started/done 更新 phases', () => { +test('run_started creates entry; phase_started/done updates phases', () => { const { bus, store } = newStore() bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) bus.emit({ type: 'phase_started', runId: 'r1', phase: 'A' }) @@ -31,7 +31,7 @@ test('run_started 建条目;phase_started/done 更新 phases', () => { expect(r.currentPhase).toBe('B') }) -test('并发 agent_done 按 agentId 精确关联(回归旧 LIFO 竞态)', () => { +test('concurrent agent_done correlates by agentId precisely (regression of old LIFO race)', () => { const { bus, store } = newStore() bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) bus.emit({ @@ -71,7 +71,7 @@ test('并发 agent_done 按 agentId 精确关联(回归旧 LIFO 竞态)', () expect(agents.find(x => x.id === 1)?.label).toBe('b') }) -test('journal 命中(仅 agent_done 无 started)按 id 补建 done 条目', () => { +test('journal hit (agent_done without started) backfills done entry by id', () => { const { bus, store } = newStore() bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) bus.emit({ @@ -86,7 +86,7 @@ test('journal 命中(仅 agent_done 无 started)按 id 补建 done 条目', expect(a.status).toBe('done') }) -test('run_done 终态 + list 排序 + subscribe 通知', () => { +test('run_done terminal state + list sort + subscribe notification', () => { const { bus, store } = newStore() let calls = 0 store.subscribe(() => calls++) @@ -104,7 +104,7 @@ test('run_done 终态 + list 排序 + subscribe 通知', () => { expect(calls).toBe(2) }) -test('run_done failed 终态记录 error', () => { +test('run_done failed terminal state records error', () => { const { bus, store } = newStore() bus.emit({ type: 'run_started', runId: 'r2', workflowName: 'w', meta: null }) bus.emit({ type: 'run_done', runId: 'r2', status: 'failed', error: 'boom' }) @@ -113,17 +113,17 @@ test('run_done failed 终态记录 error', () => { expect(r.error).toBe('boom') }) -test('log 事件不触发 notify', () => { +test('log event does not trigger notify', () => { const { bus, store } = newStore() let calls = 0 store.subscribe(() => calls++) bus.emit({ type: 'run_started', runId: 'r3', workflowName: 'w', meta: null }) const before = calls bus.emit({ type: 'log', runId: 'r3', message: 'hi' }) - expect(calls).toBe(before) // log 不应触发 notify + expect(calls).toBe(before) // log should not trigger notify }) -test('run_started 落地 declaredPhases(来自 meta.phases,顺序保留)', () => { +test('run_started persists declaredPhases (from meta.phases, order preserved)', () => { const { bus, store } = newStore() bus.emit({ type: 'run_started', @@ -138,13 +138,13 @@ test('run_started 落地 declaredPhases(来自 meta.phases,顺序保留)', expect(store.get('r1')!.declaredPhases).toEqual(['Find', 'Review', 'Verify']) }) -test('run_started meta 为 null → declaredPhases = []', () => { +test('run_started meta is null → declaredPhases = []', () => { const { bus, store } = newStore() bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) expect(store.get('r1')!.declaredPhases).toEqual([]) }) -test('agent_done 落地 outputShape(ok·object / ok·text / dead 无)', () => { +test('agent_done persists outputShape (ok·object / ok·text / dead none)', () => { const { bus, store } = newStore() bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) bus.emit({ type: 'agent_started', runId: 'r1', agentId: 0, phase: 'A' }) @@ -177,7 +177,7 @@ test('agent_done 落地 outputShape(ok·object / ok·text / dead 无)', () = expect(agents.find(a => a.id === 2)?.outputShape).toBeUndefined() }) -test('agent_progress 实时更新 token/tool(按 agentId 关联)', () => { +test('agent_progress real-time updates token/tool (correlated by agentId)', () => { const { bus, store } = newStore() bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) bus.emit({ @@ -209,7 +209,7 @@ test('agent_progress 实时更新 token/tool(按 agentId 关联)', () => { expect(a.toolCount).toBe(3) }) -test('agent_done 落地 model/tokenCount/toolCount(ok 变体)', () => { +test('agent_done persists model/tokenCount/toolCount (ok variant)', () => { const { bus, store } = newStore() bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) bus.emit({ type: 'agent_started', runId: 'r1', agentId: 0, phase: 'A' }) @@ -233,9 +233,9 @@ test('agent_done 落地 model/tokenCount/toolCount(ok 变体)', () => { expect(a.toolCount).toBe(1) }) -// ---- hydrate:从磁盘注入历史 run(跨重启恢复)---- +// ---- hydrate: inject historical run from disk (cross-restart recovery) ---- -test('hydrate 注入新 run → get 命中 + list 含该项 + 通知 listener', () => { +test('hydrate injects new run → get hits + list includes it + notifies listener', () => { const { store } = newStore() let notified = 0 store.subscribe(() => notified++) @@ -260,7 +260,7 @@ test('hydrate 注入新 run → get 命中 + list 含该项 + 通知 listener', expect(notified).toBeGreaterThan(0) }) -test('hydrate 已存在的 runId → 跳过(内存优先,不被磁盘覆盖)', () => { +test('hydrate existing runId → skip (memory first, not overwritten by disk)', () => { const { bus, store } = newStore() bus.emit({ type: 'run_started', diff --git a/src/workflow/__tests__/runStatePersistence.test.ts b/src/workflow/__tests__/runStatePersistence.test.ts index 3a6962992..6a27fc845 100644 --- a/src/workflow/__tests__/runStatePersistence.test.ts +++ b/src/workflow/__tests__/runStatePersistence.test.ts @@ -7,14 +7,14 @@ import { createProgressBus } from '../progress/bus.js' import { createProgressStoreFromBus } from '../progress/store.js' /** - * attachRunStatePersistence 的契约测试(调整后 Task 4): - * 直接测 bus + store 组合,不走 makeService(保持 makeService 签名 (ports, store, cwdOverride?) 不变)。 + * Contract test for attachRunStatePersistence (adjusted Task 4): + * directly test the bus + store combination, bypassing makeService (keeps makeService signature (ports, store, cwdOverride?) unchanged). * - * runsDir 通过 attachRunStatePersistence 的第三个参数 runsDirProvider 注入 tmpdir, - * 避免写真实项目目录(Bun ESM 模块命名空间只读,无法 monkey-patch getRunsDir)。 + * runsDir is injected as tmpdir via attachRunStatePersistence's third parameter runsDirProvider, + * to avoid writing to the real project directory (Bun ESM module namespace is read-only, cannot monkey-patch getRunsDir). */ -test('run_done completed → 写盘 state.json,returnValue 一致', async () => { +test('run_done completed → writes state.json to disk, returnValue consistent', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-persist-')) try { const bus = createProgressBus() @@ -34,7 +34,7 @@ test('run_done completed → 写盘 state.json,returnValue 一致', async () = returnValue: { ok: true, n: 3 }, }) - // writeRunState 是 async(订阅里 void writeRunState(...));让 microtask 跑完 + // writeRunState is async (void writeRunState(...) in the subscription); let the microtask complete await new Promise(r => setTimeout(r, 50)) const got = await readRunState(dir, 'rW') @@ -46,7 +46,7 @@ test('run_done completed → 写盘 state.json,returnValue 一致', async () = } }) -test('run_done failed → 写盘 status=failed + error 字段', async () => { +test('run_done failed → writes status=failed + error field to disk', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-persist-')) try { const bus = createProgressBus() @@ -76,7 +76,7 @@ test('run_done failed → 写盘 status=failed + error 字段', async () => { } }) -test('run_done killed → 写盘 status=killed', async () => { +test('run_done killed → writes status=killed to disk', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-persist-')) try { const bus = createProgressBus() @@ -99,23 +99,23 @@ test('run_done killed → 写盘 status=killed', async () => { } }) -test('writeRunState 内部 IO 异常被吞掉:attachRunStatePersistence 不传播,bus emit 不中断', async () => { +test('writeRunState internal IO exception is swallowed: attachRunStatePersistence does not propagate, bus emit does not break', async () => { const blockerDir = await mkdtemp(join(tmpdir(), 'wf-persist-')) - // 先创建一个同名文件,让子路径 mkdir 失败 → writeRunState 内部 catch 吞掉 + // first create a same-named file, so subdir mkdir fails → writeRunState internal catch swallows it await writeFile(join(blockerDir, 'not-a-dir.txt'), 'blocker', 'utf-8') try { const bus = createProgressBus() const store = createProgressStoreFromBus(bus) - // runsDir 指向一个父路径是文件的目录:mkdir recursive 失败 + // runsDir points to a dir whose parent path is a file: mkdir recursive fails attachRunStatePersistence(bus, store, () => join(blockerDir, 'not-a-dir.txt'), ) - // 额外的订阅者,验证它仍被通知(bus emit 不应因持久化 listener 内部异常中断) + // an extra subscriber to verify it still gets notified (bus emit should not break due to internal exception in persistence listener) let otherNotified = 0 bus.subscribe(() => otherNotified++) - // bus.emit 不应抛——writeRunState 内部吞异常 + // bus.emit should not throw — writeRunState swallows the exception internally expect(() => { bus.emit({ type: 'run_started', @@ -131,10 +131,10 @@ test('writeRunState 内部 IO 异常被吞掉:attachRunStatePersistence 不传 }) }).not.toThrow() - // 让 writeRunState 的 microtask 跑完(异常在内部被吞) + // let writeRunState's microtask complete (exception swallowed internally) await new Promise(r => setTimeout(r, 50)) - // store 这条订阅者仍正常工作(收到了 run_started + run_done 两次事件) + // this store subscriber still works normally (received both run_started + run_done events) expect(otherNotified).toBeGreaterThanOrEqual(2) expect(store.get('rErr')?.status).toBe('completed') } finally { @@ -142,14 +142,14 @@ test('writeRunState 内部 IO 异常被吞掉:attachRunStatePersistence 不传 } }) -test('attachRunStatePersistence 返回 unsubscribe;调用后不再写盘', async () => { +test('attachRunStatePersistence returns unsubscribe; after calling it no more disk writes', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-persist-')) try { const bus = createProgressBus() const store = createProgressStoreFromBus(bus) const unsub = attachRunStatePersistence(bus, store, () => dir) - // 先发一个 run_done,验证写盘生效 + // first emit a run_done, verify disk write takes effect bus.emit({ type: 'run_started', runId: 'r1', @@ -160,7 +160,7 @@ test('attachRunStatePersistence 返回 unsubscribe;调用后不再写盘', asy await new Promise(r => setTimeout(r, 50)) expect(await readRunState(dir, 'r1')).not.toBeNull() - // unsubscribe 后再发 run_done,不应再写盘 + // after unsubscribe, emit run_done again, should not write to disk unsub() bus.emit({ type: 'run_started', diff --git a/src/workflow/__tests__/selectors.test.ts b/src/workflow/__tests__/selectors.test.ts index 218c4a5c1..72390032d 100644 --- a/src/workflow/__tests__/selectors.test.ts +++ b/src/workflow/__tests__/selectors.test.ts @@ -23,7 +23,7 @@ function run(partial: Partial): RunProgress { } } -test('mergePhases:声明顺序优先,实际 phase 追加未声明的,计数 done/total', () => { +test('mergePhases: declared order first, actual phases append undeclared ones, counts done/total', () => { const r = run({ declaredPhases: ['Find', 'Review', 'Verify'], phases: [ @@ -49,7 +49,7 @@ test('mergePhases:声明顺序优先,实际 phase 追加未声明的,计 ]) }) -test('mergePhases:实际出现但未声明的 phase 追加到末尾', () => { +test('mergePhases: actual but undeclared phase appended to the end', () => { const r = run({ declaredPhases: ['Find'], phases: [ @@ -61,7 +61,7 @@ test('mergePhases:实际出现但未声明的 phase 追加到末尾', () => { expect(mergePhases(r).map(p => p.title)).toEqual(['Find', 'Adhoc']) }) -test('filterAgentsByPhase:All / undefined → 全部;指定 → 仅该 phase', () => { +test('filterAgentsByPhase: All / undefined → all; specified → only that phase', () => { const agents: AgentProgress[] = [ { id: 1, phase: 'A', status: 'running' }, { @@ -77,6 +77,6 @@ test('filterAgentsByPhase:All / undefined → 全部;指定 → 仅该 phase expect(filterAgentsByPhase(agents, 'A')).toEqual([agents[0]]) }) -test('tabLabel:workflow 名 + runId 后 4 位短码', () => { +test('tabLabel: workflow name + last 4 chars short code of runId', () => { expect(tabLabel('review-changes', 'wf_abc123def')).toBe('review-changes#3def') }) diff --git a/src/workflow/__tests__/service.test.ts b/src/workflow/__tests__/service.test.ts index 7e9d46b31..2127c7171 100644 --- a/src/workflow/__tests__/service.test.ts +++ b/src/workflow/__tests__/service.test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'bun:test' -// DI 模式:不使用 mock.module(进程全局、last-write-wins,会污染同进程其他测试如 -// autonomy.test.ts)。改为手工构造 FAKE WorkflowPorts:registry.run 返回固定 ok -// 结果,taskRegistrar 维护 abort 绑定,journalStore 内存空实现。真实 runWorkflow -// 因此跑完且无需 LLM 或 mock。 +// DI pattern: do not use mock.module (process-global, last-write-wins, would pollute other tests in the same process such as +// autonomy.test.ts). Instead hand-construct FAKE WorkflowPorts: registry.run returns a fixed ok +// result, taskRegistrar maintains abort bindings, journalStore is an in-memory empty impl. The real runWorkflow +// thus runs to completion without needing LLM or mocks. import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -19,10 +19,10 @@ import type { WorkflowPorts, } from '@claude-code-best/workflow-engine' -// 构造 FAKE ports:registry.run 返回固定 AgentRunResult,taskRegistrar 带 binding, -// journalStore 内存空实现。progressEmitter.emit → bus.emit(store 已在构造时订阅 bus)。 -// 注意:runWorkflow 自身会发 run_started/run_done;taskRegistrar 只管 abort 绑定, -// 不重复发事件(避免 store reducer 收到重复 run_done)。 +// Construct FAKE ports: registry.run returns a fixed AgentRunResult, taskRegistrar has bindings, +// journalStore is an in-memory empty impl. progressEmitter.emit → bus.emit (store subscribes to bus at construction). +// Note: runWorkflow itself emits run_started/run_done; taskRegistrar only manages abort bindings, +// does not re-emit events (avoids store reducer receiving duplicate run_done). type RegistrarCall = | { kind: 'complete'; runId: string; summary?: string } | { kind: 'fail'; runId: string; error?: string } @@ -38,22 +38,22 @@ type RegistrarCall = function fakePorts( opts: { - /** adapter.run 抛错(模拟 agent 后端崩溃)。 */ + /** adapter.run throws (simulates agent backend crash). */ adapterThrow?: string - /** adapter.run 返回值(默认 ok)。 */ + /** adapter.run return value (default ok). */ adapterResult?: AgentRunResult - /** agentRunner.runAgentToResult 返回值(fallback 路径,默认 throw)。 */ + /** agentRunner.runAgentToResult return value (fallback path, default throws). */ runnerResult?: AgentRunResult } = {}, ): { ports: WorkflowPorts store: ReturnType killed: string[] - /** taskRegistrar 调用记录(complete/fail/kill/registerAgentAbort/...)。 */ + /** taskRegistrar call records (complete/fail/kill/registerAgentAbort/...). */ calls: RegistrarCall[] - /** runId → (agentId → AbortController)。测试模拟 backend 注册用。 */ + /** runId → (agentId → AbortController). Used by tests to simulate backend registration. */ agentBindings: Map> - /** adapter.run 被调次数(重试时累加)。holder 引用,测试读 adapterCalls.value。 */ + /** adapter.run call count (accumulates on retry). holder reference, tests read adapterCalls.value. */ adapterCallsRef: { value: number } } { const bus = createProgressBus() @@ -61,16 +61,16 @@ function fakePorts( const killed: string[] = [] const calls: RegistrarCall[] = [] const bindings = new Map() - // agentId → AbortController(每个 runId 独立)。killAgent 据此精确中断。 + // agentId → AbortController (per runId). killAgent uses this to abort precisely. const agentBindings = new Map>() - // adapter.run 被调次数(重试时累加)。用 holder object 避免 closure/getter - // 在 Bun test runner 里的快照语义问题——返回时 shorthand 取当前值(=0), - // 后续 outer 变量 ++ 不会反映到 returned object 字段。holder 引用稳定。 + // adapter.run call count (accumulates on retry). Use holder object to avoid closure/getter + // snapshot semantics issues in Bun test runner — when returning, shorthand takes the current value (=0), + // subsequent outer variable ++ does not reflect into the returned object field. holder reference is stable. const adapterCallsRef = { value: 0 } let seq = 0 const ports = { - // hostFactory 实际不被 service.launch 路径调用(service 自建 host handle), - // 但 WorkflowPorts 类型要求存在;保留一个最小实现。 + // hostFactory is not actually called by the service.launch path (service builds its own host handle), + // but the WorkflowPorts type requires it to exist; keep a minimal impl. hostFactory: () => ({ handle: {} as never, cwd: '/tmp', @@ -175,12 +175,12 @@ function fakePorts( const stubTUC = { agentId: 'a1', toolUseId: 'tu' } as never const stubCanUseTool = (() => Promise.resolve({ behavior: 'allow' })) as never -/** 等待 detached runWorkflow 完成(detached 调用,需让微任务/宏任务排空)。 */ +/** Wait for detached runWorkflow to complete (detached call, need to drain microtasks/macrotasks). */ async function settle(): Promise { await new Promise(r => setTimeout(r, 60)) } -test('launch → completed;store 出现该 run', async () => { +test('launch → completed; store shows this run', async () => { __resetWorkflowServiceForTests() const { ports, store } = fakePorts() const svc = makeService(ports, store) @@ -192,12 +192,12 @@ test('launch → completed;store 出现该 run', async () => { await settle() const r = svc.getRun(runId) expect(r).toBeDefined() - // detached 执行可能在 settle 窗口内仍 running,或已 completed——两者皆可接受。 + // detached execution may still be running within the settle window, or already completed — both are acceptable. expect(['completed', 'running']).toContain(r!.status) expect(r!.workflowName).toBe('workflow') }) -test('launch inline script → 返回 scriptPath(持久化到 cwdOverride 目录)', async () => { +test('launch inline script → returns scriptPath (persisted to cwdOverride dir)', async () => { __resetWorkflowServiceForTests() const dir = await mkdtemp(join(tmpdir(), 'wf-svc-')) try { @@ -220,7 +220,7 @@ test('launch inline script → 返回 scriptPath(持久化到 cwdOverride 目 } }) -test('kill 走 taskRegistrar.kill', async () => { +test('kill goes through taskRegistrar.kill', async () => { __resetWorkflowServiceForTests() const { ports, store, killed } = fakePorts() const svc = makeService(ports, store) @@ -233,7 +233,7 @@ test('kill 走 taskRegistrar.kill', async () => { expect(killed).toContain(runId) }) -test('killAgent 走 taskRegistrar.killAgent:精确中断单个 agent', async () => { +test('killAgent goes through taskRegistrar.killAgent: precisely aborts a single agent', async () => { __resetWorkflowServiceForTests() const { ports, store, calls, agentBindings } = fakePorts() const svc = makeService(ports, store) @@ -242,10 +242,10 @@ test('killAgent 走 taskRegistrar.killAgent:精确中断单个 agent', async ( stubTUC, stubCanUseTool, ) - // 模拟 backend 启动 agent 时注册 AbortController + // simulate backend registering AbortController when launching agent const ac = new AbortController() agentBindings.get(runId)!.set(7, ac) - // service.killAgent 路由到 taskRegistrar.killAgent,后者真 abort 对应 controller + // service.killAgent routes to taskRegistrar.killAgent, which actually aborts the corresponding controller expect(svc.killAgent(runId, 7)).toBe(true) expect(ac.signal.aborted).toBe(true) expect( @@ -253,14 +253,14 @@ test('killAgent 走 taskRegistrar.killAgent:精确中断单个 agent', async ( c => c.kind === 'killAgent' && c.runId === runId && c.agentId === 7, ), ).toBe(true) - // 已 abort 后 controller 从 Map 删除:再次 killAgent 同 agent 返 false(幂等) + // after abort controller is deleted from Map: calling killAgent on same agent again returns false (idempotent) expect(svc.killAgent(runId, 7)).toBe(false) - // 未知 agentId / 未知 runId 安全返 false + // unknown agentId / unknown runId safe returns false expect(svc.killAgent(runId, 999)).toBe(false) expect(svc.killAgent('nope', 1)).toBe(false) }) -test('listRuns/subscribe 来自 store', () => { +test('listRuns/subscribe come from store', () => { __resetWorkflowServiceForTests() const { ports, store } = fakePorts() const svc = makeService(ports, store) @@ -274,16 +274,16 @@ test('listRuns/subscribe 来自 store', () => { expect(n).toBe(0) }) -test('listNamed 委托 namedWorkflows(空目录→[];有文件→列出)', async () => { +test('listNamed delegates to namedWorkflows (empty dir → []; with files → lists)', async () => { __resetWorkflowServiceForTests() const { ports, store } = fakePorts() const svc = makeService(ports, store) - // 不存在的目录 → [] + // non-existent dir → [] const empty = await svc.listNamed( join(tmpdir(), `wf-nope-${Math.random().toString(36).slice(2)}`), ) expect(empty).toEqual([]) - // 有命名文件的目录 → 列出 name(去扩展名,排序) + // dir with named files → lists names (extension stripped, sorted) const dir = await mkdtemp(join(tmpdir(), 'wf-named-')) try { await writeFile( @@ -298,7 +298,7 @@ test('listNamed 委托 namedWorkflows(空目录→[];有文件→列出)', } }) -test('缺 script/name/scriptPath → 抛错', async () => { +test('missing script/name/scriptPath → throws', async () => { __resetWorkflowServiceForTests() const { ports, store } = fakePorts() const svc = makeService(ports, store) @@ -307,7 +307,7 @@ test('缺 script/name/scriptPath → 抛错', async () => { ) }) -test('scriptPath 读取文件内容并校验', async () => { +test('scriptPath reads file content and validates', async () => { __resetWorkflowServiceForTests() const { ports, store } = fakePorts() const svc = makeService(ports, store) @@ -329,23 +329,23 @@ test('scriptPath 读取文件内容并校验', async () => { } }) -test('parseScript 校验失败 → launch 抛错', async () => { +test('parseScript validation failed → launch throws', async () => { __resetWorkflowServiceForTests() const { ports, store } = fakePorts() const svc = makeService(ports, store) - // 触发 ScriptError:meta 字面量缺 description(validateMeta 要求 name+description 均为字符串) + // trigger ScriptError: meta literal missing description (validateMeta requires both name+description to be strings) await expect( svc.launch( { script: `export const meta = { name: "x" }\nreturn 1` }, stubTUC, stubCanUseTool, ), - ).rejects.toThrow(/校验失败/) + ).rejects.toThrow(/Script validation failed/i) }) -// ---- 服务层失败路由覆盖(审查 gap:.then/.catch → taskRegistrar 路径)---- +// ---- Service-layer failure routing coverage (review gap: .then/.catch → taskRegistrar path) ---- -test('脚本运行抛错 → service 路由到 taskRegistrar.fail,带 error 文本', async () => { +test('script run throws → service routes to taskRegistrar.fail, with error text', async () => { __resetWorkflowServiceForTests() const { ports, store, calls } = fakePorts() const svc = makeService(ports, store) @@ -360,27 +360,27 @@ test('脚本运行抛错 → service 路由到 taskRegistrar.fail,带 error expect(fail?.kind === 'fail' && fail.error).toMatch(/script boom/) }) -test('adapter 抛错 → 重试仍抛 → 降级 dead → workflow completed(不 fail)', async () => { +test('adapter throws → retry still throws → degrade to dead → workflow completed (not fail)', async () => { __resetWorkflowServiceForTests() - // 新语义:agent 非 abort 抛错 → 重试一次 → 仍抛 → 降级 dead(agent 返 null), - // workflow 继续并 completed。重试容许临时故障(429/网络),但一个 agent - // 永久坏也不击穿整个 workflow(与 parallel/pipeline 的 null-on-error 契约一致)。 + // new semantics: agent non-abort throw → retry once → still throws → degrade to dead (agent returns null), + // workflow continues and completes. Retry tolerates transient failures (429/network), but a permanently + // broken agent does not break through the entire workflow (consistent with parallel/pipeline null-on-error contract). const { ports, store, calls, adapterCallsRef } = fakePorts({ adapterThrow: 'adapter boom', }) const svc = makeService(ports, store) await svc.launch({ script: `return agent('x')` }, stubTUC, stubCanUseTool) await settle() - // 重试一次 → adapter 被调 2 次 + // retry once → adapter called 2 times expect(adapterCallsRef.value).toBe(2) - // workflow 正常 completed,未 failed + // workflow normal completed, not failed const complete = calls.find(c => c.kind === 'complete') expect(complete).toBeDefined() const fail = calls.find(c => c.kind === 'fail') expect(fail).toBeUndefined() }) -test('脚本正常完成 → service 路由到 taskRegistrar.complete', async () => { +test('script completes normally → service routes to taskRegistrar.complete', async () => { __resetWorkflowServiceForTests() const { ports, store, calls } = fakePorts() const svc = makeService(ports, store) @@ -389,12 +389,12 @@ test('脚本正常完成 → service 路由到 taskRegistrar.complete', async () expect(calls.some(c => c.kind === 'complete')).toBe(true) }) -// ---- 修复 N:shutdown 清理 ---- +// ---- Fix N: shutdown cleanup ---- -test('shutdown 杀掉所有 running run(taskRegistrar.kill 调用每个)', async () => { +test('shutdown kills all running runs (taskRegistrar.kill called for each)', async () => { __resetWorkflowServiceForTests() const { ports, store, killed } = fakePorts() - // 让 adapter 慢一点,settle 期间 run 仍在 running + // make adapter slower, so during settle the run is still running const slowPorts = { ...ports, agentAdapterRegistry: { @@ -425,7 +425,7 @@ test('shutdown 杀掉所有 running run(taskRegistrar.kill 调用每个)', a expect(killed).toContain(b) }) -test('shutdown 不重复杀已完成 run;幂等(多次调用安全)', async () => { +test('shutdown does not re-kill completed runs; idempotent (multiple calls safe)', async () => { __resetWorkflowServiceForTests() const { ports, store, killed } = fakePorts() const svc = makeService(ports, store) @@ -434,24 +434,24 @@ test('shutdown 不重复杀已完成 run;幂等(多次调用安全)', asyn stubTUC, stubCanUseTool, ) - await settle() // 完成 + await settle() // complete killed.length = 0 svc.shutdown() - // 已完成的不应再被 kill + // already completed should not be killed again expect(killed).not.toContain(runId) - // 幂等 + // idempotent expect(() => svc.shutdown()).not.toThrow() }) // ---- Task 5: loadPersistedRuns + getRunAsync fallback ---- -// runsDirProvider 作为 makeService 第四个可选参数注入 tmpdir,避免写真实项目目录 -// (Bun ESM 模块命名空间只读,无法 monkey-patch getRunsDir)。 +// runsDirProvider is injected as makeService's fourth optional parameter with tmpdir, to avoid writing to the real project dir +// (Bun ESM module namespace is read-only, cannot monkey-patch getRunsDir). -test('loadPersistedRuns 扫盘 hydrate 历史 run;已有内存 run 不被覆盖', async () => { +test('loadPersistedRuns scans disk to hydrate historical runs; existing in-memory runs are not overwritten', async () => { __resetWorkflowServiceForTests() const dir = await mkdtemp(join(tmpdir(), 'wf-svc-')) try { - // 磁盘先有两个历史 run + // disk first has two historical runs const { writeRunState } = await import('../persistence.js') const historicalA = { runId: 'hA', @@ -483,7 +483,7 @@ test('loadPersistedRuns 扫盘 hydrate 历史 run;已有内存 run 不被覆 await writeRunState(dir, historicalB) const { ports, store } = fakePorts() - // 内存先有一个本次会话 run(通过 ports.progressEmitter.emit 走 bus → store) + // in-memory first has one current-session run (via ports.progressEmitter.emit through bus → store) ports.progressEmitter.emit({ type: 'run_started', runId: 'live', @@ -498,7 +498,7 @@ test('loadPersistedRuns 扫盘 hydrate 历史 run;已有内存 run 不被覆 expect(ids).toContain('hA') expect(ids).toContain('hB') expect(ids).toContain('live') - // 内存优先:live 仍是 running(不被磁盘覆盖;磁盘里没有 live 也不会注入 STALE) + // memory first: live is still running (not overwritten by disk; disk has no live so no STALE injected) expect(svc.getRun('live')!.status).toBe('running') expect(svc.getRun('hA')!.returnValue).toBe('a') } finally { @@ -506,7 +506,7 @@ test('loadPersistedRuns 扫盘 hydrate 历史 run;已有内存 run 不被覆 } }) -test('loadPersistedRuns 重复调用仅扫盘一次(persistedLoaded flag)', async () => { +test('loadPersistedRuns repeated calls scan disk only once (persistedLoaded flag)', async () => { __resetWorkflowServiceForTests() const dir = await mkdtemp(join(tmpdir(), 'wf-svc-')) try { @@ -517,14 +517,14 @@ test('loadPersistedRuns 重复调用仅扫盘一次(persistedLoaded flag)', await svc.loadPersistedRuns() await svc.loadPersistedRuns() - // 重复调用不抛错、不改变 listRuns 结果(空目录) + // repeated calls do not throw, do not change listRuns result (empty dir) expect(svc.listRuns()).toEqual([]) } finally { await rm(dir, { recursive: true, force: true }) } }) -test('getRunAsync 内存命中 → 不读盘', async () => { +test('getRunAsync memory hit → no disk read', async () => { __resetWorkflowServiceForTests() const dir = await mkdtemp(join(tmpdir(), 'wf-svc-')) try { @@ -544,7 +544,7 @@ test('getRunAsync 内存命中 → 不读盘', async () => { } }) -test('getRunAsync 内存 miss + 磁盘命中 → 返回磁盘值,且不注入内存(再次 get 仍读盘)', async () => { +test('getRunAsync memory miss + disk hit → returns disk value, and does not inject into memory (subsequent get still reads disk)', async () => { __resetWorkflowServiceForTests() const dir = await mkdtemp(join(tmpdir(), 'wf-svc-')) try { @@ -569,9 +569,9 @@ test('getRunAsync 内存 miss + 磁盘命中 → 返回磁盘值,且不注入 const got = await svc.getRunAsync('hist-only') expect(got?.returnValue).toEqual({ x: 1 }) - // 不注入内存:内存 list 不含(未 hydrate) + // not injected into memory: in-memory list does not contain (not hydrated) expect(svc.listRuns().map(r => r.runId)).not.toContain('hist-only') - // 再次 get 仍能返回(每次走 readRunState fallback) + // subsequent get still returns (each goes through readRunState fallback) const got2 = await svc.getRunAsync('hist-only') expect(got2?.returnValue).toEqual({ x: 1 }) } finally { @@ -579,7 +579,7 @@ test('getRunAsync 内存 miss + 磁盘命中 → 返回磁盘值,且不注入 } }) -test('getRunAsync 内存 miss + 磁盘 miss → undefined', async () => { +test('getRunAsync memory miss + disk miss → undefined', async () => { __resetWorkflowServiceForTests() const dir = await mkdtemp(join(tmpdir(), 'wf-svc-')) try { diff --git a/src/workflow/__tests__/status.test.ts b/src/workflow/__tests__/status.test.ts index 17a435e13..7d3e7aa66 100644 --- a/src/workflow/__tests__/status.test.ts +++ b/src/workflow/__tests__/status.test.ts @@ -11,7 +11,7 @@ import { agentMetaText, } from '../panel/status.js' -test('STATUS_DOT / RUN_STATUS_COLOR / RUN_STATUS_TEXT 覆盖四种 run 状态', () => { +test('STATUS_DOT / RUN_STATUS_COLOR / RUN_STATUS_TEXT cover four run states', () => { const statuses: RunProgress['status'][] = [ 'running', 'completed', @@ -31,19 +31,19 @@ test('STATUS_DOT / RUN_STATUS_COLOR / RUN_STATUS_TEXT 覆盖四种 run 状态', expect(RUN_STATUS_TEXT.running).toBe('running') }) -test('PHASE_MARK / PHASE_COLOR 覆盖 running/done/pending', () => { +test('PHASE_MARK / PHASE_COLOR cover running/done/pending', () => { expect(PHASE_MARK.running).toBe('●') expect(PHASE_MARK.done).toBe('✓') expect(PHASE_MARK.pending).toBe('○') expect(PHASE_COLOR.pending).toBe('subtle') }) -test('agentVisual:running → ● warning', () => { +test('agentVisual: running → ● warning', () => { const a: AgentProgress = { id: 1, status: 'running' } expect(agentVisual(a)).toEqual({ mark: '●', color: 'warning' }) }) -test('agentVisual:done·ok → ✓ success(不再带 outputShape 后缀)', () => { +test('agentVisual: done·ok → ✓ success (no longer carries outputShape suffix)', () => { const a: AgentProgress = { id: 1, status: 'done', @@ -53,12 +53,12 @@ test('agentVisual:done·ok → ✓ success(不再带 outputShape 后缀)', expect(agentVisual(a)).toEqual({ mark: '✓', color: 'success' }) }) -test('agentVisual:dead → ✗ error', () => { +test('agentVisual: dead → ✗ error', () => { const a: AgentProgress = { id: 1, status: 'done', resultKind: 'dead' } expect(agentVisual(a)).toEqual({ mark: '✗', color: 'error' }) }) -test('formatTokenCount:<1000 原值,≥1000 保留 1 位小数 + k', () => { +test('formatTokenCount: <1000 original value, ≥1000 keeps 1 decimal + k', () => { expect(formatTokenCount(undefined)).toBe('0') expect(formatTokenCount(0)).toBe('0') expect(formatTokenCount(42)).toBe('42') @@ -66,7 +66,7 @@ test('formatTokenCount:<1000 原值,≥1000 保留 1 位小数 + k', () => { expect(formatTokenCount(22900)).toBe('22.9k') }) -test('agentMetaText:model · Nk tok · N tool', () => { +test('agentMetaText: model · Nk tok · N tool', () => { const a: AgentProgress = { id: 1, status: 'done', @@ -77,7 +77,7 @@ test('agentMetaText:model · Nk tok · N tool', () => { expect(agentMetaText(a)).toBe('glm-5.2 · 22.9k tok · 1 tool') }) -test('agentMetaText:无 model 时省略前段', () => { +test('agentMetaText: omits prefix when no model', () => { const a: AgentProgress = { id: 1, status: 'running', diff --git a/src/workflow/__tests__/useWorkflowKeyboard.test.ts b/src/workflow/__tests__/useWorkflowKeyboard.test.ts index 196d90138..6a7408d38 100644 --- a/src/workflow/__tests__/useWorkflowKeyboard.test.ts +++ b/src/workflow/__tests__/useWorkflowKeyboard.test.ts @@ -18,7 +18,7 @@ test('x → killAgent;K → killWorkflow;r → resume;n → newRun', () => expect(routeWorkflowKey('n', {})).toBe('newRun') }) -test('confirm 模式:y/Enter → confirmYes;n/Esc/q → confirmNo;其他键 → null', () => { +test('confirm mode: y/Enter → confirmYes; n/Esc/q → confirmNo; other keys → null', () => { expect(routeWorkflowKey('y', {}, 'confirm')).toBe('confirmYes') expect(routeWorkflowKey('Y', {}, 'confirm')).toBe('confirmYes') expect(routeWorkflowKey('', { return: true }, 'confirm')).toBe('confirmYes') @@ -26,20 +26,20 @@ test('confirm 模式:y/Enter → confirmYes;n/Esc/q → confirmNo;其他 expect(routeWorkflowKey('N', {}, 'confirm')).toBe('confirmNo') expect(routeWorkflowKey('', { escape: true }, 'confirm')).toBe('confirmNo') expect(routeWorkflowKey('q', {}, 'confirm')).toBe('confirmNo') - // confirm 模式吞掉导航/编辑键,防误触 + // confirm mode swallows navigation/edit keys, preventing accidental triggers expect(routeWorkflowKey('x', {}, 'confirm')).toBeNull() expect(routeWorkflowKey('', { tab: true }, 'confirm')).toBeNull() expect(routeWorkflowKey('', { upArrow: true }, 'confirm')).toBeNull() }) -test('←/→ 切焦点列;↑/↓ 列内移动', () => { +test('←/→ switch focus column; ↑/↓ move within column', () => { expect(routeWorkflowKey('', { leftArrow: true })).toBe('focusLeft') expect(routeWorkflowKey('', { rightArrow: true })).toBe('focusRight') expect(routeWorkflowKey('', { upArrow: true })).toBe('moveUp') expect(routeWorkflowKey('', { downArrow: true })).toBe('moveDown') }) -test('无关输入 → null', () => { +test('unrelated input → null', () => { expect(routeWorkflowKey('z', {})).toBeNull() expect(routeWorkflowKey('', {})).toBeNull() }) diff --git a/src/workflow/backends/claudeCodeBackend.ts b/src/workflow/backends/claudeCodeBackend.ts index 6fd1df767..77b91cfb4 100644 --- a/src/workflow/backends/claudeCodeBackend.ts +++ b/src/workflow/backends/claudeCodeBackend.ts @@ -1,5 +1,5 @@ -// 深度集成后端:从活会话解析 agent/model/tools,委托核心 runAgent。 -// 实现 AgentAdapter 接口,由 registry(U5)注册并路由。 +// Deeply-integrated backend: parses agent/model/tools from the live session, delegates to the core runAgent. +// Implements the AgentAdapter interface, registered and routed by the registry (U5). import { type AgentAdapter, type AgentAdapterContext, @@ -32,10 +32,10 @@ import type { Message } from '../../types/message.js' import type { ToolUseContext } from '../../Tool.js' import { readHostBundle } from '../hostHandle.js' -/** workflow 子 agent 的兜底定义(agentType 未命中真实注册表时用)。 */ +/** Fallback definition for workflow subagents (used when agentType does not match a real registry entry). */ export const WORKFLOW_AGENT: BuiltInAgentDefinition = { agentType: 'workflow-worker', - whenToUse: 'workflow 脚本内 agent() 钩子派发的子任务', + whenToUse: 'subtask dispatched by the agent() hook inside a workflow script', tools: ['*'], source: 'built-in', baseDir: 'built-in', @@ -43,7 +43,7 @@ export const WORKFLOW_AGENT: BuiltInAgentDefinition = { 'You are a workflow sub-agent. Complete the task concisely; your final text is the return value relayed to the workflow.', } -/** agentType → 真实 agent 注册表(activeAgents 命中即用,否则兜底)。已导出便于单测。 */ +/** agentType -> real agent registry (use if activeAgents hits, otherwise fallback). Exported for unit test coverage. */ export function resolveAgentDefinition( agentType: string | undefined, toolUseContext: ToolUseContext, @@ -55,7 +55,7 @@ export function resolveAgentDefinition( return found ?? WORKFLOW_AGENT } -/** model 别名 → 当前 provider 实际 model id。v1 直传(保留映射扩展点)。已导出便于单测。 */ +/** model alias -> the actual model id of the current provider. v1 passes it through directly (keeps a mapping extension point). Exported for unit test coverage. */ export function mapWorkflowModel( model: string | undefined, ): string | undefined { @@ -63,21 +63,21 @@ export function mapWorkflowModel( } /** - * 从 agent 最终消息中提取 schema 模式产出的 JSON 对象;失败返回 null。已导出便于单测。 + * Extract the JSON object produced under schema mode from the agent's final message; returns null on failure. Exported for unit test coverage. * - * 鲁棒性策略(按优先级,第一个 parse 成功的返回): - * 1. fenced code block(```json ... ``` 或 ``` ... ```)—— agent 常自发加围栏 - * 2. 裸文本里第一个"括号平衡"的 {...} 片段—— 处理前后叙述 / 多段输出 + * Robustness strategy (in priority order, returns the first that successfully parses): + * 1. fenced code block (```json ... ``` or ``` ... ```) - agents often spontaneously add fences + * 2. the first "brace-balanced" {...} fragment in the bare text - handles preceding/trailing narration / multi-segment output * - * 用括号栈扫描而非 `indexOf('{')..lastIndexOf('}')`:能正确处理嵌套对象、 - * 字符串字面量内的 `{}`、转义字符。不会跨多个不相关 JSON 拼接(原版会)。 + * Uses a brace-stack scan instead of `indexOf('{')..lastIndexOf('}')`: correctly handles nested objects, + * `{}` inside string literals, and escape characters. Will not concatenate multiple unrelated JSON fragments (the original version did). * - * 不做语法修复(尾逗号、单引号→双引号、注释删除)—— agent 不会产非标 JSON, - * 修了反而可能在字符串内误改(如 `"http://..."` 被 // 注释正则吃掉)。 - * parse 失败直接 skip 到下一个候选。 + * Does not do syntax repair (trailing commas, single quotes -> double quotes, comment removal) - agents do not produce non-standard JSON, + * and fixing it may instead cause wrong edits inside strings (e.g. `"http://..."` getting eaten by a // comment regex). + * On parse failure it directly skips to the next candidate. * - * 只返回 plain object(typeof === 'object' && !null && !Array); - * schema 模式契约是 object,array/number/string 一律视为 agent 跑题。 + * Only returns a plain object (typeof === 'object' && !null && !Array); + * the schema mode contract is object, array/number/string are all treated as the agent going off-track. */ export function extractStructuredOutput( content: Array<{ type: string; text?: string }>, @@ -90,16 +90,16 @@ export function extractStructuredOutput( return null } -/** 在 text 中找第一个能 parse 成 plain object 的 JSON 片段。 */ +/** Find the first JSON fragment in text that can be parsed as a plain object. */ function findFirstJsonObject(text: string): unknown | null { - // 1. fenced code blocks——优先(agent 自然倾向,剥围栏后 parse 整块) + // 1. fenced code blocks - priority (agents naturally tend to add them; strip the fence and parse the whole block) for (const m of text.matchAll( /```[\t ]*[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n?```/g, )) { const parsed = tryParseObject(m[1] ?? '') if (parsed !== null) return parsed } - // 2. 裸文本:扫每个 '{',找平衡配对后 try parse + // 2. bare text: scan each '{', find a balanced pair and try parse for (let i = 0; i < text.length; i++) { if (text[i] !== '{') continue const end = findBalancedObjectEnd(text, i) @@ -111,9 +111,9 @@ function findFirstJsonObject(text: string): unknown | null { } /** - * 从 start(必须是 `{`)开始找配对的 `}` 索引;不平衡返 -1。 - * 跳过字符串字面量内的括号、转义字符。不做注释跳过(JSON 标准不允许注释, - * agent 不会产;做了反而风险——见函数 doc)。 + * Find the matching `}` index starting from start (which must be `{`); returns -1 when unbalanced. + * Skips braces inside string literals and escape characters. Does not skip comments (the JSON standard does not allow comments, + * agents do not produce them; doing so is a risk - see the function doc). */ function findBalancedObjectEnd(text: string, start: number): number { let depth = 0 @@ -122,7 +122,7 @@ function findBalancedObjectEnd(text: string, start: number): number { const c = text[i] if (inString) { if (c === '\\') - i++ // 跳过转义符和下一个字符 + i++ // skip the escape char and the next character else if (c === '"') inString = false continue } @@ -136,7 +136,7 @@ function findBalancedObjectEnd(text: string, start: number): number { return -1 } -/** try parse candidate;只有 plain object 才返回,其它(array/number/null)返 null。 */ +/** try parse the candidate; only returns a plain object, others (array/number/null) return null. */ function tryParseObject(candidate: string): unknown | null { const trimmed = candidate.trim() if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null @@ -151,10 +151,10 @@ function tryParseObject(candidate: string): unknown | null { type WorkflowWorktreeInfo = Awaited> /** - * 为 workflow agent 的 worktree 隔离生成 slug:sha256(runId:agentId) 派生 hex 段, - * 匹配 cleanupStaleAgentWorktrees 的清理正则 `^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$`。 - * taskId 是 `w`+base36(非 UUID),不能直接塞 runId 进正则段;sha256 是确定性映射, - * agentId 保证同 runId 多 agent 的 slug 唯一(无共享计数器,无线程安全问题)。 + * Generate a slug for the worktree isolation of a workflow agent: derive hex segments from sha256(runId:agentId), + * matching the cleanup regex of cleanupStaleAgentWorktrees `^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$`. + * taskId is `w`+base36 (not a UUID), so runId cannot be placed directly into the regex segment; sha256 is a deterministic mapping, + * and agentId ensures slug uniqueness for multiple agents under the same runId (no shared counter, no thread safety issues). */ function makeWorkflowWorktreeSlug(runId: string, agentId: string): string { const h = createHash('sha256').update(`${runId}:${agentId}`).digest('hex') @@ -162,9 +162,9 @@ function makeWorkflowWorktreeSlug(runId: string, agentId: string): string { } /** - * agent 完成后清理 worktree:hookBased 保留(无法检测 VCS 变更);否则用 - * hasWorktreeChanges(fail-closed)检测,无变更 auto-remove,有变更/检测失败保留 - * 并 log 路径(v1 用日志而非扩 AgentRunResult,避免动 journal 序列化)。 + * Clean up the worktree after the agent finishes: hookBased keeps it (cannot detect VCS changes); otherwise uses + * hasWorktreeChanges (fail-closed) to detect, auto-removes when there is no change, keeps it on change/detection failure + * and logs the path (v1 uses logs rather than extending AgentRunResult, to avoid touching journal serialization). */ async function cleanupWorkflowWorktree( info: WorkflowWorktreeInfo, @@ -199,7 +199,7 @@ async function cleanupWorkflowWorktree( } } -/** 深度集成后端:从活会话解析 agent/model/tools,委托核心 runAgent。 */ +/** Deeply-integrated backend: parses agent/model/tools from the live session, delegates to the core runAgent. */ export const claudeCodeBackend: AgentAdapter = { id: 'claude-code', capabilities: { structuredOutput: true, tools: true }, @@ -212,11 +212,11 @@ export const claudeCodeBackend: AgentAdapter = { const appState = toolUseContext.getAppState() const agentDef = resolveAgentDefinition(params.agentType, toolUseContext) const model = mapWorkflowModel(params.model) - // coreAgentId:core 层子 agent 跟踪 ID(字符串,runAgent 内部用)。 - // 与 ctx.agentId(引擎 number seq,用于面板/killAgent 路由)是两个不同概念,不可混用。 + // coreAgentId: the tracking ID for the core-layer subagent (a string, used inside runAgent). + // Different from ctx.agentId (the engine's number seq, used for panel / killAgent routing) - two distinct concepts, must not be mixed up. const coreAgentId = createAgentId() - // isolation:'worktree' — 在独立 git worktree 里跑 agent,并发写互不冲突。 + // isolation:'worktree' - run the agent inside an independent git worktree, so concurrent writes do not conflict. let worktreeInfo: WorkflowWorktreeInfo | null = null if (params.isolation === 'worktree') { try { @@ -224,7 +224,7 @@ export const claudeCodeBackend: AgentAdapter = { makeWorkflowWorktreeSlug(ctx.runId, coreAgentId), ) } catch (e) { - // fail-closed:隔离未达成不静默退化为共享 cwd(否则并发写数据竞争) + // fail-closed: when isolation fails, do not silently fall back to a shared cwd (otherwise concurrent writes race on data) const detail = (e as Error).message logForDebugging( `workflow worktree creation failed (${agentDef.agentType}): ${detail}`, @@ -232,17 +232,17 @@ export const claudeCodeBackend: AgentAdapter = { return { kind: 'dead', reason: 'worktree-failed', detail } } } - // runWithCwdOverride 让 agent 内的 Bash/Read 等工具看到 worktree 路径 - // (AsyncLocalStorage 跨 await 保持);runAgent 的 worktreePath 参数仅写 metadata。 + // runWithCwdOverride makes tools such as Bash/Read inside the agent see the worktree path + // (AsyncLocalStorage is preserved across awaits); the worktreePath parameter of runAgent only writes metadata. const runInCwd = worktreeInfo ? (fn: () => T): T => runWithCwdOverride(worktreeInfo!.worktreePath, fn) : (fn: () => T): T => fn() - // 桥接 ctx.signal → runAgent.override.abortController。否则 workflow 被 kill - // 时 runAgent 不知道('x' 无效根因):abort 信号到不了内部 fetch,agent 跑到完成。 - // 单 agent kill 走 service.kill(runId, agentId) → ports.taskRegistrar.killAgent → - // agentAbortControllers.get(agentId).abort();同一 controller 接管两条路径。 + // Bridge ctx.signal -> runAgent.override.abortController. Otherwise, when the workflow is killed + // runAgent is unaware (root cause of 'x' being ineffective): the abort signal cannot reach the internal fetch, and the agent runs to completion. + // Single-agent kill goes through service.kill(runId, agentId) -> ports.taskRegistrar.killAgent -> + // agentAbortControllers.get(agentId).abort(); the same controller takes over both paths. const agentAbort = new AbortController() const onParentAbort = (): void => agentAbort.abort() if (ctx.signal.aborted) { @@ -263,12 +263,12 @@ export const claudeCodeBackend: AgentAdapter = { appState.mcp.tools, ) - // schema → 指示 agent 在最后文本块里直接 emit JSON。 - // 不要求调 StructuredOutput 工具——它不在 workflow sub-agent 的工具集里(只有 - // stop_hook 路径显式注入;workflow 走 assembleToolPool 默认池不含)。 - // 历史上 prompt 要求"call StructuredOutput tool"导致 8/12 agent 拒绝收尾/纠结调用, - // 实测 dead 主因是工具不可达而非"忘记"。改契约:raw JSON 文本,extractStructuredOutput - // 容错 fenced 围栏 + 前后叙述 + 多段。 + // schema -> instructs the agent to directly emit JSON in the final text block. + // Does not require calling the StructuredOutput tool - it is not in the workflow subagent's tool set (only + // the stop_hook path explicitly injects it; workflow goes through assembleToolPool whose default pool does not include it). + // Historically the prompt required "call StructuredOutput tool", causing 8/12 agents to refuse to wrap up or struggle to call it; + // empirically the main cause of dead is the tool being unreachable rather than "forgetting". Change the contract: raw JSON text, extractStructuredOutput + // tolerates fenced fences + preceding/trailing narration + multiple segments. const promptText = params.schema ? [ params.prompt, @@ -289,7 +289,7 @@ export const claudeCodeBackend: AgentAdapter = { const promptMessages = [createUserMessage({ content: promptText })] const messages: Message[] = [] const startTime = Date.now() - // 运行中进度累计(onProgress 推送 → agent_progress 事件 → 面板实时刷新 token/tool)。 + // Accumulate running progress (onProgress push -> agent_progress event -> panel refreshes token/tool in real time). let tokenCount = 0 let toolCount = 0 @@ -303,15 +303,15 @@ export const claudeCodeBackend: AgentAdapter = { isAsync: true, querySource: toolUseContext.options.querySource ?? 'workflow', availableTools: workerTools, - // override 同一对象:coreAgentId(core 子 agent 跟踪)+ abortController(kill 桥接)。 - // runAgent 的 model 是顶层 ModelAlias;workflow 的 model 是任意别名串, - // 类型上不兼容,运行时由 provider 层解析。双重断言透传(优于 as any/never)。 + // override the same object: coreAgentId (core subagent tracking) + abortController (kill bridge). + // runAgent's model is the top-level ModelAlias; workflow's model is an arbitrary alias string, + // the types are incompatible and resolved by the provider layer at runtime. Passes through via double assertion (better than as any/never). override: { agentId: coreAgentId, abortController: agentAbort }, ...(model ? { model: model as unknown as ModelAlias } : {}), ...(worktreeInfo ? { worktreePath: worktreeInfo.worktreePath } : {}), })) { messages.push(msg as Message) - // 累计运行中进度:assistant message 带 usage(累积值→覆盖)、content 内 tool_use(增量)。 + // Accumulate running progress: assistant message carries usage (cumulative value -> overwrite), tool_use inside content (incremental). if (msg.type === 'assistant' && msg.message) { const usage = msg.message.usage as | Parameters[0] @@ -327,9 +327,9 @@ export const claudeCodeBackend: AgentAdapter = { } }) } catch (e) { - // abort(kill workflow / kill agent):识别后必须重抛 WorkflowAbortedError, - // 否则 hooks.agent 会把 abort 当作普通失败吞成 dead,workflow 不知道被 kill - // (kill 路径 'x' 无效的另一面:信号虽然到了,但结果被伪装成正常完成)。 + // abort (kill workflow / kill agent): must rethrow WorkflowAbortedError after detection, + // otherwise hooks.agent will swallow the abort as an ordinary failure into dead, and the workflow won't know it was killed + // (the other side of the 'x' kill path being ineffective: the signal did arrive, but the result was disguised as a normal completion). if (agentAbort.signal.aborted || (e as Error)?.name === 'AbortError') { throw new WorkflowAbortedError() } @@ -340,7 +340,7 @@ export const claudeCodeBackend: AgentAdapter = { logEvent('tengu_workflow_agent', { ok: 0 }) return { kind: 'dead', reason: 'runagent-threw', detail } } finally { - // 清理(幂等):listener removeEventListener / Map.delete 重复调用安全。 + // cleanup (idempotent): listener removeEventListener / Map.delete are safe to call repeatedly. if (typeof ctx.unregisterAgentAbort === 'function') { ctx.unregisterAgentAbort(ctx.agentId) } @@ -362,7 +362,7 @@ export const claudeCodeBackend: AgentAdapter = { }) const outputTokens = finalized.usage?.output_tokens ?? finalized.totalTokens ?? 0 - // 面板展示用:完成时 context 总 token、工具调用次数、解析后 model id。 + // For panel display: total context tokens, tool-call count, parsed model id at completion. const finalTokenCount = finalized.totalTokens ?? 0 const finalToolCount = finalized.totalToolUseCount ?? 0 const resolvedModel = model ?? toolUseContext.options.mainLoopModel @@ -371,9 +371,9 @@ export const claudeCodeBackend: AgentAdapter = { if (params.schema) { const structured = extractStructuredOutput(finalized.content) if (structured === null) { - // agent 跑完所有工具调用但最终文本块里没找到 plain-object JSON。 - // 典型场景:长 tool chain 后忘记 emit JSON、JSON 嵌套不平衡、parse 失败。 - // 把最后文本预览进 detail,让 hooks 重试日志和面板能立刻看到 agent 实际说了什么。 + // The agent finished all tool calls but no plain-object JSON was found in the final text block. + // Typical scenarios: forgot to emit JSON after a long tool chain, unbalanced JSON nesting, parse failure. + // Put a preview of the last text into detail so the hooks retry log and the panel can immediately see what the agent actually said. const preview = extractTextContent(finalized.content, '\n').slice( 0, 200, diff --git a/src/workflow/hostHandle.ts b/src/workflow/hostHandle.ts index b9905784c..043112416 100644 --- a/src/workflow/hostHandle.ts +++ b/src/workflow/hostHandle.ts @@ -8,7 +8,7 @@ import type { AssistantMessage } from '../types/message.js' import type { AgentId } from '../types/ids.js' import type { ToolUseContext } from '../Tool.js' -/** HostHandle 内含的不透明 bundle(核心侧解包后使用)。 */ +/** Opaque bundle held inside HostHandle (unpacked on the core side). */ export type WorkflowHostBundle = { toolUseContext: ToolUseContext canUseTool: CanUseToolFn @@ -17,8 +17,8 @@ export type WorkflowHostBundle = { } /** - * 共享:从 toolUseContext/canUseTool 构造 host bundle。 - * parentMessage 可选(面板启动路径无——claudeCodeBackend 从不读它)。 + * Shared: builds the host bundle from toolUseContext/canUseTool. + * parentMessage is optional (absent on the panel launch path — claudeCodeBackend never reads it). */ export function buildHostBundle( toolUseContext: WorkflowHostBundle['toolUseContext'], diff --git a/src/workflow/namedWorkflowCommands.ts b/src/workflow/namedWorkflowCommands.ts index 053050fc3..9c3f7f879 100644 --- a/src/workflow/namedWorkflowCommands.ts +++ b/src/workflow/namedWorkflowCommands.ts @@ -6,7 +6,7 @@ import { import type { Command } from '../types/command.js' import { getProjectRoot } from '../bootstrap/state.js' -/** 扫描 .claude/workflows/ 下 *.ts|*.js|*.mjs,每个生成一个 / 命令。 */ +/** Scan *.ts|*.js|*.mjs under .claude/workflows/ and generate a / command for each. */ export async function getWorkflowCommands( cwd: string = getProjectRoot(), ): Promise { diff --git a/src/workflow/notifications.ts b/src/workflow/notifications.ts index be7994b81..c53b47a36 100644 --- a/src/workflow/notifications.ts +++ b/src/workflow/notifications.ts @@ -1,14 +1,15 @@ /** - * Workflow 状态变更通知桥接。 + * Bridge for workflow status-change notifications. * - * 引擎通过 progressEmitter.emit({ type: 'run_done', ... }) 发事件, - * progress/store reducer 把状态记到 RunProgress。但旧实现没有任何代码 - * 把状态转换桥接到 host 通知机制——WorkflowTool 返回文本承诺的"完成时 - * 会自动通知"实际落空。 + * The engine emits events via progressEmitter.emit({ type: 'run_done', ... }), + * and the progress/store reducer records the status into RunProgress. But the + * old implementation had no code bridging status transitions to the host + * notification mechanism — the "notifies automatically on completion" promise + * in WorkflowTool's return text went unfulfilled. * - * 本模块订阅 WorkflowService.subscribe,监听 status 从 running → - * completed/failed/killed 的转换,通过注入的 notifier 回调发 host - * notification(默认走 enqueuePendingNotification task-notification mode)。 + * This module subscribes to WorkflowService.subscribe, watches status transitions + * from running → completed/failed/killed, and emits a host notification via the + * injected notifier callback (defaults to enqueuePendingNotification task-notification mode). */ import { STATUS_TAG, @@ -23,7 +24,7 @@ import type { WorkflowService } from './service.js' const WORKFLOW_TASK_TYPE = 'local_workflow' -/** 通知发送器抽象(便于测试注入 spy)。 */ +/** Notifier abstraction (lets tests inject a spy). */ export type WorkflowNotifier = (message: string) => void const TERMINAL_STATUSES: ReadonlySet = new Set([ @@ -32,7 +33,7 @@ const TERMINAL_STATUSES: ReadonlySet = new Set([ 'killed', ]) -/** 默认通知器:走 host message queue 的 task-notification 模式。 */ +/** Default notifier: uses the host message queue's task-notification mode. */ const defaultNotifier: WorkflowNotifier = message => { enqueuePendingNotification({ value: message, mode: 'task-notification' }) } @@ -47,13 +48,13 @@ export function installWorkflowNotifications( const runs = service.listRuns() for (const run of runs) { const prev = prevStatus.get(run.runId) - // 初次见到这个 run:仅记录当前状态,不发通知 - // (避免安装时把已有历史 run 当作新通知触发) + // First time seeing this run: just record the current status without notifying + // (avoids treating existing historical runs as new notifications on install) if (prev === undefined) { prevStatus.set(run.runId, run.status) continue } - // 状态变化 + 进入终态 → 发通知 + // Status changed + entered terminal state → emit notification if (prev !== run.status && TERMINAL_STATUSES.has(run.status)) { notify(buildMessage(run)) } diff --git a/src/workflow/panel/AgentList.tsx b/src/workflow/panel/AgentList.tsx index 20b956454..0df77ba33 100644 --- a/src/workflow/panel/AgentList.tsx +++ b/src/workflow/panel/AgentList.tsx @@ -9,27 +9,27 @@ const FRAME_MS = 120; const LABEL_MAX = 18; /** - * 截断 label 到 max 字符。保留尾部 `#数字` 后缀(audit workflow 的 - * `verify:${dim}#${findingIdx}` 格式)——同 dimension 多 finding 的 verify - * agent label 仍可区分(前缀用 `…` 省略)。无后缀则从右切(旧行为)。 - * 已导出便于单测覆盖。 + * Truncate the label to at most max characters. Preserves the trailing `#number` suffix (the audit workflow + * `verify:${dim}#${findingIdx}` format) - so verify agent labels with multiple findings under the same dimension + * stay distinguishable (the prefix is elided with `…`). When there is no suffix, truncates from the right (legacy behavior). + * Exported for unit test coverage. */ export function truncateLabel(raw: string, max: number): string { if (raw.length <= max) return raw; const m = raw.match(/#\d+$/); if (!m) return raw.slice(0, max); - const suffix = m[0]; // 含 # 号 + const suffix = m[0]; // includes the # sign const prefix = raw.slice(0, raw.length - suffix.length); - const available = max - suffix.length - 1; // -1 留给 … + const available = max - suffix.length - 1; // -1 reserved for … return `${prefix.slice(0, available)}…${suffix}`; } /** - * 右 agent 列表(已按选中 phase 过滤)。 - * 选中行:仅在本列聚焦(focused=true)时铺 selectionBg 底(保留 fg,非反色); - * 焦点不在本列时不铺底色,避免“虚假聚焦”。 - * running agent 的状态符由 useAnimationFrame 驱动 spinner 动画(共享 clock,全局同步); - * 右侧 `model · Nk tok · N tool` 由 agent_progress / agent_done 实时刷新。 + * Right-side agent list (already filtered by the selected phase). + * Selected row: only when this column has focus (focused=true) does it paint a selectionBg background (keeps fg, not inverse color); + * when focus is not on this column it does not paint the background color, to avoid a "fake focus". + * The status mark of a running agent is driven by useAnimationFrame via a spinner animation (shared clock, globally synchronized); + * the right side `model · Nk tok · N tool` is refreshed in real time by agent_progress / agent_done. */ export function AgentList({ agents, @@ -40,7 +40,7 @@ export function AgentList({ selectedIndex: number; focused: boolean; }): React.ReactNode { - // 顶层订阅一次动画帧:所有 running agent 共享同一 frame(同步动画,省去逐行 hook)。 + // Subscribe once to the animation frame at the top level: all running agents share the same frame (synchronized animation, avoids a per-row hook). const [ref, time] = useAnimationFrame(FRAME_MS); const frame = SPINNER_FRAMES[Math.floor(time / FRAME_MS) % SPINNER_FRAMES.length]; diff --git a/src/workflow/panel/PhaseSidebar.tsx b/src/workflow/panel/PhaseSidebar.tsx index 94ec4bc89..d593e8aa8 100644 --- a/src/workflow/panel/PhaseSidebar.tsx +++ b/src/workflow/panel/PhaseSidebar.tsx @@ -16,10 +16,10 @@ type PhaseRow = { }; /** - * 左 phase 侧栏:第一行 All(汇总 done/total),其后 merged phases(含 pending ○)。 - * 选中行:仅在本列聚焦(focused=true)时铺 selectionBg 底(保留 fg,非反色)+ `>` 标记; - * 焦点不在本列时不铺底色,避免“虚假聚焦”。running phase 状态符由 useAnimationFrame 驱动 spinner 动画。 - * 样式对齐参考图:`> ✓ Scan 3/3`。 + * Left phase sidebar: the first row is All (aggregating done/total), followed by the merged phases (including pending ○). + * Selected row: only when this column has focus (focused=true) does it paint a selectionBg background (keeps fg, not inverse color) + a `>` marker; + * when focus is not on this column it does not paint the background color, to avoid a "fake focus". The status mark of a running phase is driven by useAnimationFrame via a spinner animation. + * Style aligns with the reference image: `> ✓ Scan 3/3`. */ export function PhaseSidebar({ phases, diff --git a/src/workflow/panel/TabsBar.tsx b/src/workflow/panel/TabsBar.tsx index 15c650e21..7f570b26d 100644 --- a/src/workflow/panel/TabsBar.tsx +++ b/src/workflow/panel/TabsBar.tsx @@ -6,8 +6,8 @@ import { RUN_STATUS_COLOR, STATUS_DOT } from './status.js'; import { tabLabel } from './selectors.js'; /** - * 顶部 run tab 行:每个 run 一个 tab(状态点 + 名 + #短码)。 - * 当前 tab 用橙色 ═ 下划线高亮。 + * Top run tab row: one tab per run (status dot + name + #short code). + * The current tab is highlighted with an orange ═ underline. */ export function TabsBar({ runs, activeRunId }: { runs: RunProgress[]; activeRunId: string | null }): React.ReactNode { if (runs.length === 0) { diff --git a/src/workflow/panel/WorkflowsPanel.tsx b/src/workflow/panel/WorkflowsPanel.tsx index 6ef6c0ac4..87a8df03f 100644 --- a/src/workflow/panel/WorkflowsPanel.tsx +++ b/src/workflow/panel/WorkflowsPanel.tsx @@ -12,8 +12,8 @@ import { type FocusColumn, type WorkflowKeyboardHandlers, useWorkflowKeyboard } import { ALL_PHASE, filterAgentsByPhase, formatDuration, mergePhases } from './selectors.js'; /** - * 夹紧选中索引到有效区间(空列表→0;越界→末位;负/NaN→0)。 - * 抽成模块级纯函数:面板内调用 + 单测覆盖同一逻辑,避免行为漂移。 + * Clamp the selected index to a valid range (empty list -> 0; out of range -> last position; negative/NaN -> 0). + * Extracted into a module-level pure function: called inside the panel + unit tested for the same logic, to avoid behavior drift. */ export function clampSelected(selected: number, len: number): number { if (len === 0) return 0; @@ -23,13 +23,13 @@ export function clampSelected(selected: number, len: number): number { } /** - * 判断 focused run 是否完成了 running → terminal 的状态转换(用于面板自动退出)。 - * 抽成纯函数便于单测;面板 useEffect 内部直接调用。 + * Determine whether the focused run completed the running -> terminal state transition (used for panel auto-exit). + * Extracted into a pure function for easy unit testing; called directly inside the panel's useEffect. * - * 触发条件:prev 与 curr 是同一 runId,prev 是 running,curr 是 completed/failed/killed。 - * - 打开历史面板(prev=null):不触发 - * - 切到已完成 tab(不同 runId):不触发 - * - 同 run running → terminal:触发 + * Trigger condition: prev and curr are the same runId, prev is running, curr is completed/failed/killed. + * - Opening the history panel (prev=null): does not trigger + * - Switching to an already completed tab (different runId): does not trigger + * - Same run running -> terminal: triggers */ export function isRunTerminatedTransition( prev: { runId: string; status: RunProgress['status'] } | null, @@ -42,11 +42,11 @@ export function isRunTerminatedTransition( } /** - * /workflows 主面板:三区焦点模型(顶 tab + 左 phase 侧栏 + 右 agent 列表)。 + * /workflows main panel: three-region focus model (top tab + left phase sidebar + right agent list). * - * - useSyncExternalStore 订阅 WorkflowService(store 返回稳定快照,无变更不重渲染)。 - * - 焦点状态:activeRunId / focusColumn('phases'|'agents') / selectedPhaseIndex(0=All) / selectedAgentIndex。 - * - 键位:Tab 切 run · ←/→ 切焦点列 · ↑/↓ 列内移动 · x kill · r resume · q/Esc 退出。 + * - useSyncExternalStore subscribes to WorkflowService (the store returns stable snapshots, no re-render without change). + * - Focus state: activeRunId / focusColumn('phases'|'agents') / selectedPhaseIndex(0=All) / selectedAgentIndex. + * - Keybindings: Tab switch run · Left/Right switch focus column · Up/Down move within column · x kill · r resume · q/Esc quit. */ export function WorkflowsPanel({ onDone, @@ -66,17 +66,17 @@ export function WorkflowsPanel({ const [focusColumn, setFocusColumn] = useState('phases'); const [selectedPhaseIndex, setSelectedPhaseIndex] = useState(0); const [selectedAgentIndex, setSelectedAgentIndex] = useState(0); - // kill 二次确认。null = 无弹窗;'workflow' = 杀整个 run;'agent' = 杀当前选中 agent。 - // 非 null 时键盘进入 confirm 模式(仅 y/Enter/n/Esc/q 响应)。 + // kill secondary confirmation. null = no dialog; 'workflow' = kill the whole run; 'agent' = kill the currently selected agent. + // When non-null the keyboard enters confirm mode (only y/Enter/n/Esc/q respond). const [confirmKill, setConfirmKill] = useState(null); - // mount 时触发一次扫盘 hydrate 历史 run(service 内部 persistedLoaded flag 守护幂等)。 - // 重 mount/重渲染不会重复扫盘(flag 进程单例守护)。svc 引用稳定(getWorkflowService 单例)。 + // On mount, trigger a single disk scan to hydrate historical runs (the service's internal persistedLoaded flag guards idempotency). + // Re-mount / re-render does not scan again (guarded by the process-singleton flag). The svc reference is stable (getWorkflowService singleton). useEffect(() => { void svc.loadPersistedRuns(); }, [svc]); - // runs 变化时:activeRunId 失效(被 kill / 首次)→ 夹紧到首个 + // On runs change: activeRunId invalidated (killed / first time) -> clamp to the first one useEffect(() => { if (runs.length === 0) { if (activeRunId !== null) setActiveRunId(null); @@ -89,13 +89,13 @@ export function WorkflowsPanel({ const focused: RunProgress | undefined = runs.find(r => r.runId === activeRunId); const phases = focused ? mergePhases(focused) : []; - // 侧栏含 All 行:phases 数组前补一项 → 总行数 = phases.length + 1 + // The sidebar includes the All row: prepend one item to the phases array -> total rows = phases.length + 1 const phaseRowCount = phases.length + 1; const clampedPhase = clampSelected(selectedPhaseIndex, phaseRowCount); - // focused run 从 running 转 terminal 时自动退出面板(800ms 延迟让用户看到 ✓/✗ 终态)。 - // 仅同 runId 的状态转换触发:切到已完成的 tab(prev 是别的 run)不退出;打开历史面板 - // (prev=null)也不退出。否则 agent 在 Workflow tool 等结果时被面板挡住,用户必须手动 q。 + // Auto-exit the panel when the focused run transitions from running to terminal (800ms delay so the user sees the ✓/✗ terminal state). + // Only triggered by a state transition on the same runId: switching to an already completed tab (prev was a different run) does not exit; opening the history panel + // (prev=null) does not exit either. Otherwise the agent is blocked by the panel while waiting for the Workflow tool result, and the user must press q manually. const prevFocusedRef = useRef<{ runId: string; status: RunProgress['status'] } | null>(null); useEffect(() => { const curr = focused ? { runId: focused.runId, status: focused.status } : null; @@ -108,7 +108,7 @@ export function WorkflowsPanel({ }; }, [focused?.runId, focused?.status, onDone]); - // 选中 phase title(0 = All = undefined) + // Selected phase title (0 = All = undefined) const selectedPhaseTitle = clampedPhase === 0 ? undefined : phases[clampedPhase - 1]?.title; const visibleAgents = focused ? filterAgentsByPhase(focused.agents, selectedPhaseTitle) : []; @@ -148,9 +148,9 @@ export function WorkflowsPanel({ else setSelectedAgentIndex(s => clampSelected(s + 1, visibleAgents.length)); }, killAgent: () => { - // 仅在 agents 列聚焦时弹 agent 确认(在 phases 列按 x 无目标,no-op)。 - // 选中 agent 由 visibleAgents[clampedAgent] 决定;保存到 confirmKill 后由 - // confirmYes 实际执行——避免在两次渲染间 visibleAgents 变化导致误杀。 + // Only pop the agent confirmation when the agents column is focused (pressing x in the phases column has no target, no-op). + // The selected agent is decided by visibleAgents[clampedAgent]; saved into confirmKill and then + // actually executed by confirmYes - to avoid mis-killing caused by visibleAgents changing between two renders. if (focusColumn !== 'agents' || !focused) return; const agent = visibleAgents[clampedAgent]; if (!agent) return; @@ -173,8 +173,8 @@ export function WorkflowsPanel({ }, newRun: () => onDone('Tip: start a named workflow with /, or pass name via the Workflow tool.'), quit: () => { - // confirm 模式下 q = 取消确认(routeWorkflowKey 已路由到 confirmNo); - // 非 confirm 模式才真退出面板。 + // In confirm mode q = cancel confirmation (routeWorkflowKey already routed to confirmNo); + // only in non-confirm mode does it really exit the panel. if (confirmKill !== null) { setConfirmKill(null); return; @@ -184,9 +184,9 @@ export function WorkflowsPanel({ confirmYes: () => { if (confirmKill === 'workflow' && focused) { svc.kill(focused.runId); - // 杀掉整个 workflow 后立即回主 chat:run_done 事件 → store reducer 把 status 改为 - // killed → notifications.ts 桥接 enqueuePendingNotification,主 chat 展示 - // `Workflow "" was stopped`。继续停在面板反而让用户错过"已停止"反馈。 + // After killing the entire workflow, immediately return to the main chat: the run_done event -> the store reducer changes the status to + // killed -> notifications.ts bridges enqueuePendingNotification, and the main chat shows + // `Workflow "" was stopped`. Staying on the panel would instead make the user miss the "stopped" feedback. setConfirmKill(null); onDone(); return; @@ -204,7 +204,7 @@ export function WorkflowsPanel({ const done = runs.length - running; const phaseHeader = selectedPhaseTitle ?? ALL_PHASE; const agentDone = focused ? focused.agents.filter(a => a.status === 'done').length : 0; - // 每秒刷新 header 耗时(共享 clock;订阅即触发重渲染,耗时走墙钟)。 + // Refresh the header duration every second (shared clock; subscribing triggers re-render, duration follows wall clock). const [clockRef] = useAnimationFrame(1000); const elapsed = focused ? Date.now() - focused.startedAt : 0; diff --git a/src/workflow/panel/panelCall.tsx b/src/workflow/panel/panelCall.tsx index 22997f214..bede88318 100644 --- a/src/workflow/panel/panelCall.tsx +++ b/src/workflow/panel/panelCall.tsx @@ -3,11 +3,11 @@ import { SentryErrorBoundary } from '../../components/SentryErrorBoundary.js'; import { WorkflowsPanel } from './WorkflowsPanel.js'; /** - * /workflows 的 local-jsx call:构造面板元素返回给 Ink 渲染。 + * local-jsx call for /workflows: builds the panel element and returns it for Ink to render. * - * 用 SentryErrorBoundary 包裹:useSyncExternalStore / listNamed / 子组件 - * 抛错时不让异常击穿到 REPL 顶层导致整个会话崩溃;boundary 落到本地错误卡片。 - * onDone/context 由命令运行时注入;args 未使用(面板无参数化行为)。 + * Wrapped in SentryErrorBoundary: when useSyncExternalStore / listNamed / child components + * throw, the exception must not break through to the REPL top level and crash the whole session; the boundary falls back to a local error card. + * onDone/context are injected by the command runtime; args is unused (the panel has no parameterized behavior). */ export const call: LocalJSXCommandCall = async (onDone, context, _args) => ( diff --git a/src/workflow/panel/selectors.ts b/src/workflow/panel/selectors.ts index b70af91a3..606dfde81 100644 --- a/src/workflow/panel/selectors.ts +++ b/src/workflow/panel/selectors.ts @@ -1,10 +1,10 @@ import type { AgentProgress, RunProgress } from '../progress/store.js' import type { PhaseStatus } from './status.js' -/** 「不筛选」固定项的 title(侧栏第一行)。 */ +/** Title of the fixed "no filter" item (first row of the sidebar). */ export const ALL_PHASE = 'All' -/** 合并后的 phase(含 pending),带该 phase 下 agent 的 done/total 计数。 */ +/** Merged phase (including pending), with done/total counts of agents under that phase. */ export type MergedPhase = { title: string status: PhaseStatus @@ -13,10 +13,10 @@ export type MergedPhase = { } /** - * 合并 declaredPhases(meta 声明)与 run.phases(实际 running/done): - * - 声明顺序优先;未在 declared 但实际出现的 phase 追加末尾。 - * - 实际无记录 → pending;否则取实际 status。 - * - done/total = 该 phase 下 done / 全部 agent 数。 + * Merge declaredPhases (declared by meta) and run.phases (actually running/done): + * - Declared order takes priority; phases present in actual but not declared are appended at the end. + * - No actual record -> pending; otherwise take the actual status. + * - done/total = done under that phase / total agents under that phase. */ export function mergePhases( run: Pick, @@ -43,8 +43,8 @@ export function mergePhases( } /** - * 按选中 phase 筛选 agent。 - * selectedPhase 为 undefined 或 ALL_PHASE → 全部。 + * Filter agents by the selected phase. + * selectedPhase undefined or ALL_PHASE -> all. */ export function filterAgentsByPhase( agents: AgentProgress[], @@ -54,12 +54,12 @@ export function filterAgentsByPhase( return agents.filter(a => a.phase === selectedPhase) } -/** tab 标签:workflow 名 + `#` + runId 末 4 位(同名 run 消歧)。 */ +/** tab label: workflow name + `#` + last 4 chars of runId (disambiguates same-name runs). */ export function tabLabel(workflowName: string, runId: string): string { return `${workflowName}#${runId.slice(-4)}` } -/** 毫秒 → 紧凑耗时(<60s → `Ns`;<60m → `MmSSs`;否则 `HhMMm`)。面板 header 用。 */ +/** milliseconds -> compact duration (<60s -> `Ns`; <60m -> `MmSSs`; otherwise `HhMMm`). Used by the panel header. */ export function formatDuration(ms: number): string { const s = Math.floor(ms / 1000) if (s < 60) return `${s}s` diff --git a/src/workflow/panel/status.ts b/src/workflow/panel/status.ts index d429f4c37..744c6b162 100644 --- a/src/workflow/panel/status.ts +++ b/src/workflow/panel/status.ts @@ -1,6 +1,6 @@ import type { AgentProgress, RunProgress } from '../progress/store.js' -/** run 状态 → 圆点字符(顶部 tab 用)。 */ +/** run status -> dot character (used by top tab). */ export const STATUS_DOT: Record = { running: '●', completed: '✓', @@ -8,7 +8,7 @@ export const STATUS_DOT: Record = { killed: '■', } -/** run 状态 → ink theme 颜色 token(沿用现有 WorkflowList 配色)。 */ +/** run status -> ink theme color token (follows existing WorkflowList palette). */ export const RUN_STATUS_COLOR: Record = { running: 'warning', completed: 'success', @@ -16,7 +16,7 @@ export const RUN_STATUS_COLOR: Record = { killed: 'subtle', } -/** run 状态 → 展示文字(header 用;对齐参考图 done/running)。 */ +/** run status -> display text (used by header; aligns with reference image done/running). */ export const RUN_STATUS_TEXT: Record = { running: 'running', completed: 'done', @@ -24,7 +24,7 @@ export const RUN_STATUS_TEXT: Record = { killed: 'killed', } -/** phase 在侧栏的合并状态(含 pending:meta 声明但未启动)。 */ +/** merged phase status in the sidebar (includes pending: declared by meta but not started). */ export type PhaseStatus = 'running' | 'done' | 'pending' export const PHASE_MARK: Record = { @@ -39,14 +39,14 @@ export const PHASE_COLOR: Record = { pending: 'subtle', } -/** agent 行的视觉:标记字符 + 颜色(running 由 UI 用 spinner 动画覆盖 mark)。 */ +/** visual for an agent row: mark character + color (running has the mark overridden by a spinner animation in UI). */ export type AgentVisual = { mark: string; color: string } /** - * agent 状态 → 视觉。 - * - running → ● warning(UI 用 spinner 动画覆盖 mark) - * - done·dead → ✗ error - * - done·ok → ✓ success + * agent status -> visual. + * - running -> ● warning (UI overrides mark with spinner animation) + * - done·dead -> ✗ error + * - done·ok -> ✓ success */ export function agentVisual(a: AgentProgress): AgentVisual { if (a.status === 'running') return { mark: '●', color: 'warning' } @@ -54,15 +54,15 @@ export function agentVisual(a: AgentProgress): AgentVisual { return { mark: '✓', color: 'success' } } -/** token 数 → 展示字符串(<1000 原值;否则保留 1 位小数 + k)。 */ +/** token count -> display string (<1000 keeps the raw value; otherwise keeps 1 decimal + k). */ export function formatTokenCount(n: number | undefined): string { if (!n) return '0' return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n) } /** - * agent 行右侧统计文本:`model · Nk tok · N tool`。 - * 无 model 时省略前段;running 中 token/tool 由 agent_progress 实时刷新。 + * right-side stats text for an agent row: `model · Nk tok · N tool`. + * Omits the prefix when there is no model; token/tool refresh in real time via agent_progress while running. */ export function agentMetaText(a: AgentProgress): string { const parts: string[] = [] diff --git a/src/workflow/panel/useWorkflowKeyboard.ts b/src/workflow/panel/useWorkflowKeyboard.ts index 4298a6023..4a91a6d70 100644 --- a/src/workflow/panel/useWorkflowKeyboard.ts +++ b/src/workflow/panel/useWorkflowKeyboard.ts @@ -1,12 +1,12 @@ import { useInput } from '@anthropic/ink' -/** 焦点所在列。 */ +/** The column that currently has focus. */ export type FocusColumn = 'phases' | 'agents' -/** 键盘模式:normal=正常导航;confirm=弹了 Dialog,等用户 y/n 确认。 */ +/** Keyboard mode: normal = regular navigation; confirm = a Dialog is open, waiting for the user's y/n confirmation. */ export type WorkflowKeyboardMode = 'normal' | 'confirm' -/** useInput 的 key 对象子集(仅声明用到的字段,避免耦合 ink Key 类型)。 */ +/** Subset of the useInput key object (only declares the fields we use, to avoid coupling to the ink Key type). */ type KeyEvent = { tab?: boolean shift?: boolean @@ -18,7 +18,7 @@ type KeyEvent = { downArrow?: boolean } -/** 键 → 动作(纯函数,便于单测;无渲染依赖)。 */ +/** key -> action (pure function, easy to unit test; no rendering dependencies). */ export type WorkflowKeyAction = | 'nextTab' | 'prevTab' @@ -39,7 +39,7 @@ export function routeWorkflowKey( key: KeyEvent, mode: WorkflowKeyboardMode = 'normal', ): WorkflowKeyAction | null { - // confirm 模式:仅 y/Enter 确认,n/Esc/q 取消,其他键吞掉(防误触) + // confirm mode: only y/Enter confirms, n/Esc/q cancels, all other keys are swallowed (prevent mis-touch) if (mode === 'confirm') { if (input === 'y' || input === 'Y' || key.return) return 'confirmYes' if (input === 'n' || input === 'N' || key.escape || input === 'q') { @@ -47,11 +47,11 @@ export function routeWorkflowKey( } return null } - // @anthropic/ink 的 key.tab 对 Tab 键置 true;个别环境回落到 '\t' + // @anthropic/ink sets key.tab to true for the Tab key; some environments fall back to '\t' if (key.tab || input === '\t') return key.shift ? 'prevTab' : 'nextTab' if (key.escape || input === 'q') return 'quit' - // 大写 K = 杀整个 workflow;小写 x = 杀当前选中 agent(仅 agents 列)。 - // 大小写区分避免 x 误触发 workflow kill;K 显式需要 Shift 暗示"重操作"。 + // Capital K = kill the entire workflow; lowercase x = kill the currently selected agent (agents column only). + // Case distinction avoids x accidentally triggering workflow kill; K explicitly requires Shift, hinting at a "heavy operation". if (input === 'K') return 'killWorkflow' if (input === 'x') return 'killAgent' if (input === 'r') return 'resume' @@ -63,7 +63,7 @@ export function routeWorkflowKey( return null } -/** 焦点模型回调(WorkflowsPanel 注入)。 */ +/** Focus model callbacks (injected by WorkflowsPanel). */ export type WorkflowKeyboardHandlers = { nextTab: () => void prevTab: () => void @@ -71,27 +71,27 @@ export type WorkflowKeyboardHandlers = { focusRight: () => void moveUp: () => void moveDown: () => void - /** 请求杀当前选中 agent(panel 弹 Dialog 二次确认)。 */ + /** Request killing the currently selected agent (panel pops a Dialog for secondary confirmation). */ killAgent: () => void - /** 请求杀整个 workflow(panel 弹 Dialog 二次确认)。 */ + /** Request killing the entire workflow (panel pops a Dialog for secondary confirmation). */ killWorkflow: () => void resumeFocused: () => void newRun: () => void quit: () => void - /** confirm 模式下用户确认(y/Enter)。 */ + /** User confirms in confirm mode (y/Enter). */ confirmYes: () => void - /** confirm 模式下用户取消(n/Esc/q)。 */ + /** User cancels in confirm mode (n/Esc/q). */ confirmNo: () => void } /** - * /workflows 面板键位(焦点轮转模型): - * - Tab / Shift+Tab:切顶部 run tab - * - ← / →:phases ↔ agents 焦点切换 - * - ↑ / ↓:当前焦点列内移动 - * - x kill 单 agent · K kill 整个 workflow(带 Dialog 二次确认) · r resume · n new · q / Esc quit + * /workflows panel keybindings (focus rotation model): + * - Tab / Shift+Tab: switch the top run tab + * - Left / Right: switch focus between phases and agents + * - Up / Down: move within the currently focused column + * - x kill single agent · K kill the entire workflow (with Dialog secondary confirmation) · r resume · n new · q / Esc quit * - * @param mode confirm 时只接受 y/n/Esc/q,其他键吞掉——避免在确认弹窗里误导航。 + * @param mode In confirm mode only y/n/Esc/q are accepted, all other keys are swallowed - avoid mis-navigation inside the confirmation dialog. */ export function useWorkflowKeyboard( h: WorkflowKeyboardHandlers, diff --git a/src/workflow/persistence.ts b/src/workflow/persistence.ts index 5925302b4..b01a81363 100644 --- a/src/workflow/persistence.ts +++ b/src/workflow/persistence.ts @@ -5,15 +5,15 @@ import { logForDebugging } from '../utils/debug.js' import type { ProgressBus } from './progress/bus.js' import type { ProgressStore, RunProgress } from './progress/store.js' -/** state.json 当前 schema 版本;升级时引入迁移链。 */ +/** Current schema version of state.json; introduces a migration chain on upgrade. */ const SCHEMA_VERSION = 1 const STATE_FILE = 'state.json' const STATE_TMP = 'state.json.tmp' /** - * runsDir 统一来源:与 ports.ts journalStore 同根(${projectRoot}/.claude/workflow-runs)。 - * 提取为函数:消除 ports.ts 与持久化逻辑的路径拼接重复,进入 worktree/子目录时保持同根。 - * 测试用 monkey-patch 本函数指向 tmpdir。 + * Single source for runsDir: shares the same root as ports.ts journalStore (${projectRoot}/.claude/workflow-runs). + * Extracted as a function: eliminates duplicated path concatenation between ports.ts and persistence logic, staying in the same root when entering worktree/subdirectory. + * Tests monkey-patch this function to point at a tmpdir. */ export function getRunsDir(): string { return join(getProjectRoot(), '.claude', 'workflow-runs') @@ -25,9 +25,9 @@ type StateFile = { } /** - * 原子覆盖写终态 RunProgress 到 //state.json。 - * 原子性:writeFile(tmp) → rename(tmp, target),rename 原子;最坏留 tmp,下次写覆盖。 - * 失败 best-effort:IO 异常只 log warn,不抛(workflow 已成功,持久化失败只意味着重启后取不到)。 + * Atomically overwrite the terminal RunProgress to //state.json. + * Atomicity: writeFile(tmp) → rename(tmp, target), rename is atomic; worst case leaves tmp, next write overwrites it. + * Failure is best-effort: IO exceptions only log a warn, do not throw (workflow already succeeded; persistence failure only means it cannot be retrieved after restart). */ export async function writeRunState( runsDir: string, @@ -49,9 +49,9 @@ export async function writeRunState( } /** - * 读 //state.json,容错: - * - 文件不存在 → null(调用方按 miss 处理) - * - JSON 解析失败 / schema 结构不符 / schemaVersion 不符 → null(log warn,不崩) + * Read //state.json with fault tolerance: + * - File does not exist → null (caller treats it as a miss) + * - JSON parse failure / schema structure mismatch / schemaVersion mismatch → null (log warn, do not crash) */ export async function readRunState( runsDir: string, @@ -81,11 +81,11 @@ export async function readRunState( } /** - * 扫描 runsDir 下所有子目录,读取每个 state.json,返回非空 RunProgress 列表。 - * - runsDir 不存在 → 空数组 - * - 某子目录无 state.json(半残 run)→ 跳过 - * - 某子目录 state.json 损坏 → 跳过该单个,继续扫其余 - * - 按 updatedAt 降序(与 store.list() 排序一致) + * Scan all subdirectories under runsDir, read each state.json, return a list of non-null RunProgress. + * - runsDir does not exist → empty array + * - A subdirectory without state.json (half-written run) → skip + * - A subdirectory whose state.json is corrupted → skip that single one, keep scanning the rest + * - Sort by updatedAt descending (consistent with store.list() ordering) */ export async function listPersistedRuns( runsDir: string, @@ -105,17 +105,17 @@ export async function listPersistedRuns( } /** - * 订阅 bus 的 run_done 事件,把终态 RunProgress 写到磁盘 state.json。 - * 覆盖 completed/failed/killed 三态(shutdown-kill 也走 run_done killed)。 - * store 先于本订阅注册到 bus,故 listener 执行时 store.get(runId) 已是终态。 - * 返回 unsubscribe 函数(测试清理用)。 + * Subscribe to the bus's run_done event and write the terminal RunProgress to state.json on disk. + * Covers all three terminal states (completed/failed/killed; shutdown-kill also routes to run_done killed). + * The store registers to the bus before this subscription, so when the listener runs store.get(runId) is already terminal. + * Returns an unsubscribe function (for test cleanup). * - * 写盘 best-effort:writeRunState 内部吞 IO 异常只 log,不传播—— - * 因此 bus 的其他订阅者(store 等)不受持久化失败影响。 + * Disk write is best-effort: writeRunState swallows IO exceptions and only logs, does not propagate — + * so other bus subscribers (store, etc.) are not affected by persistence failures. * - * @param runsDirProvider 可选的 runsDir 解析器(默认 getRunsDir)。 - * 生产路径走默认值;测试注入 tmpdir 避免写真实项目目录(Bun ESM 模块命名空间只读, - * 无法 monkey-patch getRunsDir 本身)。 + * @param runsDirProvider Optional runsDir resolver (defaults to getRunsDir). + * Production path uses the default; tests inject a tmpdir to avoid writing to the real project directory (Bun ESM module namespace is read-only, + * cannot monkey-patch getRunsDir itself). */ export function attachRunStatePersistence( bus: ProgressBus, diff --git a/src/workflow/ports.ts b/src/workflow/ports.ts index bfeda9049..eea1ac846 100644 --- a/src/workflow/ports.ts +++ b/src/workflow/ports.ts @@ -34,11 +34,11 @@ type RunBinding = { setAppState: SetAppState abortController: AbortController workflowName: string - /** agentId → AbortController。backend 启动 agent 时注册;killAgent 据此精确中断。 */ + /** agentId → AbortController. Registered when backend starts an agent; killAgent uses it for precise abort. */ agentAbortControllers: Map } -/** 每次工具调用从 toolUseContext 构造 WorkflowHostContext。 */ +/** Constructs a WorkflowHostContext from toolUseContext on each tool invocation. */ function makeHostFactory(): WorkflowPorts['hostFactory'] { return ({ context, canUseTool, parentMessage }) => { const ctx = context as WorkflowHostBundle['toolUseContext'] & { @@ -52,20 +52,21 @@ function makeHostFactory(): WorkflowPorts['hostFactory'] { parentMessage as AssistantMessage | undefined, ), ), - // 用 projectRoot 而非 getCwd():与 journalStore 的 runsDir 同根, - // 否则用户进入 worktree/子目录时命名 workflow 解析与 journal 落盘不同步。 - // 引擎内部 ctx.cwd 仅用于解析(scriptPath/name),不影响 agent 执行 cwd - // (agent 通过 host bundle 内的 toolUseContext 拿到自己的 cwd)。 + // Use projectRoot rather than getCwd(): shares the same root as journalStore's runsDir, + // otherwise named workflow resolution and journal persistence diverge when the user + // enters a worktree/sub-directory. The engine's internal ctx.cwd is only used for + // resolution (scriptPath/name) and does not affect the agent's execution cwd + // (the agent gets its own cwd via the toolUseContext inside the host bundle). cwd: getProjectRoot(), - budgetTotal: null, // turn 级预算注入点(未来从 settings 读) + budgetTotal: null, // turn-level budget injection point (read from settings in the future) ...(ctx.toolUseId ? { toolUseId: ctx.toolUseId } : {}), } } } /** - * 组装完整 WorkflowPorts。bus/store 由调用方传入(service 单例共享)。 - * taskRegistrar 维护 runId → RunBinding 供 kill 路由。 + * Assembles the complete WorkflowPorts. bus/store are passed in by the caller (shared via the service singleton). + * taskRegistrar maintains runId → RunBinding for kill routing. */ export function createWorkflowPorts(opts: { bus: ProgressBus @@ -75,8 +76,8 @@ export function createWorkflowPorts(opts: { const runsDir = getRunsDir() const registry = buildRegistry() - // 遥测订阅(独立于 store)。LogEventMetadata 只接受 boolean/number/undefined, - // runId 为字符串——用 analytics 模块自带的 brand cast(已验证非代码/路径)放行。 + // Telemetry subscription (independent of store). LogEventMetadata only accepts boolean/number/undefined, + // and runId is a string — use the brand cast provided by the analytics module (verified non-code/path) to pass it through. opts.bus.subscribe((e: ProgressEvent) => { if (e.type === 'run_done') { logEvent('tengu_workflow_done', { @@ -133,13 +134,13 @@ export function createWorkflowPorts(opts: { kill(runId) { const b = bindings.get(runId) if (!b) return - killWorkflowTask(b.taskId, b.setAppState) // 内部 abort controller - // 杀 run 同时中断所有 in-flight agent(防止 backend 没接到 task abort 的极端时序) + killWorkflowTask(b.taskId, b.setAppState) // internal abort controller + // Killing the run also aborts all in-flight agents (guards against the edge timing where the backend misses the task abort) for (const ac of b.agentAbortControllers.values()) { try { ac.abort() } catch { - // no-op:abort 内部不会抛,但 fail-closed + // no-op: abort won't throw internally, but fail-closed } } b.agentAbortControllers.clear() @@ -169,7 +170,7 @@ export function createWorkflowPorts(opts: { return true }, pendingAction() { - return null // v1:skip/retry 不接线(seam 保留) + return null // v1: skip/retry not wired (seam retained) }, } @@ -177,7 +178,7 @@ export function createWorkflowPorts(opts: { hostFactory: makeHostFactory(), agentAdapterRegistry: registry, agentRunner: { - // 死代码兜底:hooks 始终走 agentAdapterRegistry(ports 必设)。若到此说明 registry 未注册——fail-fast。 + // Dead-code fallback: hooks always go through agentAdapterRegistry (required on ports). Reaching here means the registry was not registered — fail-fast. async runAgentToResult() { throw new Error( 'workflow agentRunner fallback reached — agentAdapterRegistry must be set on ports', @@ -186,12 +187,12 @@ export function createWorkflowPorts(opts: { }, progressEmitter: { emit(event) { - opts.bus.emit(event) // → store reducer + 遥测 + opts.bus.emit(event) // → store reducer + telemetry }, }, taskRegistrar, journalStore: createFileJournalStore(runsDir), - permissionGate: { isAborted: () => false }, // 引擎用 ctx.signal 判 abort + permissionGate: { isAborted: () => false }, // engine uses ctx.signal to check abort logger: { debug: msg => logForDebugging(msg), warn: msg => logForDebugging(`[workflow warn] ${msg}`), diff --git a/src/workflow/progress/bus.ts b/src/workflow/progress/bus.ts index 92d760cf0..9e3f43d33 100644 --- a/src/workflow/progress/bus.ts +++ b/src/workflow/progress/bus.ts @@ -1,6 +1,6 @@ import type { ProgressEvent } from '@claude-code-best/workflow-engine' -/** 类型化进度事件总线。引擎 progressEmitter.emit → 广播给所有订阅者(store / 遥测)。 */ +/** Typed progress event bus. engine progressEmitter.emit -> broadcasts to all subscribers (store / telemetry). */ export type ProgressBus = { emit(event: ProgressEvent): void subscribe(listener: (event: ProgressEvent) => void): () => void diff --git a/src/workflow/progress/store.ts b/src/workflow/progress/store.ts index e056092ea..b66b368e2 100644 --- a/src/workflow/progress/store.ts +++ b/src/workflow/progress/store.ts @@ -2,19 +2,19 @@ import type { ProgressEvent } from '@claude-code-best/workflow-engine' import type { ProgressBus } from './bus.js' export type AgentProgress = { - /** 引擎盖戳的唯一 id,精确关联 started/done(修旧 LIFO 竞态)。 */ + /** Unique id stamped by the engine, precisely correlates started/done (fixes the old LIFO race condition). */ id: number label?: string phase?: string status: 'running' | 'done' resultKind?: string - /** 仅 done·ok 时有意义:output 是对象→'object',否则→'text'。dead/skipped 无。 */ + /** Only meaningful when done·ok: output is an object -> 'object', otherwise -> 'text'. None for dead/skipped. */ outputShape?: 'text' | 'object' - /** 实际解析后的 model id(agent_done 带入;运行中无)。 */ + /** Actually parsed model id (carried in by agent_done; none while running). */ model?: string - /** context 总 token(agent_progress 实时 / agent_done 落地最终值)。 */ + /** Cumulative context tokens (live via agent_progress / final value settled by agent_done). */ tokenCount?: number - /** 累计工具调用次数(agent_progress 实时 / agent_done 落地最终值)。 */ + /** Cumulative tool-call count (live via agent_progress / final value settled by agent_done). */ toolCount?: number } @@ -23,16 +23,16 @@ export type RunProgress = { workflowName: string status: 'running' | 'completed' | 'failed' | 'killed' phases: Array<{ title: string; status: 'running' | 'done' }> - /** 来自 run_started.meta.phases[].title;面板据此显示 pending(○) phase。无 meta → []。 */ + /** From run_started.meta.phases[].title; the panel uses this to show pending(○) phases. [] when no meta. */ declaredPhases: string[] currentPhase: string | null agents: AgentProgress[] agentCount: number returnValue?: unknown error?: string - /** run_started 时间戳(面板算运行耗时用)。 */ + /** run_started timestamp (used by the panel to compute run duration). */ startedAt: number - /** workflow 描述(来自 run_started.meta.description)。 */ + /** workflow description (from run_started.meta.description). */ description?: string updatedAt: number } @@ -41,14 +41,14 @@ export type ProgressStore = { apply(event: ProgressEvent): void list(): RunProgress[] get(runId: string): RunProgress | undefined - /** 直接注入磁盘读出的 run(绕过 bus);已存在的 runId 跳过——内存优先。 */ + /** Directly inject a run read from disk (bypassing bus); skips existing runId - in-memory takes priority. */ hydrate(run: RunProgress): void - /** 供 useSyncExternalStore:返回稳定引用,无变更时同一数组。 */ + /** For useSyncExternalStore: returns a stable reference, the same array when no change. */ subscribe(listener: () => void): () => void getSnapshot(): RunProgress[] } -/** 从 bus 构造 reactive store:订阅 bus,归约事件,通知 React 订阅者。 */ +/** Build a reactive store from the bus: subscribe to the bus, reduce events, notify React subscribers. */ export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore { const byId = new Map() let snapshot: RunProgress[] = [] @@ -80,7 +80,7 @@ export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore { } const apply = (event: ProgressEvent): void => { - // log 不产生可见状态变更(面板无日志视图):早退,避免无谓的快照重建与 React 重渲染 + // log produces no visible state change (panel has no log view): early exit to avoid pointless snapshot rebuild and React re-render if (event.type === 'log') return const runId = event.runId const p = ensure( @@ -125,7 +125,7 @@ export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore { break } case 'agent_progress': { - // 实时进度:仅更新 token/tool(高频,但每 agent message 一次,频率可控)。 + // live progress: only update token/tool (high frequency, but once per agent message, frequency is controllable). const ap = p.agents.find(x => x.id === event.agentId) if (ap) { ap.tokenCount = event.tokenCount diff --git a/src/workflow/registry.ts b/src/workflow/registry.ts index e7f91fa7f..778290b3a 100644 --- a/src/workflow/registry.ts +++ b/src/workflow/registry.ts @@ -2,8 +2,9 @@ import { AgentAdapterRegistry } from '@claude-code-best/workflow-engine' import { claudeCodeBackend } from './backends/claudeCodeBackend.js' /** - * 构建多后端 registry。v1(depth B)只注册单一 claude-code adapter 为默认, - * 不预填路由规则——扩第二个 provider adapter 时再补 .route(...)。 + * Build a multi-backend registry. v1 (depth B) only registers a single + * claude-code adapter as default, without prefilling routing rules — add + * .route(...) when extending with a second provider adapter. */ export function buildRegistry(): AgentAdapterRegistry { const reg = new AgentAdapterRegistry() diff --git a/src/workflow/service.ts b/src/workflow/service.ts index 3e473dd7d..19fd6c4cd 100644 --- a/src/workflow/service.ts +++ b/src/workflow/service.ts @@ -32,17 +32,17 @@ import type { CanUseToolFn } from '../hooks/useCanUseTool.js' import type { ToolUseContext } from '../Tool.js' /** - * WorkflowService:工具(U7)与面板(U9)共享的唯一入口。 + * WorkflowService: the single entry shared by the tool (U7) and panel (U9). * - * - `ports`:共享的 WorkflowPorts,工具描述符透传给引擎。 - * - `launch`:解析脚本 → parseScript 快速校验 → taskRegistrar.register(拿 runId+signal) - * → detached runWorkflow → 结束后 complete/fail/kill。 - * - `kill/listRuns/getRun/subscribe/listNamed`:面板与工具的辅助查询。 + * - `ports`: shared WorkflowPorts; tool descriptors are passed through to the engine. + * - `launch`: parse script → parseScript quick validation → taskRegistrar.register (gets runId+signal) + * → detached runWorkflow → on completion routes to complete/fail/kill. + * - `kill/listRuns/getRun/subscribe/listNamed`: auxiliary queries for panel and tool. */ export type WorkflowService = { - /** 共享端口(工具描述符用)。 */ + /** Shared ports (used by tool descriptors). */ ports: WorkflowPorts - /** 面板/工具启动 workflow:解析脚本 → register → detached runWorkflow。 */ + /** Panel/tool launches a workflow: parse script → register → detached runWorkflow. */ launch( input: Pick< WorkflowInput, @@ -60,25 +60,25 @@ export type WorkflowService = { ): Promise<{ runId: string; scriptPath?: string }> kill(runId: string): void /** - * 中断单个 agent(不影响同 run 其他 agent,workflow 继续跑)。 - * 返回是否命中(false = agent 已完成/不存在)。agent 被 abort 后返回 dead → null。 + * Aborts a single agent (does not affect other agents in the same run; workflow keeps running). + * Returns whether the agent was hit (false = agent already finished/does not exist). An aborted agent returns dead → null. */ killAgent(runId: string, agentId: number): boolean /** - * 进程退出 / 配置卸载时清理:杀掉所有 running run,避免孤儿 task。 - * 已完成/失败的 run 不受影响。幂等——多次调用安全。 + * Cleanup on process exit / config unload: kill all running runs to avoid orphan tasks. + * Completed/failed runs are unaffected. Idempotent — safe to call multiple times. */ shutdown(): void listRuns(): RunProgress[] getRun(runId: string): RunProgress | undefined /** - * 异步按 runId 查:内存命中则返回;miss 读盘 state.json(不注入内存)。 - * 供"按 runId 取历史 return"场景;面板展示请走 loadPersistedRuns + listRuns。 + * Async lookup by runId: return on memory hit; on miss read state.json from disk (not injected into memory). + * Used by the "get historical return by runId" scenario; for panel display use loadPersistedRuns + listRuns. */ getRunAsync(runId: string): Promise /** - * 扫盘把所有历史 run 的 state.json hydrate 进 store(已存在 runId 跳过)。 - * 进程单例内仅实际扫盘一次(persistedLoaded flag);重复调用立即返回。 + * Scans the disk and hydrates state.json of all historical runs into the store (skips existing runIds). + * The process singleton only scans the disk once (persistedLoaded flag); repeated calls return immediately. */ loadPersistedRuns(): Promise subscribe(listener: () => void): () => void @@ -87,30 +87,30 @@ export type WorkflowService = { let cached: WorkflowService | null = null -/** 进程单例。工具与面板共享同一 ports/registry/store。 */ +/** Process singleton. Tool and panel share the same ports/registry/store. */ export function getWorkflowService(): WorkflowService { if (cached) return cached const bus = createProgressBus() const store = createProgressStoreFromBus(bus) const ports = createWorkflowPorts({ bus, store }) const service = makeService(ports, store) - // 订阅 run_done 写终态快照到磁盘(completed/failed/killed 三态共用入口,shutdown-kill 也走此路径)。 - // store 先于本订阅注册到 bus,故 listener 执行时 store.get(runId) 已是终态。 + // Subscribe to run_done to write the terminal snapshot to disk (shared entry for completed/failed/killed; shutdown-kill also routes here). + // The store registers to the bus before this subscription, so when the listener runs store.get(runId) is already terminal. attachRunStatePersistence(bus, store) - // 安装状态变更通知桥接(commit 0768d4dc 承诺但旧实现落空的"完成时自动通知") + // Install the state-change notification bridge (commit 0768d4dc promised "auto-notify on completion" but the old implementation left it unfulfilled) installWorkflowNotifications(service) cached = service return cached } /** - * 构造 service(注入 ports + store)。 + * Construct the service (inject ports + store). * - * 生产路径用 {@link getWorkflowService};测试用本函数直接注入 fake ports, - * 避免触碰真实的 getProjectRoot/getCwd/analytics 等模块级副作用。 + * Production path uses {@link getWorkflowService}; tests use this function to inject fake ports directly, + * avoiding touching real getProjectRoot/getCwd/analytics and other module-level side effects. * - * @param cwdOverride 仅供测试注入临时目录(避免 inline 持久化写真实项目目录)。 - * @param runsDirProvider 仅供测试注入 tmpdir(Bun ESM 模块命名空间只读,无法 monkey-patch getRunsDir)。 + * @param cwdOverride For tests only: inject a temp directory (avoids inline persistence writing to the real project directory). + * @param runsDirProvider For tests only: inject a tmpdir (Bun ESM module namespace is read-only, cannot monkey-patch getRunsDir). */ export function makeService( ports: WorkflowPorts, @@ -123,11 +123,11 @@ export function makeService( canUseTool: CanUseToolFn, ): WorkflowHostContext => ({ handle: makeHostHandle(buildHostBundle(toolUseContext, canUseTool)), - // 用 projectRoot 与 ports.ts hostFactory / journalStore 保持同根; - // 进入 worktree/子目录时不会让命名 workflow 解析与 journal 落盘不同步。 - // cwdOverride 仅供测试注入临时目录(避免 inline 持久化写真实项目目录)。 + // Use projectRoot to stay in sync with ports.ts hostFactory / journalStore; + // entering a worktree/subdirectory will not desync named workflow resolution from journal persistence. + // cwdOverride is for tests only: inject a temp directory (avoids inline persistence writing to the real project directory). cwd: cwdOverride ?? getProjectRoot(), - budgetTotal: null, // turn 级预算注入点(未来从 settings 读) + budgetTotal: null, // turn-level budget injection point (in future read from settings) toolUseId: toolUseContext.toolUseId, }) @@ -155,7 +155,7 @@ export function makeService( const found = await resolveNamedWorkflow(dir, input.name) if (!found) { throw new Error( - `命名 workflow "${input.name}" 未找到(查找 ${WORKFLOW_DIR_NAME}/)`, + `Named workflow "${input.name}" not found (looked in ${WORKFLOW_DIR_NAME}/)`, ) } return { @@ -164,11 +164,11 @@ export function makeService( workflowName: input.name, } } - throw new Error('必须提供 script、name 或 scriptPath 之一') + throw new Error('One of script, name, or scriptPath must be provided') } - // loadPersistedRuns 的进程单例 flag:首次调用后置 true,后续重复调用立即返回。 - // 扫盘失败时复位允许下次重试。每个 makeService 调用独立闭包变量(测试构造新 service 时重置)。 + // Process-singleton flag for loadPersistedRuns: set to true on first call, subsequent calls return immediately. + // Reset on scan failure to allow next retry. Each makeService call has its own closure variable (reset when tests build a new service). let persistedLoaded = false return { @@ -179,7 +179,7 @@ export function makeService( try { parseScript(script) } catch (e) { - throw new Error(`脚本校验失败:${(e as Error).message}`) + throw new Error(`Script validation failed: ${(e as Error).message}`) } const host = buildHost(toolUseContext, canUseTool) @@ -194,8 +194,8 @@ export function makeService( host.handle, ) - // inline 入口持久化脚本到 run 目录(与 WorkflowTool 对称),返回可复用路径。 - // 写盘失败降级(log),不阻断 run(script 已在内存)。 + // Inline entry: persist script to the run directory (symmetric with WorkflowTool), return a reusable path. + // Degrade on write failure (log), do not block the run (script is already in memory). let persistedScriptPath: string | undefined if (!workflowFile && input.script) { try { @@ -211,7 +211,7 @@ export function makeService( } } - // detached:不 await,让调用方立即拿到 runId;结束路由到 registrar。 + // detached: do not await, let the caller get runId immediately; on completion route to the registrar. void runWorkflow({ script, ...(input.args !== undefined ? { args: input.args } : {}), @@ -253,10 +253,10 @@ export function makeService( }, shutdown() { - // 仅杀 running:已完成/失败的 run taskRegistrar 已回收 binding,kill 是 no-op。 - // taskRegistrar.kill 对未知 runId 安全 no-op,因此幂等——多次 shutdown 不重复抛错。 - // 每个 kill 单独 try/catch:kill 内部走 setAppState,进程 exit 阶段触发 React 重渲染 - // 可能抛错(render 已卸载等);单个失败不应阻断其他 run 的清理。 + // Only kill running: for completed/failed runs the taskRegistrar has already reclaimed the binding, kill is a no-op. + // taskRegistrar.kill is a safe no-op for unknown runIds, hence idempotent — multiple shutdowns do not throw repeatedly. + // Each kill is wrapped in its own try/catch: kill internally routes through setAppState, and process-exit phase triggers a React re-render + // which may throw (render already unmounted, etc.); a single failure should not block cleanup of other runs. for (const run of store.list()) { if (run.status !== 'running') continue try { @@ -283,7 +283,7 @@ export function makeService( const runs = await listPersistedRuns(runsDirProvider()) for (const run of runs) store.hydrate(run) } catch (e) { - // 扫盘失败不阻断面板:log + 复位 flag 允许下次重试 + // Scan failure does not block the panel: log + reset flag to allow next retry logForDebugging( `[workflow warn] loadPersistedRuns failed: ${(e as Error).message}`, ) @@ -300,14 +300,14 @@ export function makeService( } } -/** 测试用:重置单例(避免跨用例污染)。 */ +/** For tests: reset the singleton (avoid cross-case contamination). */ export function __resetWorkflowServiceForTests(): void { cached = null } /** - * 返回已实例化的 service(不创建)。进程退出 / 配置卸载时用本函数 peek, - * 没用过 workflow 则 cached 仍为 null——避免在 exit hook 里副作用地创建 bus/ports。 + * Returns the already-instantiated service (does not create one). Used on process exit / config unload to peek; + * if workflow was never used, cached is still null — avoids side-effecting bus/ports creation in the exit hook. */ export function peekWorkflowService(): WorkflowService | null { return cached diff --git a/src/workflow/wiring.ts b/src/workflow/wiring.ts index 82f179ade..aaf1c51f1 100644 --- a/src/workflow/wiring.ts +++ b/src/workflow/wiring.ts @@ -8,14 +8,15 @@ import { buildTool, type Tool } from '../Tool.js' import { getWorkflowService } from './service.js' /** - * 把引擎自包含描述符适配为 buildTool 兼容的 Tool。 - * 描述符统一走 service 单例(共享 ports/registry/store)。 + * Adapts the engine's self-contained descriptor into a buildTool-compatible Tool. + * The descriptor routes through the service singleton (sharing ports/registry/store). * - * ports 解析延迟到首次实际方法调用(lazy):tools.ts 在模块加载阶段(feature-gated) - * 调用 createWorkflowToolCore(),若此时立即解析 ports 会触发 service 实例化, - * 进而调用 getProjectRoot 等模块级副作用——这在 bootstrap 完成前可能拿到错误路径。 - * Tool 对象本身的单例由 createWorkflowToolCore 的 cached 保证(PermissionRequest - * 按引用匹配),ports 单例由 getWorkflowService 保证。 + * ports resolution is deferred to the first real method call (lazy): tools.ts calls + * createWorkflowToolCore() during module-load (feature-gated), and resolving ports + * immediately would trigger service instantiation, which in turn calls module-level + * side effects like getProjectRoot — yielding wrong paths before bootstrap completes. + * The Tool object itself is a singleton via createWorkflowToolCore's cached (PermissionRequest + * matches by reference), and the ports singleton is guaranteed by getWorkflowService. */ function buildWorkflowTool(): Tool { let cachedDescriptor: WorkflowToolDescriptor | null = null @@ -55,7 +56,7 @@ function buildWorkflowTool(): Tool { }) } -// 单例:tools.ts 注册与 PermissionRequest 引用需为同一实例(switch 按引用匹配)。 +// Singleton: tools.ts registration and PermissionRequest must reference the same instance (switch matches by reference). let cached: Tool | null = null export function createWorkflowToolCore(): Tool {