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