chore(workflow): 工作流相关代码中文文案全部英文化

源码(src/workflow/ + packages/workflow-engine/src/)的中文注释、
用户可见错误消息、字符串字面量;测试文件的标题与注释;同步 6 条
硬编码断言到英文化后的错误消息。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-14 15:48:29 +08:00
parent 490714dbcb
commit 4903f544b7
71 changed files with 1091 additions and 1077 deletions

View File

@@ -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/<name>.ts', async () => {
test('name resolves to .claude/workflows/<name>.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<string, string>()
@@ -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 === undefinedstring 上无 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 = subDirscriptPath 是 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 为对象 → completeformatValue 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(

View File

@@ -36,7 +36,7 @@ const CTX = {
agentId: 1,
}
test('resolve 默认走 default adapterrun 返回结果', 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 adapterrun 返回结果', 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 },

View File

@@ -46,7 +46,7 @@ function build(results: Map<string, AgentRunResult>) {
return { ctx, events, hooks: makeHooks(ctx, async () => null) }
}
test('并发 agent 各自拿到唯一 agentIdstarted/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 各自拿到唯一 agentIdstarted/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,

View File

@@ -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)

View File

@@ -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('clampMaxConcurrencyundefined/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('clampMaxConcurrencyundefined/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 permitacquire 不阻塞', 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 () => {
// 修复 Lqueued 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()
})

View File

@@ -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('createSharedResourcesmaxConcurrency 控制 semaphore permits', async () => {
// 默认 permits = DEFAULT_MAX_CONCURRENCY = 34 次 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('createSharedResourcesmaxConcurrency 控制 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('createSharedResourcesmaxConcurrency 控制 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')

View File

@@ -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()
}

View File

@@ -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')
})

View File

@@ -24,8 +24,8 @@ type CtxOverrides = Partial<{
truncated: string[]
agentAdapterRegistry: AgentAdapterRegistry
loggerWarn: (msg: string) => void
// taskRegistrar agent abort 绑定(agent kill 桥接)。
// 提供后 buildCtx 注入到 ports.taskRegistrarhooks.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 都给一次重试机会;WorkflowAbortedErrorkill)不重试。
// 重试仍失败dead 保持 deadthrow 降级为 dead不击穿 workflowhooks.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 → 最终 nulldead 保持 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 → 最终 nulldead 保持 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 throwsdegrade 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 → 不重试,直接 rethrowkill 不容许重试)', 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 → 不重试,直接 rethrowkill 不
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/donelog 发射 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/donelog 发射 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 会排队。
// bugassertCanSpend acquire 之前,所有 waiter 入队时 spent=0 都过检;
// 后续 permit 释放后 waiter 直接跑 runner、扣预算不再 re-check → 全部超支。
// 修复:assertCanSpend 移入临界区waiter 被唤醒后先看 spent 再决定是否跑。
// 强制 capacity=1serializing 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 token2 次即超 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 成 nullassertCanSpend 抛错)
// 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 塞进 Mapservice.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({

View File

@@ -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: () => {} },

View File

@@ -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<AgentRunResult> => {
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 runIdjournal 命中,不重跑
// resume same runId: journal hit, no re-run
calls = 0
const resumed = await runWorkflow({
script,

View File

@@ -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)

View File

@@ -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')

View File

@@ -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()
})

View File

@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { persistInlineScript } from '../tool/persistInline.js'
test('持久化到 <cwd>/.claude/workflow-runs/<runId>/script.js 并返回路径', async () => {
test('persists to <cwd>/.claude/workflow-runs/<runId>/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('持久化到 <cwd>/.claude/workflow-runs/<runId>/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)

View File

@@ -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()

View File

@@ -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('resumejournal 命中则不调用 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 新 entrylive
// 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_donehook.phase 只在切换时 emit 上一个的 done
// 最后一个 phase 无后续切换 → UI 左栏会永远显示 running。验证三路径都补发。
test('终态前补发 currentPhase 的 phase_donecompleted 路径)', 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_donecompleted 路径)', 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_donecompleted 路径)', async
}
})
test('终态前补发 currentPhase 的 phase_donekilled 路径)', 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_donekilled 路径)', async ()
}
})
test(' phase() 调用 → 终态不补发 phase_donecurrentPhase 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_donecurrentPhase 为 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_donecurrentPhase 为 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 })
}

View File

@@ -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('未知字段被 stripzod 默认非 strictsafeParse 成功)', () => {
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('maxConcurrency116 整数合法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('maxConcurrency116 整数合法0/17/小数/非数字被拒', () =>
}
})
test('maxConcurrency optional(省略时 safeParse 成功)', () => {
test('maxConcurrency optional (safeParse succeeds when omitted)', () => {
expect(workflowInputSchema.safeParse({ script: 'x' }).success).toBe(true)
})

View File

@@ -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`,
)

View File

@@ -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,
)

View File

@@ -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/detailjournal 持久化后能保留死因,事后审计/面板展示用。
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')
})
// 兼容旧 journalreason/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 } },

View File

@@ -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 级 AbortControlleragent 完成或失败时调;幂等)。
* 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<AgentRunResult>
/** 初始化(由 registry.initializeAll 触发)。 */
/** Initialize (triggered by registry.initializeAll). */
initialize?(): Promise<void>
/** 销毁(由 registry.disposeAll 触发)。 */
/** Dispose (triggered by registry.disposeAll). */
dispose?(): Promise<void>
}
/** 路由规则:决定哪些 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 {
}
/**
* 多后端 registryregister 注册 adapterroute/default 配路由resolve 按
* 规则顺序匹配选 adapter。adapter lifecycleinitialize/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<string, AgentAdapter>()
private readonly rules: AdapterRouteRule[] = []
private defaultId: string | null = null
/** 注册一个 adapterid 重复则覆盖)。链式。 */
/** 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<void> {
for (const a of this.adapters.values()) {
await a.initialize?.()
}
}
/** 触发所有 adapter 的 dispose跳过未实现的 */
/** Trigger dispose on all adapters (skips unimplemented ones). */
async disposeAll(): Promise<void> {
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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 abortkill */
/** workflow was aborted (killed). */
export class WorkflowAbortedError extends Error {
constructor() {
super('workflow 已被取消(abort')
super('workflow has been aborted')
this.name = 'WorkflowAbortedError'
}
}

View File

@@ -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 层 bindingsservice.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<AgentRunResult> =>
adapter
? adapter.run(params, adapterCtx!)
: ctx.ports.agentRunner.runAgentToResult(params, ctx.host)
// 失败一次自动重试:deadterminal API error after retries)或 非 abort 抛错
// 都给一次重试机会;WorkflowAbortedErrorkill)不重试——是用户意图。
// 重试仍失败dead 保持 deadthrow 降级为 dead不让一个 agent 击穿 workflow)。
// budget 不重复扣dead 不 addOutputTokens重试 ok 才扣一次(最终 ok 时)。
// dead.reason 透传到日志no-structured-outputagent 最终文本块没产 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<Array<R | null>> => {
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<SubWorkflowRunner>[0] =
typeof nameOrRef === 'string'

View File

@@ -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() 调用的确定性 keyprompt + 规范化 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')
}
/** 文件式 JournalStorejsonl,每个 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 []

View File

@@ -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<string[]> {

View File

@@ -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

View File

@@ -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
/** resumetrue 时载入既有 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 左栏
// 会永远显示 runningagent 列表已 ✓ 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')
}

View File

@@ -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<string, unknown>) => Promise<unknown>
parallel: <T>(thunks: Array<() => Promise<T>>) => Promise<Array<T | null>>
@@ -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<string, unknown>
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<unknown>
}
/** 校验 + 包装脚本为可执行 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()

View File

@@ -3,8 +3,8 @@ import { Ajv, type ValidateFunction } from 'ajv'
const cache = new WeakMap<object, ValidateFunction>()
/**
* 用 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,

View File

@@ -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'

View File

@@ -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<AgentRunResult>
}
/** 进度事件发射。 */
/** 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 级 AbortControlleragent 完成/失败时调;幂等)。
* Unregister an agent-level AbortController (called when the agent completes/fails; idempotent).
*/
unregisterAgentAbort?(runId: string, agentId: number): void
/**
* 中断单个 agent。返回是否命中false = agent 已完成/不存在)。
* 不影响同 run 其他 agentworkflow 继续跑(被中断 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<JournalEntry[]>
append(runId: string, entry: JournalEntry): Promise<void>
truncate(runId: string): Promise<void>
}
/** 取消/权限门。 */
/** Cancellation / permission gate. */
export type PermissionGate = {
isAborted(host: HostHandle): boolean
}
/** 日志 + 遥测。 */
/** Logging + telemetry. */
export type Logger = {
debug(msg: string): void
event(name: string, metadata?: Record<string, unknown>): 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 agentRunnerhooks.agent routes
* to adapter.run via the registry; when omitted, falls back to agentRunner (backward compatibility).
*/
agentAdapterRegistry?: AgentAdapterRegistry
progressEmitter: ProgressEmitter

View File

@@ -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[]

View File

@@ -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<WorkflowInput>
@@ -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不阻断 runscript 已在内存)。
// 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('必须提供 scriptname scriptPath 之一')
throw new Error('One of script, name, or scriptPath must be provided')
}

View File

@@ -1,28 +1,34 @@
import { z } from 'zod/v4'
/** Workflow 工具输入 schemaargs 为任意 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/<name>.ts|js|mjs'),
scriptPath: z.string().optional().describe('已有脚本文件的绝对路径'),
.describe('Named workflow, resolved to .claude/workflows/<name>.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 派生,避免手工 typeschema 漂移。
* 旧实现里 {@link WorkflowInput} types.ts 手写、schema schema.ts
* 中间靠 `as unknown as z.ZodType<WorkflowInput>` 双重断言连接——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<WorkflowInput>` 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<typeof workflowInputSchema>
/** schema 的 typeof 类型(用于"以 schema 为准"的精确签名)。 */
/** typeof type of the schema (used for "schema is the source of truth" precise signatures). */
export type WorkflowInputSchema = typeof workflowInputSchema

View File

@@ -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/detailjournal 历史只记 `{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-outputagent 完成但 finalize content StructuredOutput(既没调工具也没在文本里产 JSON
* - runagent-threwrunAgent 抛非 abort 错误API 故障 / context 溢出 / runtime 错误)
* - worktree-failedisolation:'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

View File

@@ -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('✗');
});
// 修复 MuseSyncExternalStore / 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<React.ReactNode>)(
() => {},
{ 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<void> } | 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=nullruns 清空):不触发
// 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);
});

View File

@@ -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-globalworktreeState 在工厂外定义供测试重置。
// mock cwd.jsrunWithCwdOverride 真跑 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 识别为 abortthrow 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 创建的 agentAbortmock 收到的 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 必为 truekill 桥接根因)
// 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('extractStructuredOutputfenced code block(剥围栏 + 剥语言标签)', () => {
test('extractStructuredOutput: fenced code block (strip fence + strip language tag)', () => {
expect(
extractStructuredOutput([
{
@@ -318,13 +318,13 @@ test('extractStructuredOutputfenced 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('extractStructuredOutputarray / 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('extractStructuredOutputarray / number / string / null 不算 object', (
expect(extractStructuredOutput([{ type: 'text', text: 'null' }])).toBeNull()
})
test('extractStructuredOutput:多 text block → 跨块找第一个成功', () => {
test('extractStructuredOutput: multiple text blockscross-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()
})

View File

@@ -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),
)
// 第一次 emitlistener 记录初始 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()

View File

@@ -33,7 +33,7 @@ function makeRun(over: Partial<RunProgress> = {}): 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 返回 <projectRoot>/.claude/workflow-runs 形态', () => {
test('getRunsDir returns <projectRoot>/.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)
})

View File

@@ -1,9 +1,9 @@
import { expect, test } from 'bun:test'
// 注意:本测试不 mock bootstrap/stateutils/cwdanalyticsdebug
// 原因:mock.module 是进程全局的(last-write-winsmock 这些公共模块会污染
// 同进程其他测试(如 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_startedstore 能看到
// 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 对象承载 tasksregisterTask 走真实代码路径。
// 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 agentAbortControllersregister/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<typeof ports.taskRegistrar>
const state = { tasks: {} } as unknown as AppState
@@ -120,7 +120,7 @@ test('taskRegistrar agentAbortControllersregister/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 agentAbortControllersregister/killAgent 精确中断ki
expect(ac1.signal.aborted).toBe(false)
expect(ac2.signal.aborted).toBe(false)
// killAgent 精确中断 agent #1:仅 ac1 abortac2 不受影响
// 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 同 agentcontroller 已 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 剩余 agentac2
// 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 回归', () => {
// 历史 bughostFactory.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 })

View File

@@ -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)

View File

@@ -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 落地 outputShapeok·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 落地 outputShapeok·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/toolCountok 变体)', () => {
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/toolCountok 变体)', () => {
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',

View File

@@ -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.jsonreturnValue 一致', 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.jsonreturnValue 一致', 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.jsonreturnValue 一致', 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',

View File

@@ -23,7 +23,7 @@ function run(partial: Partial<RunProgress>): 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('filterAgentsByPhaseAll / 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('filterAgentsByPhaseAll / undefined → 全部;指定 → 仅该 phase
expect(filterAgentsByPhase(agents, 'A')).toEqual([agents[0]])
})
test('tabLabelworkflow 名 + runId 后 4 位短码', () => {
test('tabLabel: workflow name + last 4 chars short code of runId', () => {
expect(tabLabel('review-changes', 'wf_abc123def')).toBe('review-changes#3def')
})

View File

@@ -1,8 +1,8 @@
import { expect, test } from 'bun:test'
// DI 模式:不使用 mock.module进程全局、last-write-wins会污染同进程其他测试如
// autonomy.test.ts)。改为手工构造 FAKE WorkflowPortsregistry.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 portsregistry.run 返回固定 AgentRunResulttaskRegistrar binding
// journalStore 内存空实现。progressEmitter.emit → bus.emitstore 已在构造时订阅 bus
// 注意:runWorkflow 自身会发 run_started/run_donetaskRegistrar 只管 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<typeof createProgressStoreFromBus>
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<string, Map<number, AbortController>>
/** 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<string, { abort: AbortController }>()
// agentId → AbortController(每个 runId 独立)。killAgent 据此精确中断。
// agentId → AbortController (per runId). killAgent uses this to abort precisely.
const agentBindings = new Map<string, Map<number, AbortController>>()
// 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<void> {
await new Promise(r => setTimeout(r, 60))
}
test('launch → completedstore 出现该 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 → completedstore 出现该 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)
// 触发 ScriptErrormeta 字面量缺 descriptionvalidateMeta 要求 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 抛错 → 重试一次 → 仍抛 → 降级 deadagent 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)
})
// ---- 修复 Nshutdown 清理 ----
// ---- Fix N: shutdown cleanup ----
test('shutdown 杀掉所有 running runtaskRegistrar.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 runtaskRegistrar.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 {

View File

@@ -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('agentVisualrunning → ● warning', () => {
test('agentVisual: running → ● warning', () => {
const a: AgentProgress = { id: 1, status: 'running' }
expect(agentVisual(a)).toEqual({ mark: '●', color: 'warning' })
})
test('agentVisualdone·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('agentVisualdone·ok → ✓ success不再带 outputShape 后缀)',
expect(agentVisual(a)).toEqual({ mark: '✓', color: 'success' })
})
test('agentVisualdead → ✗ 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('agentMetaTextmodel · Nk tok · N tool', () => {
test('agentMetaText: model · Nk tok · N tool', () => {
const a: AgentProgress = {
id: 1,
status: 'done',
@@ -77,7 +77,7 @@ test('agentMetaTextmodel · 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',

View File

@@ -18,7 +18,7 @@ test('x → killAgentK → killWorkflowr → resumen → newRun', () =>
expect(routeWorkflowKey('n', {})).toBe('newRun')
})
test('confirm 模式:y/Enter → confirmYesn/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 → confirmYesn/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()
})

View File

@@ -1,5 +1,5 @@
// 深度集成后端:从活会话解析 agent/model/tools委托核心 runAgent
// 实现 AgentAdapter 接口,由 registryU5注册并路由。
// 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 objecttypeof === 'object' && !null && !Array
* schema 模式契约是 objectarray/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<ReturnType<typeof createAgentWorktree>>
/**
* 为 workflow agent 的 worktree 隔离生成 slugsha256(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 完成后清理 worktreehookBased 保留(无法检测 VCS 变更);否则用
* hasWorktreeChangesfail-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)
// coreAgentIdcore 层子 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
? <T>(fn: () => T): T =>
runWithCwdOverride(worktreeInfo!.worktreePath, fn)
: <T>(fn: () => T): T => fn()
// 桥接 ctx.signal runAgent.override.abortController。否则 workflow kill
// runAgent 不知道('x' 无效根因abort 信号到不了内部 fetchagent 跑到完成。
// 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 同一对象:coreAgentIdcore agent 跟踪)+ abortControllerkill 桥接)。
// runAgent model 是顶层 ModelAliasworkflow 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<typeof getTokenCountFromUsage>[0]
@@ -327,9 +327,9 @@ export const claudeCodeBackend: AgentAdapter = {
}
})
} catch (e) {
// abortkill workflow / kill agent):识别后必须重抛 WorkflowAbortedError
// 否则 hooks.agent 会把 abort 当作普通失败吞成 deadworkflow 不知道被 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,

View File

@@ -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'],

View File

@@ -6,7 +6,7 @@ import {
import type { Command } from '../types/command.js'
import { getProjectRoot } from '../bootstrap/state.js'
/** 扫描 .claude/workflows/ 下 *.ts|*.js|*.mjs每个生成一个 /<name> 命令。 */
/** Scan *.ts|*.js|*.mjs under .claude/workflows/ and generate a /<name> command for each. */
export async function getWorkflowCommands(
cwd: string = getProjectRoot(),
): Promise<Command[]> {

View File

@@ -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<RunProgress['status']> = new Set([
@@ -32,7 +33,7 @@ const TERMINAL_STATUSES: ReadonlySet<RunProgress['status']> = 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))
}

View File

@@ -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];

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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 是同一 runIdprev runningcurr 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 订阅 WorkflowServicestore 返回稳定快照,无变更不重渲染)。
* - 焦点状态: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<FocusColumn>('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 | 'agent' | 'workflow'>(null);
// mount 时触发一次扫盘 hydrate 历史 runservice 内部 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 的状态转换触发:切到已完成的 tabprev 是别的 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 title0 = 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 /<name>, 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 后立即回主 chatrun_done 事件 → store reducer status 改为
// killed notifications.ts 桥接 enqueuePendingNotification,主 chat 展示
// `Workflow "<name>" 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 "<name>" 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;

View File

@@ -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) => (
<SentryErrorBoundary name="WorkflowsPanel">

View File

@@ -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 = {
}
/**
* 合并 declaredPhasesmeta 声明)与 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<RunProgress, 'declaredPhases' | 'phases' | 'agents'>,
@@ -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`

View File

@@ -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<RunProgress['status'], string> = {
running: '●',
completed: '✓',
@@ -8,7 +8,7 @@ export const STATUS_DOT: Record<RunProgress['status'], string> = {
killed: '■',
}
/** run 状态 → ink theme 颜色 token(沿用现有 WorkflowList 配色)。 */
/** run status -> ink theme color token (follows existing WorkflowList palette). */
export const RUN_STATUS_COLOR: Record<RunProgress['status'], string> = {
running: 'warning',
completed: 'success',
@@ -16,7 +16,7 @@ export const RUN_STATUS_COLOR: Record<RunProgress['status'], string> = {
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<RunProgress['status'], string> = {
running: 'running',
completed: 'done',
@@ -24,7 +24,7 @@ export const RUN_STATUS_TEXT: Record<RunProgress['status'], string> = {
killed: 'killed',
}
/** phase 在侧栏的合并状态(含 pendingmeta 声明但未启动)。 */
/** 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<PhaseStatus, string> = {
@@ -39,14 +39,14 @@ export const PHASE_COLOR: Record<PhaseStatus, string> = {
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 ● warningUI 用 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[] = []

View File

@@ -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 killK 显式需要 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
/** 请求杀当前选中 agentpanel Dialog 二次确认)。 */
/** Request killing the currently selected agent (panel pops a Dialog for secondary confirmation). */
killAgent: () => void
/** 请求杀整个 workflowpanel 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,

View File

@@ -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 <runsDir>/<runId>/state.json
* 原子性:writeFile(tmp) → rename(tmp, target)rename 原子;最坏留 tmp下次写覆盖。
* 失败 best-effortIO 异常只 log warn不抛workflow 已成功,持久化失败只意味着重启后取不到)。
* Atomically overwrite the terminal RunProgress to <runsDir>/<runId>/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(
}
/**
* <runsDir>/<runId>/state.json,容错:
* - 文件不存在 → null调用方按 miss 处理)
* - JSON 解析失败 / schema 结构不符 / schemaVersion 不符 → nulllog warn,不崩)
* Read <runsDir>/<runId>/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-effortwriteRunState 内部吞 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,

View File

@@ -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<number, AbortController>
}
/** 每次工具调用从 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 } : {}),
}
}
}
/**
* 组装完整 WorkflowPortsbus/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-opabort 内部不会抛,但 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 // v1skip/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 始终走 agentAdapterRegistryports 必设)。若到此说明 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}`),

View File

@@ -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

View File

@@ -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 idagent_done 带入;运行中无)。 */
/** Actually parsed model id (carried in by agent_done; none while running). */
model?: string
/** context tokenagent_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<string, RunProgress>()
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

View File

@@ -2,8 +2,9 @@ import { AgentAdapterRegistry } from '@claude-code-best/workflow-engine'
import { claudeCodeBackend } from './backends/claudeCodeBackend.js'
/**
* 构建多后端 registry。v1depth 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()

View File

@@ -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 其他 agentworkflow 继续跑)。
* 返回是否命中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<RunProgress | undefined>
/**
* 扫盘把所有历史 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<void>
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 仅供测试注入 tmpdirBun 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('必须提供 scriptname 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不阻断 runscript 已在内存)。
// 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 已回收 bindingkill no-op
// taskRegistrar.kill 对未知 runId 安全 no-op因此幂等——多次 shutdown 不重复抛错。
// 每个 kill 单独 try/catchkill 内部走 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

View File

@@ -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 解析延迟到首次实际方法调用(lazytools.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 {