mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
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:
@@ -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 === undefined(string 上无 commit 属性)
|
||||
// 若 args 归一化:args.commit === 'abc123'
|
||||
// if args not normalized: args.commit === undefined (string has no commit property)
|
||||
// if args normalized: args.commit === 'abc123'
|
||||
expect(capturedPrompts).toContain('abc123')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('args 为非合法 JSON 字符串时保持原值不抛', async () => {
|
||||
test('args keeps original value for non-legal JSON string without throwing', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const capturedPrompts: unknown[] = []
|
||||
@@ -416,7 +416,7 @@ test('args 为非合法 JSON 字符串时保持原值不抛', async () => {
|
||||
const tool = createWorkflowTool(ports)
|
||||
await tool.call(
|
||||
{
|
||||
// 脚本把 args 当字符串用:agent(args) → agent('hello')
|
||||
// script uses args as a string: agent(args) → agent('hello')
|
||||
script: `return agent(args)`,
|
||||
args: 'hello',
|
||||
},
|
||||
@@ -427,22 +427,22 @@ test('args 为非合法 JSON 字符串时保持原值不抛', async () => {
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 50)
|
||||
})
|
||||
// 'hello' 不是合法 JSON,应保持为字符串
|
||||
// 'hello' is not valid JSON, should be kept as a string
|
||||
expect(capturedPrompts).toContain('hello')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('scriptPath 越界(resolve 后在 cwd 之外)→ 拒绝并报错(防任意文件读)', async () => {
|
||||
test('scriptPath out of bounds (resolved outside cwd) → rejected with error (prevents arbitrary file read)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const subDir = join(dir, 'sub')
|
||||
await mkdir(subDir, { recursive: true })
|
||||
// 在 subDir 之外(dir 内)放置一个脚本
|
||||
// place a script outside subDir (inside dir)
|
||||
const outsideScript = join(dir, 'outside.ts')
|
||||
await writeFile(outsideScript, `return agent('x')`)
|
||||
// host.cwd = subDir,scriptPath 是 subDir 外的绝对路径
|
||||
// host.cwd = subDir, scriptPath is an absolute path outside subDir
|
||||
const { ports, runStatus } = mockPorts(subDir, new Map())
|
||||
const tool = createWorkflowTool(ports)
|
||||
const res = await tool.call(
|
||||
@@ -452,22 +452,22 @@ test('scriptPath 越界(resolve 后在 cwd 之外)→ 拒绝并报错(防
|
||||
undefined,
|
||||
)
|
||||
expect(res.data.output).toMatch(/^Error:/)
|
||||
expect(res.data.output).toMatch(/越界|外|outside|contain/i)
|
||||
expect(res.data.output).toMatch(/out of bounds|outside|not within/i)
|
||||
expect(runStatus.size).toBe(0)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('name 含 ".." 路径段 → 拒绝(防路径遍历逃出 workflowDir)', async () => {
|
||||
test('name contains ".." path segment → rejected (prevents path traversal escaping workflowDir)', async () => {
|
||||
const outer = await mkdtemp(join(tmpdir(), 'wf-outer-'))
|
||||
try {
|
||||
// 在 outer 根下放置 evil.ts(在 .claude/workflows 之外)
|
||||
// place evil.ts at outer root (outside .claude/workflows)
|
||||
await writeFile(join(outer, 'evil.ts'), `return agent('x')`)
|
||||
await mkdir(join(outer, '.claude', 'workflows'), { recursive: true })
|
||||
const { ports, runStatus } = mockPorts(outer, new Map())
|
||||
const tool = createWorkflowTool(ports)
|
||||
// name = '../../evil' → join 后逃离 workflows 目录到 outer/evil.ts
|
||||
// name = '../../evil' → after join escapes the workflows directory to outer/evil.ts
|
||||
const res = await tool.call(
|
||||
{ name: '../../evil' },
|
||||
undefined,
|
||||
@@ -481,7 +481,7 @@ test('name 含 ".." 路径段 → 拒绝(防路径遍历逃出 workflowDir)'
|
||||
}
|
||||
})
|
||||
|
||||
test('name 含路径分隔符或为绝对路径 → 拒绝', async () => {
|
||||
test('name contains path separators or is absolute → rejected', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
await mkdir(join(dir, '.claude', 'workflows'), { recursive: true })
|
||||
@@ -501,7 +501,7 @@ test('name 含路径分隔符或为绝对路径 → 拒绝', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('returnValue 为对象 → complete(formatValue 走 JSON 分支)', async () => {
|
||||
test('returnValue is an object → complete (formatValue takes JSON branch)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const { ports, runStatus } = mockPorts(
|
||||
|
||||
@@ -36,7 +36,7 @@ const CTX = {
|
||||
agentId: 1,
|
||||
}
|
||||
|
||||
test('resolve 默认走 default adapter,run 返回结果', async () => {
|
||||
test('resolve goes to default adapter, run returns result', async () => {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(makeAdapter('a'))
|
||||
.register(makeAdapter('b'))
|
||||
@@ -46,7 +46,7 @@ test('resolve 默认走 default adapter,run 返回结果', async () => {
|
||||
expect(r.kind).toBe('ok')
|
||||
})
|
||||
|
||||
test('route agentType 命中优先于 default', () => {
|
||||
test('route agentType hit takes priority over default', () => {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(makeAdapter('default'))
|
||||
.register(makeAdapter('research'))
|
||||
@@ -56,7 +56,7 @@ test('route agentType 命中优先于 default', () => {
|
||||
expect(reg.resolve(P({ agentType: 'other' })).id).toBe('default')
|
||||
})
|
||||
|
||||
test('route model 前缀匹配', () => {
|
||||
test('route model prefix match', () => {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(makeAdapter('cheap'))
|
||||
.register(makeAdapter('strong'))
|
||||
@@ -64,10 +64,10 @@ test('route model 前缀匹配', () => {
|
||||
.default('cheap')
|
||||
expect(reg.resolve(P({ model: 'claude-opus-4' })).id).toBe('strong')
|
||||
expect(reg.resolve(P({ model: 'claude-sonnet-4' })).id).toBe('cheap')
|
||||
expect(reg.resolve(P()).id).toBe('cheap') // 无 model → default
|
||||
expect(reg.resolve(P()).id).toBe('cheap') // no model → default
|
||||
})
|
||||
|
||||
test('route custom 谓词', () => {
|
||||
test('route custom predicate', () => {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(makeAdapter('main'))
|
||||
.register(makeAdapter('special'))
|
||||
@@ -81,7 +81,7 @@ test('route custom 谓词', () => {
|
||||
expect(reg.resolve(P({ prompt: 'normal' })).id).toBe('main')
|
||||
})
|
||||
|
||||
test('规则按顺序匹配(先命中先用)', () => {
|
||||
test('rules match in order (first hit wins)', () => {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(makeAdapter('a'))
|
||||
.register(makeAdapter('b'))
|
||||
@@ -90,7 +90,7 @@ test('规则按顺序匹配(先命中先用)', () => {
|
||||
expect(reg.resolve(P({ agentType: 'x' })).id).toBe('a')
|
||||
})
|
||||
|
||||
test('规则命中的 adapter 未注册 → 跳过该规则继续匹配', () => {
|
||||
test('rule-matched adapter not registered → skip that rule and continue matching', () => {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(makeAdapter('real'))
|
||||
.route({ kind: 'agentType', agentType: 'x', adapter: 'ghost' })
|
||||
@@ -98,12 +98,12 @@ test('规则命中的 adapter 未注册 → 跳过该规则继续匹配', () =>
|
||||
expect(reg.resolve(P({ agentType: 'x' })).id).toBe('real')
|
||||
})
|
||||
|
||||
test('无匹配且无 default → AdapterNotFoundError', () => {
|
||||
test('no match and no default → AdapterNotFoundError', () => {
|
||||
const reg = new AgentAdapterRegistry().register(makeAdapter('a'))
|
||||
expect(() => reg.resolve(P())).toThrow(AdapterNotFoundError)
|
||||
})
|
||||
|
||||
test('default 指向未注册的 adapter → 仍抛(不静默回退)', () => {
|
||||
test('default points to an unregistered adapter → still throws (no silent fallback)', () => {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(makeAdapter('a'))
|
||||
.default('missing')
|
||||
@@ -118,7 +118,7 @@ test('has / get', () => {
|
||||
expect(reg.get('b')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('initializeAll / disposeAll 触发 lifecycle(跳过未实现)', async () => {
|
||||
test('initializeAll / disposeAll triggers lifecycle (skips unimplemented)', async () => {
|
||||
const events: string[] = []
|
||||
const withLifecycle: AgentAdapter = {
|
||||
id: 'a',
|
||||
@@ -133,7 +133,7 @@ test('initializeAll / disposeAll 触发 lifecycle(跳过未实现)', async (
|
||||
events.push('dispose-a')
|
||||
},
|
||||
}
|
||||
const noLifecycle = makeAdapter('b') // 无 initialize/dispose
|
||||
const noLifecycle = makeAdapter('b') // no initialize/dispose
|
||||
const reg = new AgentAdapterRegistry()
|
||||
.register(withLifecycle)
|
||||
.register(noLifecycle)
|
||||
@@ -142,7 +142,7 @@ test('initializeAll / disposeAll 触发 lifecycle(跳过未实现)', async (
|
||||
expect(events).toEqual(['init-a', 'dispose-a'])
|
||||
})
|
||||
|
||||
test('capabilities 声明可读', () => {
|
||||
test('capabilities declaration is readable', () => {
|
||||
const adapter: AgentAdapter = {
|
||||
id: 'a',
|
||||
capabilities: { structuredOutput: true, tools: true, stream: false },
|
||||
|
||||
@@ -46,7 +46,7 @@ function build(results: Map<string, AgentRunResult>) {
|
||||
return { ctx, events, hooks: makeHooks(ctx, async () => null) }
|
||||
}
|
||||
|
||||
test('并发 agent 各自拿到唯一 agentId,started/done 配对', async () => {
|
||||
test('concurrent agents each get a unique agentId, started/done are paired', async () => {
|
||||
const ok = (out: string): AgentRunResult => ({
|
||||
kind: 'ok',
|
||||
output: out,
|
||||
@@ -71,7 +71,7 @@ test('并发 agent 各自拿到唯一 agentId,started/done 配对', async () =
|
||||
expect(ctx.resources.agentIdSeq.value).toBe(2)
|
||||
})
|
||||
|
||||
test('agentId 单调递增', async () => {
|
||||
test('agentId increases monotonically', async () => {
|
||||
const ok = (out: string): AgentRunResult => ({
|
||||
kind: 'ok',
|
||||
output: out,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '../engine/concurrency.js'
|
||||
import { DEFAULT_MAX_CONCURRENCY, MAX_CONCURRENCY_CAP } from '../constants.js'
|
||||
|
||||
test('Semaphore 限制并发,permit 转移不泄漏', async () => {
|
||||
test('Semaphore limits concurrency, permit transfer does not leak', async () => {
|
||||
const sem = new Semaphore(2)
|
||||
let active = 0
|
||||
let peak = 0
|
||||
@@ -21,15 +21,15 @@ test('Semaphore 限制并发,permit 转移不泄漏', async () => {
|
||||
release()
|
||||
}
|
||||
await Promise.all(Array.from({ length: 6 }, () => task()))
|
||||
expect(peak).toBe(2) // 永不超过 permits
|
||||
expect(peak).toBe(2) // never exceeds permits
|
||||
})
|
||||
|
||||
test('maxConcurrency 返回 DEFAULT_MAX_CONCURRENCY (=3)', () => {
|
||||
test('maxConcurrency returns DEFAULT_MAX_CONCURRENCY (=3)', () => {
|
||||
expect(maxConcurrency()).toBe(DEFAULT_MAX_CONCURRENCY)
|
||||
expect(maxConcurrency()).toBe(3)
|
||||
})
|
||||
|
||||
test('clampMaxConcurrency:undefined/NaN→DEFAULT;<1→1;>CAP→CAP;正常原值', () => {
|
||||
test('clampMaxConcurrency: undefined/NaN→DEFAULT; <1→1; >CAP→CAP; normal value kept', () => {
|
||||
expect(clampMaxConcurrency(undefined)).toBe(DEFAULT_MAX_CONCURRENCY)
|
||||
expect(clampMaxConcurrency(Number.NaN)).toBe(DEFAULT_MAX_CONCURRENCY)
|
||||
expect(clampMaxConcurrency(0)).toBe(1)
|
||||
@@ -40,18 +40,18 @@ test('clampMaxConcurrency:undefined/NaN→DEFAULT;<1→1;>CAP→CAP;正
|
||||
expect(clampMaxConcurrency(5)).toBe(5)
|
||||
expect(clampMaxConcurrency(1)).toBe(1)
|
||||
expect(clampMaxConcurrency(MAX_CONCURRENCY_CAP)).toBe(MAX_CONCURRENCY_CAP)
|
||||
// 小数截断(Semaphore 已有 Math.max(1, Math.floor);clampMaxConcurrency 显式 trunc)
|
||||
// decimal truncation (Semaphore already does Math.max(1, Math.floor); clampMaxConcurrency explicitly truncs)
|
||||
expect(clampMaxConcurrency(2.9)).toBe(2)
|
||||
})
|
||||
|
||||
test('Semaphore(0) 至少 1 permit,acquire 不阻塞', async () => {
|
||||
test('Semaphore(0) has at least 1 permit, acquire does not block', async () => {
|
||||
const sem = new Semaphore(0)
|
||||
const release = await sem.acquire()
|
||||
expect(release).toBeTypeOf('function')
|
||||
release()
|
||||
})
|
||||
|
||||
test('Semaphore 唤醒按 FIFO 顺序', async () => {
|
||||
test('Semaphore wakes up in FIFO order', async () => {
|
||||
const sem = new Semaphore(1)
|
||||
const order: string[] = []
|
||||
const first = await sem.acquire()
|
||||
@@ -80,40 +80,40 @@ test('Semaphore 唤醒按 FIFO 顺序', async () => {
|
||||
;(await p2)()
|
||||
})
|
||||
|
||||
test('Semaphore.acquire 传 aborted signal → 立即 reject,不消耗 permit', async () => {
|
||||
// 修复 L:queued waiter 在 abort 时必须立即 reject 而非等 permit。
|
||||
// 否则一个被取消的 agent 阻塞在 acquire(),permit 被消耗(transfer 给已死的 waiter),
|
||||
// 实际并发能力降低;最坏情况下所有 waiter 都被取消,semaphore 还在排队等死掉的 waiter。
|
||||
test('Semaphore.acquire with an aborted signal → immediately rejects, no permit consumed', async () => {
|
||||
// Fix L: a queued waiter on abort must reject immediately instead of waiting for a permit.
|
||||
// Otherwise a cancelled agent blocks on acquire(), the permit is consumed (transferred to a dead waiter),
|
||||
// reducing actual concurrency capacity; in the worst case all waiters are cancelled while the semaphore still queues for dead waiters.
|
||||
const sem = new Semaphore(1)
|
||||
const ac = new AbortController()
|
||||
|
||||
// 占用唯一 permit
|
||||
// occupy the only permit
|
||||
const first = await sem.acquire()
|
||||
|
||||
// 排队的 waiter
|
||||
// queued waiter
|
||||
const queued = sem.acquire(ac.signal)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 5)
|
||||
})
|
||||
|
||||
// abort → waiter 应立即 reject
|
||||
// abort → waiter should reject immediately
|
||||
ac.abort()
|
||||
await expect(queued).rejects.toThrow()
|
||||
|
||||
// permit 无泄漏:释放 first 后,新 acquire 应能立即拿到(无 stale waiter 抢占)
|
||||
// no permit leak: after releasing first, a new acquire should get it immediately (no stale waiter preemption)
|
||||
first()
|
||||
const third = await sem.acquire()
|
||||
expect(third).toBeTypeOf('function')
|
||||
third()
|
||||
})
|
||||
|
||||
test('Semaphore.acquire 传已 aborted 的 signal → 同步 reject', async () => {
|
||||
test('Semaphore.acquire with an already aborted signal → synchronous reject', async () => {
|
||||
const sem = new Semaphore(1)
|
||||
const ac = new AbortController()
|
||||
ac.abort()
|
||||
// 信号已 aborted,即使有 permit 也不应 acquire(语义:调用者已取消)
|
||||
// 注意:当前实现先看 available,可能直接返回。本测试 lock "先 check abort"。
|
||||
// 若实现选择"permit 可用时优先发放",则此测试改为:acquire 成功,调用者后续检查 abort。
|
||||
// 当前实现选择前者:aborted signal 立即抛错,避免已死 agent 拿 permit。
|
||||
// signal already aborted, should not acquire even if a permit is available (semantics: caller already cancelled)
|
||||
// Note: current implementation checks available first and may return directly. This test locks "check abort first".
|
||||
// If the implementation chose "prefer granting when permit available", this test would change to: acquire succeeds, caller checks abort later.
|
||||
// Current implementation chose the former: aborted signal throws immediately, preventing dead agents from grabbing permits.
|
||||
await expect(sem.acquire(ac.signal)).rejects.toThrow()
|
||||
})
|
||||
|
||||
@@ -33,15 +33,15 @@ function mockPorts(): WorkflowPorts {
|
||||
}
|
||||
}
|
||||
|
||||
test('createSharedResources 初始化预算与计数', () => {
|
||||
test('createSharedResources initializes budget and counts', () => {
|
||||
const r = createSharedResources(100)
|
||||
expect(r.budget.total).toBe(100)
|
||||
expect(r.agentCountBox.value).toBe(0)
|
||||
expect(r.depth).toBe(0)
|
||||
})
|
||||
|
||||
test('createSharedResources:maxConcurrency 控制 semaphore permits', async () => {
|
||||
// 默认 permits = DEFAULT_MAX_CONCURRENCY = 3:4 次 acquire 后第 4 次 pending
|
||||
test('createSharedResources: maxConcurrency controls semaphore permits', async () => {
|
||||
// default permits = DEFAULT_MAX_CONCURRENCY = 3: after 4 acquires the 4th is pending
|
||||
const r1 = createSharedResources(null)
|
||||
const releases1: Array<() => void> = []
|
||||
for (let i = 0; i < 3; i++) releases1.push(await r1.semaphore.acquire())
|
||||
@@ -54,11 +54,11 @@ test('createSharedResources:maxConcurrency 控制 semaphore permits', async ()
|
||||
setTimeout(res, 5)
|
||||
})
|
||||
expect(fourthResolved).toBe(false)
|
||||
releases1[0]!() // 释放一个,第四个应被唤醒
|
||||
releases1[0]!() // release one, the fourth should be woken up
|
||||
releases1.push(await pending)
|
||||
for (const rel of releases1) rel()
|
||||
|
||||
// 显式 maxConcurrency=2:第 3 次 acquire pending
|
||||
// explicit maxConcurrency=2: the 3rd acquire is pending
|
||||
const r2 = createSharedResources(null, 2)
|
||||
const releases2: Array<() => void> = []
|
||||
releases2.push(await r2.semaphore.acquire())
|
||||
@@ -77,7 +77,7 @@ test('createSharedResources:maxConcurrency 控制 semaphore permits', async ()
|
||||
for (const rel of releases2) rel()
|
||||
})
|
||||
|
||||
test('createEngineContext 透传 maxConcurrency 到 resources.semaphore', async () => {
|
||||
test('createEngineContext passes maxConcurrency through to resources.semaphore', async () => {
|
||||
const ctx = createEngineContext({
|
||||
ports: mockPorts(),
|
||||
host: createHostHandle(null),
|
||||
@@ -88,7 +88,7 @@ test('createEngineContext 透传 maxConcurrency 到 resources.semaphore', async
|
||||
budgetTotal: null,
|
||||
maxConcurrency: 1,
|
||||
})
|
||||
// maxConcurrency=1:第二次 acquire 应 pending
|
||||
// maxConcurrency=1: the second acquire should be pending
|
||||
const first = await ctx.resources.semaphore.acquire()
|
||||
let secondResolved = false
|
||||
const pending = ctx.resources.semaphore.acquire().then(r => {
|
||||
@@ -103,7 +103,7 @@ test('createEngineContext 透传 maxConcurrency 到 resources.semaphore', async
|
||||
await pending
|
||||
})
|
||||
|
||||
test('createEngineContext 复制 journal 并重置游标', () => {
|
||||
test('createEngineContext copies journal and resets cursor', () => {
|
||||
const journal = [
|
||||
{
|
||||
key: 'k',
|
||||
@@ -126,13 +126,13 @@ test('createEngineContext 复制 journal 并重置游标', () => {
|
||||
expect(ctx.journalInvalidated).toBe(false)
|
||||
})
|
||||
|
||||
test('createBufferingEmitter 收集事件', () => {
|
||||
test('createBufferingEmitter collects events', () => {
|
||||
const { emitter, events } = createBufferingEmitter()
|
||||
emitter.emit({ type: 'log', runId: 'r', message: 'hi' })
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('WorkflowError 可识别', () => {
|
||||
test('WorkflowError is recognizable', () => {
|
||||
const e = new WorkflowError('boom')
|
||||
expect(e).toBeInstanceOf(Error)
|
||||
expect(e.message).toBe('boom')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -24,8 +24,8 @@ type CtxOverrides = Partial<{
|
||||
truncated: string[]
|
||||
agentAdapterRegistry: AgentAdapterRegistry
|
||||
loggerWarn: (msg: string) => void
|
||||
// taskRegistrar 的 agent 级 abort 绑定(agent kill 桥接)。
|
||||
// 提供后 buildCtx 注入到 ports.taskRegistrar;hooks.agent 把闭包塞进 adapterCtx。
|
||||
// taskRegistrar agent-level abort binding (agent kill bridge).
|
||||
// When provided, buildCtx injects it into ports.taskRegistrar; hooks.agent pushes the closure into adapterCtx.
|
||||
registerAgentAbort: (
|
||||
runId: string,
|
||||
agentId: number,
|
||||
@@ -98,7 +98,7 @@ function buildCtx(overrides: CtxOverrides = {}): {
|
||||
return { ctx, events, hooks: makeHooks(ctx, noopSub) }
|
||||
}
|
||||
|
||||
test('agent 返回文本结果并计数', async () => {
|
||||
test('agent returns text result and counts', async () => {
|
||||
const { ctx, hooks } = buildCtx({
|
||||
agentResults: new Map([
|
||||
['hi', { kind: 'ok', output: 'hello', usage: { outputTokens: 5 } }],
|
||||
@@ -109,7 +109,7 @@ test('agent 返回文本结果并计数', async () => {
|
||||
expect(ctx.resources.agentCountBox.value).toBe(1)
|
||||
})
|
||||
|
||||
test('agent skipped → null 且不计数', async () => {
|
||||
test('agent skipped → null and not counted', async () => {
|
||||
const { hooks } = buildCtx({
|
||||
agentResults: new Map([['hi', { kind: 'skipped' }]]),
|
||||
})
|
||||
@@ -123,9 +123,9 @@ test('agent dead → null', async () => {
|
||||
expect(await hooks.agent('hi')).toBeNull()
|
||||
})
|
||||
|
||||
// 重试:dead 或 非 abort throw 都给一次重试机会;WorkflowAbortedError(kill)不重试。
|
||||
// 重试仍失败:dead 保持 dead;throw 降级为 dead(不击穿 workflow,hooks.agent 返 null)。
|
||||
test('agent dead → 重试一次成功 → ok', async () => {
|
||||
// Retry: dead or non-abort throw both get one retry chance; WorkflowAbortedError (kill) is not retried.
|
||||
// Retry still fails: dead stays dead; throw degrades to dead (does not break the workflow, hooks.agent returns null).
|
||||
test('agent dead → retry once succeeds → ok', async () => {
|
||||
let calls = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
@@ -143,7 +143,7 @@ test('agent dead → 重试一次成功 → ok', async () => {
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test('agent dead → 重试仍 dead → 最终 null(dead 保持 dead)', async () => {
|
||||
test('agent dead → retry still dead → final null (dead stays dead)', async () => {
|
||||
let calls = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
@@ -156,7 +156,7 @@ test('agent dead → 重试仍 dead → 最终 null(dead 保持 dead)', asyn
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test('agent 非 abort throw → 重试一次成功 → ok', async () => {
|
||||
test('agent non-abort throw → retry once succeeds → ok', async () => {
|
||||
let calls = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
@@ -174,7 +174,7 @@ test('agent 非 abort throw → 重试一次成功 → ok', async () => {
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test('agent 非 abort throw → 重试仍 throw → 降级 dead(返 null,不击穿 workflow)', async () => {
|
||||
test('agent non-abort throw → retry still throws → degrade to dead (returns null, does not break workflow)', async () => {
|
||||
let calls = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
@@ -187,7 +187,7 @@ test('agent 非 abort throw → 重试仍 throw → 降级 dead(返 null,不
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test('agent throw WorkflowAbortedError → 不重试,直接 rethrow(kill 不容许重试)', async () => {
|
||||
test('agent throw WorkflowAbortedError → no retry, rethrow directly (kill does not allow retry)', async () => {
|
||||
let calls = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
@@ -199,7 +199,7 @@ test('agent throw WorkflowAbortedError → 不重试,直接 rethrow(kill 不
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test('agent ok → 不重试(calls=1,省一次 backend 往返)', async () => {
|
||||
test('agent ok → no retry (calls=1, saves a backend round-trip)', async () => {
|
||||
let calls = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
@@ -215,7 +215,7 @@ test('agent ok → 不重试(calls=1,省一次 backend 往返)', async ()
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test('agent skipped → 不重试(用户主动 skip,不重试)', async () => {
|
||||
test('agent skipped → no retry (user actively skips, no retry)', async () => {
|
||||
let calls = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
@@ -227,7 +227,7 @@ test('agent skipped → 不重试(用户主动 skip,不重试)', async ()
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test('agent journal 命中时不调用 runner', async () => {
|
||||
test('agent journal hit does not call runner', async () => {
|
||||
let called = 0
|
||||
const { emitter } = createBufferingEmitter()
|
||||
const ports: WorkflowPorts = {
|
||||
@@ -280,13 +280,13 @@ test('agent journal 命中时不调用 runner', async () => {
|
||||
expect(called).toBe(0)
|
||||
})
|
||||
|
||||
test('agent 超过总数上限抛错', async () => {
|
||||
test('agent exceeding total cap throws', async () => {
|
||||
const { hooks, ctx } = buildCtx()
|
||||
ctx.resources.agentCountBox.value = 1000
|
||||
await expect(hooks.agent('hi')).rejects.toThrow(WorkflowError)
|
||||
})
|
||||
|
||||
test('parallel 单项抛错 → null,其余保留', async () => {
|
||||
test('parallel single item throws → null, others kept', async () => {
|
||||
const { hooks } = buildCtx()
|
||||
const out = await hooks.parallel([
|
||||
async () => 'a',
|
||||
@@ -298,7 +298,7 @@ test('parallel 单项抛错 → null,其余保留', async () => {
|
||||
expect(out).toEqual(['a', null, 'c'])
|
||||
})
|
||||
|
||||
test('parallel 单项抛错 → logger.warn 记录失败原因', async () => {
|
||||
test('parallel single item throws → logger.warn records the failure reason', async () => {
|
||||
const warns: string[] = []
|
||||
const { hooks } = buildCtx({ loggerWarn: msg => warns.push(msg) })
|
||||
await hooks.parallel([
|
||||
@@ -312,7 +312,7 @@ test('parallel 单项抛错 → logger.warn 记录失败原因', async () => {
|
||||
expect(warns[0]).toMatch(/boom-x/)
|
||||
})
|
||||
|
||||
test('pipeline 逐 stage 链式,stage 抛错 → null', async () => {
|
||||
test('pipeline chains stage by stage, stage throws → null', async () => {
|
||||
const { hooks } = buildCtx()
|
||||
const out = await hooks.pipeline(
|
||||
[1, 2],
|
||||
@@ -328,7 +328,7 @@ test('pipeline 逐 stage 链式,stage 抛错 → null', async () => {
|
||||
expect(out2).toEqual([null])
|
||||
})
|
||||
|
||||
test('pipeline stage 抛错 → logger.warn 记录失败原因', async () => {
|
||||
test('pipeline stage throws → logger.warn records the failure reason', async () => {
|
||||
const warns: string[] = []
|
||||
const { hooks } = buildCtx({ loggerWarn: msg => warns.push(msg) })
|
||||
await hooks.pipeline(
|
||||
@@ -340,14 +340,14 @@ test('pipeline stage 抛错 → logger.warn 记录失败原因', async () => {
|
||||
expect(warns[0]).toMatch(/stage-boom/)
|
||||
})
|
||||
|
||||
test('pipeline 超 4096 抛错', async () => {
|
||||
test('pipeline over 4096 throws', async () => {
|
||||
const { hooks } = buildCtx()
|
||||
await expect(
|
||||
hooks.pipeline(Array(4097), () => Promise.resolve(1)),
|
||||
).rejects.toThrow(WorkflowError)
|
||||
})
|
||||
|
||||
test('phase 切换发射 phase_started/done;log 发射 log', () => {
|
||||
test('phase switch emits phase_started/done; log emits log', () => {
|
||||
const { hooks, events } = buildCtx()
|
||||
hooks.phase('A')
|
||||
hooks.log('hello')
|
||||
@@ -364,9 +364,9 @@ test('phase 切换发射 phase_started/done;log 发射 log', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// ---- 边界与错误路径 ----
|
||||
// ---- boundary and error paths ----
|
||||
|
||||
test('agent dead 也计入 agentCountBox', async () => {
|
||||
test('agent dead also counts in agentCountBox', async () => {
|
||||
const { hooks, ctx } = buildCtx({
|
||||
agentResults: new Map([['x', { kind: 'dead' }]]),
|
||||
})
|
||||
@@ -374,7 +374,7 @@ test('agent dead 也计入 agentCountBox', async () => {
|
||||
expect(ctx.resources.agentCountBox.value).toBe(1)
|
||||
})
|
||||
|
||||
test('agent pendingAction=skip → null、不调 runner、不计数', async () => {
|
||||
test('agent pendingAction=skip → null, does not call runner, not counted', async () => {
|
||||
let called = 0
|
||||
const { hooks, ctx } = buildCtx({
|
||||
pending: { kind: 'skip' },
|
||||
@@ -388,7 +388,7 @@ test('agent pendingAction=skip → null、不调 runner、不计数', async () =
|
||||
expect(ctx.resources.agentCountBox.value).toBe(0)
|
||||
})
|
||||
|
||||
test('agent journal key 发散 → invalidate 并 truncate', async () => {
|
||||
test('agent journal key diverges → invalidate and truncate', async () => {
|
||||
const truncated: string[] = []
|
||||
const { hooks, ctx } = buildCtx({
|
||||
runner: async () => ({
|
||||
@@ -411,7 +411,7 @@ test('agent journal key 发散 → invalidate 并 truncate', async () => {
|
||||
expect(ctx.journalInvalidated).toBe(true)
|
||||
})
|
||||
|
||||
test('agent 预算耗尽时抛错', async () => {
|
||||
test('agent throws when budget exhausted', async () => {
|
||||
const { hooks, ctx } = buildCtx({
|
||||
budgetTotal: 10,
|
||||
runner: async () => ({
|
||||
@@ -424,27 +424,27 @@ test('agent 预算耗尽时抛错', async () => {
|
||||
await expect(hooks.agent('x')).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('agent 预算检查在 semaphore 临界区内(queued waiter 看到最新 spent)', async () => {
|
||||
// 当 semaphore capacity < parallel agent 数时,部分 agent 会排队。
|
||||
// 旧 bug:assertCanSpend 在 acquire 之前,所有 waiter 入队时 spent=0 都过检;
|
||||
// 后续 permit 释放后 waiter 直接跑 runner、扣预算,不再 re-check → 全部超支。
|
||||
// 修复:assertCanSpend 移入临界区,waiter 被唤醒后先看 spent 再决定是否跑。
|
||||
// 强制 capacity=1(serializing semaphore)确保 N>1 个 agent 必须排队。
|
||||
test('agent budget check inside semaphore critical section (queued waiter sees latest spent)', async () => {
|
||||
// When semaphore capacity < parallel agent count, some agents will queue.
|
||||
// Old bug: assertCanSpend was before acquire, all waiters entered the queue with spent=0 and passed the check;
|
||||
// after permits released waiters ran the runner and deducted the budget without re-checking → all over-spent.
|
||||
// Fix: assertCanSpend moved into the critical section; waiters check spent after being woken before deciding to run.
|
||||
// Force capacity=1 (serializing semaphore) to ensure N>1 agents must queue.
|
||||
const { hooks, ctx } = buildCtx({
|
||||
budgetTotal: 10,
|
||||
runner: async () => {
|
||||
// 让 runner 慢一点,确保 waiter 真的排队
|
||||
// make the runner a bit slow to ensure waiters truly queue
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 5)
|
||||
})
|
||||
return {
|
||||
kind: 'ok',
|
||||
output: 'x',
|
||||
usage: { outputTokens: 6 }, // 每次 6 token,2 次即超 10
|
||||
usage: { outputTokens: 6 }, // 6 tokens each, 2 runs exceed 10
|
||||
}
|
||||
},
|
||||
})
|
||||
// 用单 permit semaphore 替换默认的,强制序列化
|
||||
// replace the default semaphore with a single-permit one, forcing serialization
|
||||
ctx.resources.semaphore = new Semaphore(1)
|
||||
const results = await hooks.parallel([
|
||||
() => hooks.agent('a'),
|
||||
@@ -452,9 +452,9 @@ test('agent 预算检查在 semaphore 临界区内(queued waiter 看到最新
|
||||
() => hooks.agent('c'),
|
||||
() => hooks.agent('d'),
|
||||
])
|
||||
// 至少 1 个 agent 被 parallel catch 成 null(assertCanSpend 抛错)
|
||||
// at least 1 agent is caught as null by parallel (assertCanSpend throws)
|
||||
expect(results.some(r => r === null)).toBe(true)
|
||||
// 不应 4 个全跑扣 24;上限是 at-most-one-over(前两个扣 12,后两个被拦)
|
||||
// not all 4 should run and spend 24; the cap is at-most-one-over (first two spend 12, last two blocked)
|
||||
expect(ctx.resources.budget.spent()).toBeLessThanOrEqual(12)
|
||||
})
|
||||
|
||||
@@ -472,20 +472,20 @@ test('agent signal aborted → WorkflowAbortedError', async () => {
|
||||
await expect(hooks.agent('x')).rejects.toThrow(WorkflowAbortedError)
|
||||
})
|
||||
|
||||
test('parallel 超过 4096 项抛错', async () => {
|
||||
test('parallel over 4096 items throws', async () => {
|
||||
const { hooks } = buildCtx()
|
||||
await expect(
|
||||
hooks.parallel(Array.from({ length: 4097 }, () => async () => 1)),
|
||||
).rejects.toThrow(WorkflowError)
|
||||
})
|
||||
|
||||
test('workflow() 嵌套超过一层抛错', async () => {
|
||||
test('workflow() nesting beyond one level throws', async () => {
|
||||
const { hooks, ctx } = buildCtx()
|
||||
ctx.resources.depth = 1
|
||||
await expect(hooks.workflow('child')).rejects.toThrow(WorkflowError)
|
||||
})
|
||||
|
||||
test('agent 并发受 semaphore 限制(不超 maxConcurrency)', async () => {
|
||||
test('agent concurrency bounded by semaphore (does not exceed maxConcurrency)', async () => {
|
||||
let active = 0
|
||||
let peak = 0
|
||||
const { hooks } = buildCtx({
|
||||
@@ -503,7 +503,7 @@ test('agent 并发受 semaphore 限制(不超 maxConcurrency)', async () =>
|
||||
expect(peak).toBeLessThanOrEqual(maxConcurrency())
|
||||
})
|
||||
|
||||
test('agentAdapterRegistry 优先于 agentRunner(按路由分发到 adapter)', async () => {
|
||||
test('agentAdapterRegistry takes priority over agentRunner (dispatched to adapter by route)', async () => {
|
||||
const called: string[] = []
|
||||
const registry = new AgentAdapterRegistry()
|
||||
.register({
|
||||
@@ -530,8 +530,8 @@ test('agentAdapterRegistry 优先于 agentRunner(按路由分发到 adapter)
|
||||
expect(called).toEqual(['adapter'])
|
||||
})
|
||||
|
||||
test('agentAdapterRegistry resolve 抛错 → agent 上抛(workflow failed)', async () => {
|
||||
const registry = new AgentAdapterRegistry().default('missing') // 未注册
|
||||
test('agentAdapterRegistry resolve throws → agent rethrows (workflow failed)', async () => {
|
||||
const registry = new AgentAdapterRegistry().default('missing') // not registered
|
||||
const { hooks } = buildCtx({
|
||||
agentAdapterRegistry: registry,
|
||||
runner: async () => ({
|
||||
@@ -543,17 +543,17 @@ test('agentAdapterRegistry resolve 抛错 → agent 上抛(workflow failed)'
|
||||
await expect(hooks.agent('x')).rejects.toThrow()
|
||||
})
|
||||
|
||||
// service.kill(runId, agentId) 桥接:hooks.agent 必须把 taskRegistrar 的
|
||||
// registerAgentAbort/unregisterAgentAbort 注入 adapterCtx(绑定当前 runId)。
|
||||
// backend 据此把 agentAbort controller 塞进 Map,service.kill 据 agentId 精确 abort。
|
||||
test('agentAdapter ctx 注入 registerAgentAbort/unregisterAgentAbort(绑定 runId 转发 taskRegistrar)', async () => {
|
||||
// service.kill(runId, agentId) bridge: hooks.agent must inject taskRegistrar's
|
||||
// registerAgentAbort/unregisterAgentAbort into adapterCtx (bound to the current runId).
|
||||
// The backend puts the agentAbort controller into a Map based on this; service.kill aborts precisely by agentId.
|
||||
test('agentAdapter ctx injects registerAgentAbort/unregisterAgentAbort (bound to runId, forwards to taskRegistrar)', async () => {
|
||||
const registered: Array<{
|
||||
runId: string
|
||||
agentId: number
|
||||
controller: AbortController
|
||||
}> = []
|
||||
const unregistered: Array<{ runId: string; agentId: number }> = []
|
||||
// 捕获 hooks 传给 adapter 的 ctx(验证 register/unregister 已注入且绑定 runId)
|
||||
// capture the ctx hooks pass to the adapter (verify register/unregister are injected and bound to runId)
|
||||
let capturedCtx: {
|
||||
registerAgentAbort?: (id: number, ac: AbortController) => void
|
||||
unregisterAgentAbort?: (id: number) => void
|
||||
@@ -578,12 +578,12 @@ test('agentAdapter ctx 注入 registerAgentAbort/unregisterAgentAbort(绑定 r
|
||||
unregistered.push({ runId, agentId }),
|
||||
})
|
||||
await hooks.agent('x')
|
||||
// ctx 含 register/unregister(闭包绑定 runId='r1')
|
||||
// ctx contains register/unregister (closure bound to runId='r1')
|
||||
expect(capturedCtx).not.toBeNull()
|
||||
expect(typeof capturedCtx!.registerAgentAbort).toBe('function')
|
||||
expect(typeof capturedCtx!.unregisterAgentAbort).toBe('function')
|
||||
// 模拟 backend 调用:注入的闭包把 (agentId, controller) 转发到 taskRegistrar,
|
||||
// 并自动补 runId='r1'(backend 不需要知道 runId)
|
||||
// simulate backend call: the injected closure forwards (agentId, controller) to taskRegistrar,
|
||||
// and auto-fills runId='r1' (backend does not need to know runId)
|
||||
const ac = new AbortController()
|
||||
capturedCtx!.registerAgentAbort!(7, ac)
|
||||
capturedCtx!.unregisterAgentAbort!(7)
|
||||
@@ -591,9 +591,9 @@ test('agentAdapter ctx 注入 registerAgentAbort/unregisterAgentAbort(绑定 r
|
||||
expect(unregistered).toEqual([{ runId: 'r1', agentId: 7 }])
|
||||
})
|
||||
|
||||
test('taskRegistrar 未提供 registerAgentAbort → adapterCtx 也不含(hooks 不报错)', async () => {
|
||||
// 不传 registerAgentAbort/unregisterAgentAbort overrides → buildCtx 也不注入 taskRegistrar
|
||||
// hooks 用 optional chaining 跳过,adapterCtx 不含这两个字段
|
||||
test('taskRegistrar does not provide registerAgentAbort → adapterCtx also lacks it (hooks do not error)', async () => {
|
||||
// without registerAgentAbort/unregisterAgentAbort overrides → buildCtx does not inject taskRegistrar either
|
||||
// hooks skip via optional chaining; adapterCtx lacks these two fields
|
||||
let capturedCtx: object | null = null
|
||||
const registry = new AgentAdapterRegistry()
|
||||
.register({
|
||||
|
||||
@@ -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: () => {} },
|
||||
|
||||
@@ -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 同 runId:journal 命中,不重跑
|
||||
// resume same runId: journal hit, no re-run
|
||||
calls = 0
|
||||
const resumed = await runWorkflow({
|
||||
script,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -70,7 +70,7 @@ function portsWithEvents(
|
||||
}
|
||||
}
|
||||
|
||||
test('端到端:脚本返回 agent 结果,状态 completed', async () => {
|
||||
test('end-to-end: script returns agent result, status completed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const ports = portsWith(
|
||||
@@ -95,7 +95,7 @@ test('端到端:脚本返回 agent 结果,状态 completed', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('脚本语法错误 → failed', async () => {
|
||||
test('script syntax error → failed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const ports = portsWith(dir, new Map())
|
||||
@@ -115,7 +115,7 @@ test('脚本语法错误 → failed', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('resume:journal 命中则不调用 runner', async () => {
|
||||
test('resume: journal hit skips runner call', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
let called = 0
|
||||
@@ -192,7 +192,7 @@ test('abort → killed', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('workflow() 嵌套(一层)共享计数', async () => {
|
||||
test('workflow() nesting (one level) shares counts', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
await mkdir(join(dir, '.claude', 'workflows'), { recursive: true })
|
||||
@@ -225,9 +225,9 @@ test('workflow() 嵌套(一层)共享计数', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 边界与事件 ----
|
||||
// ---- boundary and events ----
|
||||
|
||||
test('scriptChanged=true → truncate journal 并全量现场跑', async () => {
|
||||
test('scriptChanged=true → truncate journal and run all live', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
let called = 0
|
||||
@@ -275,7 +275,7 @@ test('scriptChanged=true → truncate journal 并全量现场跑', async () => {
|
||||
expect(result.status).toBe('completed')
|
||||
expect(result.returnValue).toBe('live')
|
||||
expect(called).toBe(1)
|
||||
// truncate 清空了旧 cached journal,现场 agent append 新 entry(live)
|
||||
// truncate cleared the old cached journal, live agent appends a new entry
|
||||
const final = await ports.journalStore.read('run-chg')
|
||||
expect(final).toHaveLength(1)
|
||||
expect((final[0]!.result as { output: string }).output).toBe('live')
|
||||
@@ -284,7 +284,7 @@ test('scriptChanged=true → truncate journal 并全量现场跑', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('脚本运行时抛错(非语法错)→ failed', async () => {
|
||||
test('script runtime throw (non-syntax error) → failed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const ports = portsWith(dir, new Map())
|
||||
@@ -304,7 +304,7 @@ test('脚本运行时抛错(非语法错)→ failed', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('发射 run_started(含 workflowName)与 run_done 事件', async () => {
|
||||
test('emits run_started (with workflowName) and run_done events', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const { ports, events } = portsWithEvents(
|
||||
@@ -332,9 +332,9 @@ test('发射 run_started(含 workflowName)与 run_done 事件', async () =>
|
||||
}
|
||||
})
|
||||
|
||||
// 终态前补发当前 phase 的 phase_done:hook.phase 只在切换时 emit 上一个的 done,
|
||||
// 最后一个 phase 无后续切换 → UI 左栏会永远显示 running。验证三路径都补发。
|
||||
test('终态前补发 currentPhase 的 phase_done(completed 路径)', async () => {
|
||||
// Emit phase_done for currentPhase before terminal state: hook.phase only emits the previous phase's done on switch,
|
||||
// the last phase has no subsequent switch → the UI left panel would show running forever. Verify all three paths re-emit.
|
||||
test('re-emit phase_done for currentPhase before terminal state (completed path)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const { ports, events } = portsWithEvents(
|
||||
@@ -350,14 +350,14 @@ test('终态前补发 currentPhase 的 phase_done(completed 路径)', async
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
// Review 的 phase_started + phase_done 都应存在(done 来自终态前补发)
|
||||
// Both phase_started and phase_done for Review should be present (done from re-emit before terminal)
|
||||
expect(
|
||||
events.some(e => e.type === 'phase_started' && e.phase === 'Review'),
|
||||
).toBe(true)
|
||||
expect(
|
||||
events.some(e => e.type === 'phase_done' && e.phase === 'Review'),
|
||||
).toBe(true)
|
||||
// 顺序:phase_done 必须在 run_done 之前(reducer 不依赖顺序,但事件流语义清晰)
|
||||
// Order: phase_done must precede run_done (reducer is order-independent, but the event stream is clearer this way)
|
||||
const lastPhaseDone = Math.max(
|
||||
0,
|
||||
...events.map((e, i) => (e.type === 'phase_done' ? i : -1)),
|
||||
@@ -370,7 +370,7 @@ test('终态前补发 currentPhase 的 phase_done(completed 路径)', async
|
||||
}
|
||||
})
|
||||
|
||||
test('终态前补发 currentPhase 的 phase_done(killed 路径)', async () => {
|
||||
test('re-emit phase_done for currentPhase before terminal state (killed path)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const { ports, events } = portsWithEvents(
|
||||
@@ -399,7 +399,7 @@ test('终态前补发 currentPhase 的 phase_done(killed 路径)', async ()
|
||||
}
|
||||
})
|
||||
|
||||
test('无 phase() 调用 → 终态不补发 phase_done(currentPhase 为 null)', async () => {
|
||||
test('no phase() call → terminal does not re-emit phase_done (currentPhase is null)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const { ports, events } = portsWithEvents(
|
||||
@@ -415,7 +415,7 @@ test('无 phase() 调用 → 终态不补发 phase_done(currentPhase 为 null
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
// 没有 phase() → currentPhase 为 null → 终态不补发 phase_done
|
||||
// No phase() → currentPhase is null → terminal does not re-emit phase_done
|
||||
expect(events.some(e => e.type === 'phase_done')).toBe(false)
|
||||
expect(events.some(e => e.type === 'phase_started')).toBe(false)
|
||||
expect(
|
||||
@@ -426,7 +426,7 @@ test('无 phase() 调用 → 终态不补发 phase_done(currentPhase 为 null
|
||||
}
|
||||
})
|
||||
|
||||
test('未传 workflowName 时从 meta.name 推导', async () => {
|
||||
test('derives workflowName from meta.name when not passed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const { ports, events } = portsWithEvents(dir, new Map())
|
||||
@@ -449,7 +449,7 @@ test('未传 workflowName 时从 meta.name 推导', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('budgetTotal 耗尽 → failed', async () => {
|
||||
test('budgetTotal exhausted → failed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const ports = portsWith(
|
||||
@@ -474,7 +474,7 @@ test('budgetTotal 耗尽 → failed', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('maxConcurrency 透传:并行 agent 受 run 级并发槽位限制', async () => {
|
||||
test('maxConcurrency passthrough: parallel agents bounded by run-level concurrency slots', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
let active = 0
|
||||
@@ -525,7 +525,7 @@ test('maxConcurrency 透传:并行 agent 受 run 级并发槽位限制', async
|
||||
}
|
||||
})
|
||||
|
||||
test('workflow() 引用语法错的子脚本 → failed', async () => {
|
||||
test('workflow() references a syntactically broken sub-script → failed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
await mkdir(join(dir, '.claude', 'workflows'), { recursive: true })
|
||||
@@ -541,13 +541,13 @@ test('workflow() 引用语法错的子脚本 → failed', async () => {
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.error).toMatch(/子 workflow|脚本错误/)
|
||||
expect(result.error).toMatch(/Sub-workflow|script error/i)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('workflow() 引用不存在的 name → failed', async () => {
|
||||
test('workflow() references a non-existent name → failed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const ports = portsWith(dir, new Map())
|
||||
@@ -561,7 +561,7 @@ test('workflow() 引用不存在的 name → failed', async () => {
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.error).toMatch(/子 workflow|未找到/)
|
||||
expect(result.error).toMatch(/Sub-workflow|not found/i)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { workflowInputSchema } from '../tool/schema.js'
|
||||
|
||||
test('空对象通过(所有字段 optional)', () => {
|
||||
test('empty object passes (all fields optional)', () => {
|
||||
expect(workflowInputSchema.safeParse({}).success).toBe(true)
|
||||
})
|
||||
|
||||
test('全部已知字段可填', () => {
|
||||
test('all known fields can be filled', () => {
|
||||
const r = workflowInputSchema.safeParse({
|
||||
script: 'return 1',
|
||||
name: 'release',
|
||||
@@ -19,19 +19,19 @@ test('全部已知字段可填', () => {
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
test('args 接受任意 JSON 值(对象/数组/字符串/数字/布尔/null)', () => {
|
||||
test('args accepts any JSON value (object/array/string/number/boolean/null)', () => {
|
||||
for (const args of [{ a: 1 }, [1, 2], 's', 42, true, null]) {
|
||||
expect(workflowInputSchema.safeParse({ args }).success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('类型错误被拒(script/name/scriptPath 非字符串)', () => {
|
||||
test('type errors rejected (script/name/scriptPath not strings)', () => {
|
||||
expect(workflowInputSchema.safeParse({ script: 123 }).success).toBe(false)
|
||||
expect(workflowInputSchema.safeParse({ name: 42 }).success).toBe(false)
|
||||
expect(workflowInputSchema.safeParse({ scriptPath: {} }).success).toBe(false)
|
||||
})
|
||||
|
||||
test('resumeFromRunId/description/title 必须为字符串', () => {
|
||||
test('resumeFromRunId/description/title must be strings', () => {
|
||||
expect(workflowInputSchema.safeParse({ resumeFromRunId: 1 }).success).toBe(
|
||||
false,
|
||||
)
|
||||
@@ -39,12 +39,12 @@ test('resumeFromRunId/description/title 必须为字符串', () => {
|
||||
expect(workflowInputSchema.safeParse({ title: 1 }).success).toBe(false)
|
||||
})
|
||||
|
||||
test('未知字段被 strip(zod 默认非 strict,safeParse 成功)', () => {
|
||||
test('unknown fields are stripped (zod default non-strict, safeParse succeeds)', () => {
|
||||
const r = workflowInputSchema.safeParse({ script: 'x', extra: 1 })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
test('maxConcurrency:1–16 整数合法;0/17/小数/非数字被拒', () => {
|
||||
test('maxConcurrency: integers 1-16 valid; 0/17/decimal/non-number rejected', () => {
|
||||
for (const n of [1, 3, 5, 16]) {
|
||||
expect(workflowInputSchema.safeParse({ maxConcurrency: n }).success).toBe(
|
||||
true,
|
||||
@@ -57,6 +57,6 @@ test('maxConcurrency:1–16 整数合法;0/17/小数/非数字被拒', () =>
|
||||
}
|
||||
})
|
||||
|
||||
test('maxConcurrency optional(省略时 safeParse 成功)', () => {
|
||||
test('maxConcurrency optional (safeParse succeeds when omitted)', () => {
|
||||
expect(workflowInputSchema.safeParse({ script: 'x' }).success).toBe(true)
|
||||
})
|
||||
|
||||
@@ -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`,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
|
||||
// 直接构造类型形状,验证 JSON 往返(resume 持久化的核心要求)。
|
||||
test('AgentRunResult ok 分支可 JSON 往返', () => {
|
||||
// Directly construct type shapes to verify JSON round-trip (core requirement for resume persistence).
|
||||
test('AgentRunResult ok branch can JSON round-trip', () => {
|
||||
const result = {
|
||||
kind: 'ok' as const,
|
||||
output: { confirmed: true },
|
||||
@@ -12,15 +12,15 @@ test('AgentRunResult ok 分支可 JSON 往返', () => {
|
||||
expect(round.kind).toBe('ok')
|
||||
})
|
||||
|
||||
test('AgentRunResult skipped/dead 分支可 JSON 往返', () => {
|
||||
test('AgentRunResult skipped/dead branch can JSON round-trip', () => {
|
||||
for (const kind of ['skipped', 'dead'] as const) {
|
||||
const round = JSON.parse(JSON.stringify({ kind }))
|
||||
expect(round.kind).toBe(kind)
|
||||
}
|
||||
})
|
||||
|
||||
// dead 携带可选 reason/detail:journal 持久化后能保留死因,事后审计/面板展示用。
|
||||
test('AgentRunResult dead 带 reason/detail 可 JSON 往返', () => {
|
||||
// dead carries optional reason/detail: journal persistence preserves cause of death for post-hoc audit / panel display.
|
||||
test('AgentRunResult dead with reason/detail can JSON round-trip', () => {
|
||||
const dead = {
|
||||
kind: 'dead' as const,
|
||||
reason: 'no-structured-output' as const,
|
||||
@@ -32,8 +32,8 @@ test('AgentRunResult dead 带 reason/detail 可 JSON 往返', () => {
|
||||
expect(round.reason).toBe('no-structured-output')
|
||||
})
|
||||
|
||||
// 兼容旧 journal:reason/detail 都可选,缺失时仍是合法 dead。
|
||||
test('AgentRunResult dead 无 reason 仍合法(兼容旧 journal)', () => {
|
||||
// Backward compatible with old journals: reason/detail both optional, missing is still valid dead.
|
||||
test('AgentRunResult dead without reason is still valid (backward compatible with old journal)', () => {
|
||||
const legacy = { kind: 'dead' as const }
|
||||
const round = JSON.parse(JSON.stringify(legacy))
|
||||
expect(round.kind).toBe('dead')
|
||||
@@ -41,7 +41,7 @@ test('AgentRunResult dead 无 reason 仍合法(兼容旧 journal)', () => {
|
||||
expect(round.detail).toBeUndefined()
|
||||
})
|
||||
|
||||
test('JournalEntry 形状稳定', () => {
|
||||
test('JournalEntry shape is stable', () => {
|
||||
const entry = {
|
||||
key: 'abc123',
|
||||
result: { kind: 'ok', output: 'text', usage: { outputTokens: 1 } },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Agent 后端适配器抽象。引擎通过 registry 取 adapter 再调 run,不关心具体实现
|
||||
// (Anthropic SDK / 核心 runAgent / OpenAI / 本地模型 / mock 均为 adapter 的实现)。
|
||||
// Agent backend adapter abstraction. The engine takes an adapter from the registry via resolve then calls run; it does not care about the concrete implementation
|
||||
// (Anthropic SDK / core runAgent / OpenAI / local model / mock are all adapter implementations).
|
||||
import type {
|
||||
AgentProgressUpdate,
|
||||
AgentRunParams,
|
||||
@@ -7,68 +7,68 @@ import type {
|
||||
} from './types.js'
|
||||
import type { HostHandle } from './ports.js'
|
||||
|
||||
/** adapter 能力声明。引擎/脚本据此降级(如后端不支持 schema 则改文本 + 解析)。 */
|
||||
/** Adapter capability declaration. The engine/script degrades based on this (e.g. if the backend does not support schema, switch to text + parse). */
|
||||
export type AgentAdapterCapabilities = {
|
||||
/** 支持 schema 结构化输出(agent(schema) 直接返回对象)。 */
|
||||
/** Supports schema structured output (agent(schema) returns an object directly). */
|
||||
structuredOutput: boolean
|
||||
/** 支持工具调用(仅核心 agent 后端有)。 */
|
||||
/** Supports tool calling (only the core agent backend has this). */
|
||||
tools?: boolean
|
||||
/** 支持流式(v1 引擎不消费,预留)。 */
|
||||
/** Supports streaming (the v1 engine does not consume it; reserved). */
|
||||
stream?: boolean
|
||||
}
|
||||
|
||||
/** adapter.run 的上下文。 */
|
||||
/** Context for adapter.run. */
|
||||
export type AgentAdapterContext = {
|
||||
/** 透传的不透明 host 句柄(核心 adapter 用;独立后端忽略)。 */
|
||||
/** Opaque host handle passed through (used by the core adapter; ignored by standalone backends). */
|
||||
host: HostHandle
|
||||
/** 取消信号(与 workflow signal 一致)。 */
|
||||
/** Cancellation signal (same as the workflow signal). */
|
||||
signal: AbortSignal
|
||||
/** 当前 workflow runId(日志/追踪用)。 */
|
||||
/** Current workflow runId (for logging/tracing). */
|
||||
runId: string
|
||||
/**
|
||||
* 引擎层 agent 序号(hooks.agentIdSeq 递增;面板 RunProgress.agents[].id 同源)。
|
||||
* 注意:与 backend 内部创建的 core AgentId(字符串,子 agent 跟踪用)是两个不同概念,
|
||||
* 不可混用。本字段用于 registerAgentAbort/unregisterAgentAbort 的 key,让 service
|
||||
* .kill(runId, agentId) 能精确路由到 backend 创建的 AbortController。
|
||||
* Engine-layer agent sequence number (incremented by hooks.agentIdSeq; same source as panel RunProgress.agents[].id).
|
||||
* Note: this is a different concept from the core AgentId (a string, used for sub-agent tracking) created internally by the backend;
|
||||
* do not mix them. This field is the key for registerAgentAbort/unregisterAgentAbort, so that service
|
||||
* .kill(runId, agentId) can precisely route to the AbortController created by the backend.
|
||||
*/
|
||||
agentId: number
|
||||
/**
|
||||
* 运行中进度上报(后端循环累计 token/tool 时调用)。可选:独立后端可不实现;
|
||||
* 引擎据此发 agent_progress 事件(闭包带 agentId/runId 关联),面板实时刷新。
|
||||
* In-progress reporting (called by the backend loop as it accumulates tokens/tools). Optional: standalone backends may not implement it;
|
||||
* the engine emits the agent_progress event based on this (closure carries agentId/runId for correlation), and the panel refreshes in real time.
|
||||
*/
|
||||
onProgress?: (update: AgentProgressUpdate) => void
|
||||
/**
|
||||
* 注册 agent 级 AbortController(可选)。后端创建 controller 后调此注入 Map,
|
||||
* 让 service.kill(runId, agentId) 能精确中断单个 agent 而不影响其他。
|
||||
* 由 hooks.agent 在 backend.run 调用前注入。
|
||||
* Register an agent-level AbortController (optional). The backend calls this after creating the controller to inject it into a Map,
|
||||
* so that service.kill(runId, agentId) can precisely abort a single agent without affecting others.
|
||||
* Injected by hooks.agent before backend.run is called.
|
||||
*/
|
||||
registerAgentAbort?: (agentId: number, ac: AbortController) => void
|
||||
/**
|
||||
* 注销 agent 级 AbortController(agent 完成或失败时调;幂等)。
|
||||
* 与 registerAgentAbort 配对。
|
||||
* Unregister an agent-level AbortController (called when the agent completes or fails; idempotent).
|
||||
* Paired with registerAgentAbort.
|
||||
*/
|
||||
unregisterAgentAbort?: (agentId: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 后端适配器。引擎只依赖此接口;具体后端实现它并注册到 registry。
|
||||
* initialize/dispose 为可选生命周期(连接池/资源管理),由调用方通过
|
||||
* registry.initializeAll/disposeAll 触发。
|
||||
* Agent backend adapter. The engine only depends on this interface; concrete backends implement it and register into the registry.
|
||||
* initialize/dispose are optional lifecycle hooks (connection pool / resource management), triggered by the caller via
|
||||
* registry.initializeAll/disposeAll.
|
||||
*/
|
||||
export interface AgentAdapter {
|
||||
/** 唯一标识(registry 路由 / 日志)。 */
|
||||
/** Unique identifier (registry routing / logging). */
|
||||
readonly id: string
|
||||
/** 能力声明。 */
|
||||
/** Capability declaration. */
|
||||
readonly capabilities: AgentAdapterCapabilities
|
||||
/** 执行一次 agent 调用。 */
|
||||
/** Execute one agent call. */
|
||||
run(params: AgentRunParams, ctx: AgentAdapterContext): Promise<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 {
|
||||
}
|
||||
|
||||
/**
|
||||
* 多后端 registry。register 注册 adapter,route/default 配路由,resolve 按
|
||||
* 规则顺序匹配选 adapter。adapter 的 lifecycle(initialize/dispose)通过
|
||||
* initializeAll/disposeAll 统一触发(由调用方在运行前后调)。
|
||||
* Multi-backend registry. register registers an adapter, route/default configure routing, and resolve picks an adapter by
|
||||
* matching rules in order. The adapter lifecycle (initialize/dispose) is triggered uniformly via
|
||||
* initializeAll/disposeAll (called by the caller before/after the run).
|
||||
*/
|
||||
export class AgentAdapterRegistry {
|
||||
private readonly adapters = new Map<string, AgentAdapter>()
|
||||
private readonly rules: AdapterRouteRule[] = []
|
||||
private defaultId: string | null = null
|
||||
|
||||
/** 注册一个 adapter(id 重复则覆盖)。链式。 */
|
||||
/** Register an adapter (duplicate id overwrites). Chainable. */
|
||||
register(adapter: AgentAdapter): this {
|
||||
this.adapters.set(adapter.id, adapter)
|
||||
return this
|
||||
}
|
||||
|
||||
/** 设默认 adapter(无规则命中时用)。链式。 */
|
||||
/** Set the default adapter (used when no rule matches). Chainable. */
|
||||
default(adapterId: string): this {
|
||||
this.defaultId = adapterId
|
||||
return this
|
||||
}
|
||||
|
||||
/** 加一条路由规则(按添加顺序匹配)。链式。 */
|
||||
/** Add a routing rule (matched in insertion order). Chainable. */
|
||||
route(rule: AdapterRouteRule): this {
|
||||
this.rules.push(rule)
|
||||
return this
|
||||
@@ -122,7 +122,7 @@ export class AgentAdapterRegistry {
|
||||
return this.adapters.get(id)
|
||||
}
|
||||
|
||||
/** 按规则匹配;第一个命中返回;无命中走 default;都没有抛 AdapterNotFoundError。 */
|
||||
/** Match by rules; return the first hit; if no hit, go to default; if neither, throw AdapterNotFoundError. */
|
||||
resolve(params: AgentRunParams): AgentAdapter {
|
||||
for (const rule of this.rules) {
|
||||
if (matchRule(rule, params)) {
|
||||
@@ -135,18 +135,18 @@ export class AgentAdapterRegistry {
|
||||
if (fallback) return fallback
|
||||
}
|
||||
throw new AdapterNotFoundError(
|
||||
`无 adapter 匹配(rules=${this.rules.length}, default=${this.defaultId ?? '无'})`,
|
||||
`No adapter matched (rules=${this.rules.length}, default=${this.defaultId ?? 'none'})`,
|
||||
)
|
||||
}
|
||||
|
||||
/** 触发所有 adapter 的 initialize(跳过未实现的)。 */
|
||||
/** Trigger initialize on all adapters (skips unimplemented ones). */
|
||||
async initializeAll(): Promise<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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 引擎级可预期错误(脚本错、上限、嵌套)。 */
|
||||
/** Engine-level expected errors (script errors, caps, nesting). */
|
||||
export class WorkflowError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
@@ -6,10 +6,10 @@ export class WorkflowError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** workflow 被 abort(kill)。 */
|
||||
/** workflow was aborted (killed). */
|
||||
export class WorkflowAbortedError extends Error {
|
||||
constructor() {
|
||||
super('workflow 已被取消(abort)')
|
||||
super('workflow has been aborted')
|
||||
this.name = 'WorkflowAbortedError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { WorkflowAbortedError, WorkflowError } from './errors.js'
|
||||
import { agentCallKey } from './journal.js'
|
||||
import type { WorkflowHooks } from './script.js'
|
||||
|
||||
/** workflow() 钩子的子 workflow 执行器(由 runWorkflow 注入,避免循环依赖)。 */
|
||||
/** Sub-workflow executor for the workflow() hook (injected by runWorkflow to avoid circular dependencies). */
|
||||
export type SubWorkflowRunner = (opts: {
|
||||
name?: string
|
||||
scriptPath?: string
|
||||
@@ -44,7 +44,7 @@ export function makeHooks(
|
||||
ctx: EngineContext,
|
||||
runSubWorkflow: SubWorkflowRunner,
|
||||
): WorkflowHooks {
|
||||
// 所有进度事件自动注入 runId,供 adapter 路由到对应 task(多并发 workflow)
|
||||
// All progress events auto-inject runId so the adapter can route them to the corresponding task (multiple concurrent workflows)
|
||||
const emit = (init: HookProgressInit): void => {
|
||||
ctx.ports.progressEmitter.emit({
|
||||
runId: ctx.runId,
|
||||
@@ -56,11 +56,11 @@ export function makeHooks(
|
||||
const r = ctx.resources
|
||||
if (r.agentCountBox.value >= MAX_TOTAL_AGENTS) {
|
||||
throw new WorkflowError(
|
||||
`workflow 超过 agent 总数上限 (${MAX_TOTAL_AGENTS})`,
|
||||
`workflow exceeds total agent cap (${MAX_TOTAL_AGENTS})`,
|
||||
)
|
||||
}
|
||||
|
||||
// 每次 agent() 调用分配唯一 id(含 journal 命中),盖戳 started/done 供 reducer 精确关联
|
||||
// Assign a unique id to each agent() call (including journal hits); stamp started/done so the reducer can associate them precisely
|
||||
const agentId = r.agentIdSeq.value++
|
||||
|
||||
const params: AgentRunParams = { prompt, ...opts }
|
||||
@@ -69,7 +69,7 @@ export function makeHooks(
|
||||
const phase =
|
||||
(opts.phase as string | undefined) ?? ctx.currentPhase ?? undefined
|
||||
|
||||
// journal 命中 → 直接返回缓存
|
||||
// Journal hit -> return cached result directly
|
||||
if (!ctx.journalInvalidated && ctx.journalIndex < ctx.journal.length) {
|
||||
const entry = ctx.journal[ctx.journalIndex]!
|
||||
if (entry.key === key) {
|
||||
@@ -83,7 +83,7 @@ export function makeHooks(
|
||||
})
|
||||
return resultToOutput(entry.result)
|
||||
}
|
||||
// 发散:丢弃后续 journal,后续全部现场跑
|
||||
// Divergence: discard subsequent journal entries; everything from here on runs live
|
||||
ctx.journalInvalidated = true
|
||||
ctx.journal = ctx.journal.slice(0, ctx.journalIndex)
|
||||
await ctx.ports.journalStore.truncate(ctx.runId)
|
||||
@@ -93,14 +93,14 @@ export function makeHooks(
|
||||
try {
|
||||
release = await ctx.resources.semaphore.acquire(ctx.signal)
|
||||
} catch {
|
||||
// abort 期间在队列中等待:semaphore 已把 waiter 移除、未消耗 permit
|
||||
// Queued wait during abort: the semaphore already removed the waiter and did not consume a permit
|
||||
throw new WorkflowAbortedError()
|
||||
}
|
||||
try {
|
||||
if (ctx.signal.aborted) throw new WorkflowAbortedError()
|
||||
// 预算检查在 semaphore 临界区内:queued waiter 被唤醒后看到最新 spent,
|
||||
// 否则 N 个 waiter 入队时 spent=0 全过检,唤醒后无 re-check 全部超支。
|
||||
// journal 命中路径不扣预算,无需检查。
|
||||
// Budget check inside the semaphore critical section: a queued waiter sees the latest spent when woken,
|
||||
// otherwise N waiters enqueued while spent=0 all pass the check and overspend on wake-up without re-check.
|
||||
// Journal-hit path does not charge budget and needs no check.
|
||||
r.budget.assertCanSpend()
|
||||
|
||||
const pending = ctx.ports.taskRegistrar.pendingAction(ctx.runId)
|
||||
@@ -113,14 +113,14 @@ export function makeHooks(
|
||||
ctx.resources.agentCountBox.value++
|
||||
emit({ type: 'agent_started', agentId, label, phase })
|
||||
const registry = ctx.ports.agentAdapterRegistry
|
||||
// onProgress 闭包:后端循环累计 token/tool → 发 agent_progress 事件(带 agentId 关联)
|
||||
// onProgress closure: the backend loop accumulates token/tool counts -> emits an agent_progress event (carrying agentId for association)
|
||||
const onProgress = (update: AgentProgressUpdate): void => {
|
||||
emit({ type: 'agent_progress', agentId, label, phase, ...update })
|
||||
}
|
||||
// 注入 agent 级 AbortController 注册/注销:backend 创建 controller 后调
|
||||
// registerAgentAbort 注入 ports 层 bindings,service.kill(runId, agentId) 据此
|
||||
// 精确中断单个 agent。registry 不存在(agentRunner 兜底路径)时无 backend 中间层,
|
||||
// ports 层 agentAbortControllers 永远空——单 agent kill 在该路径降级为 no-op。
|
||||
// Inject agent-level AbortController register/unregister: the backend creates the controller then calls
|
||||
// registerAgentAbort to inject ports-layer bindings; service.kill(runId, agentId) uses this to
|
||||
// precisely abort a single agent. When the registry is absent (agentRunner fallback path), there is no backend middle layer,
|
||||
// and agentAbortControllers at the ports layer is always empty — single-agent kill degrades to a no-op on this path.
|
||||
const adapterCtx = registry
|
||||
? {
|
||||
host: ctx.host,
|
||||
@@ -154,22 +154,22 @@ export function makeHooks(
|
||||
: {}),
|
||||
}
|
||||
: null
|
||||
// resolve 在 try 外:配置错(AdapterNotFoundError 等)直接上抛,不走重试——
|
||||
// 这是 workflow 配置问题而非 backend 临时故障,重试无意义且掩盖 bug。
|
||||
// resolve is outside the try: configuration errors (e.g. AdapterNotFoundError) propagate directly without retry —
|
||||
// this is a workflow configuration problem, not a transient backend failure; retrying is meaningless and would mask the bug.
|
||||
const adapter = registry ? registry.resolve(params) : null
|
||||
const invokeBackend = (): Promise<AgentRunResult> =>
|
||||
adapter
|
||||
? adapter.run(params, adapterCtx!)
|
||||
: ctx.ports.agentRunner.runAgentToResult(params, ctx.host)
|
||||
|
||||
// 失败一次自动重试:dead(terminal API error after retries)或 非 abort 抛错
|
||||
// 都给一次重试机会;WorkflowAbortedError(kill)不重试——是用户意图。
|
||||
// 重试仍失败:dead 保持 dead;throw 降级为 dead(不让一个 agent 击穿 workflow)。
|
||||
// budget 不重复扣:dead 不 addOutputTokens;重试 ok 才扣一次(最终 ok 时)。
|
||||
// dead.reason 透传到日志:no-structured-output(agent 最终文本块没产 plain-object JSON)
|
||||
// 是高频死因;detail 进日志能立刻看到 agent 最后说了什么。
|
||||
// detail 用 String() 包裹防御:旧 journal 或第三方 adapter 可能写入非 string(损坏数据),
|
||||
// 直接 .slice 会抛 TypeError 击穿日志路径。
|
||||
// Auto-retry once on failure: dead (terminal API error after retries) or a non-abort throw
|
||||
// both get one retry chance; WorkflowAbortedError (kill) is not retried — it is the user's intent.
|
||||
// If retry still fails: dead stays dead; a throw degrades to dead (one agent must not take down the workflow).
|
||||
// budget is not double-charged: dead does not call addOutputTokens; retry-ok charges once (at the final ok).
|
||||
// dead.reason is passed through to the log: no-structured-output (the agent's final text block did not produce plain-object JSON)
|
||||
// is a high-frequency cause of death; logging detail lets you immediately see what the agent last said.
|
||||
// detail is wrapped with String() defensively: old journals or third-party adapters may write non-strings (corrupted data),
|
||||
// and calling .slice directly would throw a TypeError that pierces the logging path.
|
||||
let result: AgentRunResult
|
||||
try {
|
||||
result = await invokeBackend()
|
||||
@@ -194,7 +194,7 @@ export function makeHooks(
|
||||
result = await invokeBackend()
|
||||
} catch (e2) {
|
||||
if (e2 instanceof WorkflowAbortedError) throw e2
|
||||
// 重试仍抛:降级 dead(保持 workflow 继续;hooks.agent 返 null)
|
||||
// Retry still threw: degrade to dead (keep the workflow going; hooks.agent returns null)
|
||||
result = {
|
||||
kind: 'dead',
|
||||
reason: 'runagent-threw',
|
||||
@@ -208,8 +208,8 @@ export function makeHooks(
|
||||
emit({ type: 'agent_done', agentId, label, phase, result })
|
||||
|
||||
const entry: JournalEntry = { key, seq: agentId, result }
|
||||
// 关键:push 顺序 = 完成顺序(非调用顺序);read() 已按 seq 重排,
|
||||
// 因此 resume 时调用顺序与 journal 顺序对齐,key 索引稳定。
|
||||
// Key point: push order = completion order (not call order); read() already re-sorts by seq,
|
||||
// so during resume the call order aligns with the journal order and the key index stays stable.
|
||||
ctx.journal.push(entry)
|
||||
ctx.journalIndex++
|
||||
await ctx.ports.journalStore.append(ctx.runId, entry)
|
||||
@@ -222,7 +222,7 @@ export function makeHooks(
|
||||
const parallel: WorkflowHooks['parallel'] = async thunks => {
|
||||
if (thunks.length > MAX_ITEMS_PER_CALL) {
|
||||
throw new WorkflowError(
|
||||
`parallel 超过单次调用 items 上限 (${MAX_ITEMS_PER_CALL})`,
|
||||
`parallel exceeds the per-call items cap (${MAX_ITEMS_PER_CALL})`,
|
||||
)
|
||||
}
|
||||
return Promise.all(
|
||||
@@ -230,7 +230,7 @@ export function makeHooks(
|
||||
try {
|
||||
return await t()
|
||||
} catch (e) {
|
||||
// "null on error"契约不变,但应 log——否则 workflow 作者无法定位为何 agent 失败
|
||||
// The "null on error" contract is unchanged, but it should log — otherwise the workflow author cannot locate why an agent failed
|
||||
ctx.ports.logger.warn?.(
|
||||
`parallel thunk #${i} failed: ${(e as Error).message}`,
|
||||
)
|
||||
@@ -248,7 +248,7 @@ export function makeHooks(
|
||||
): Promise<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'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from 'node:path'
|
||||
import type { JournalStore } from '../ports.js'
|
||||
import type { AgentRunParams, JournalEntry } from '../types.js'
|
||||
|
||||
/** 去掉纯展示字段后的规范化参数字符串。 */
|
||||
/** Canonical parameter string after removing display-only fields. */
|
||||
function canonicalParams(params: AgentRunParams): string {
|
||||
const { label: _label, phase: _phase, ...rest } = params
|
||||
const keys = Object.keys(rest).sort()
|
||||
@@ -13,14 +13,14 @@ function canonicalParams(params: AgentRunParams): string {
|
||||
return JSON.stringify(sorted)
|
||||
}
|
||||
|
||||
/** agent() 调用的确定性 key(prompt + 规范化 params 的 sha256)。 */
|
||||
/** Determinism key for an agent() call (sha256 of prompt + canonical params). */
|
||||
export function agentCallKey(prompt: string, params: AgentRunParams): string {
|
||||
return createHash('sha256')
|
||||
.update(prompt + '\n' + canonicalParams(params))
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
/** 文件式 JournalStore(jsonl,每个 run 一个目录)。纯 fs,无核心依赖。 */
|
||||
/** File-based JournalStore (jsonl, one directory per run). Pure fs, no core dependencies. */
|
||||
export function createFileJournalStore(runsDir: string): JournalStore {
|
||||
const pathOf = (runId: string) => join(runsDir, runId, 'journal.jsonl')
|
||||
|
||||
@@ -32,8 +32,8 @@ export function createFileJournalStore(runsDir: string): JournalStore {
|
||||
.split('\n')
|
||||
.filter(line => line.trim().length > 0)
|
||||
.map(line => JSON.parse(line) as JournalEntry)
|
||||
// parallel 完成顺序 ≠ 调用顺序;按 seq 重排,使 resume 期间 key 索引稳定。
|
||||
// 缺 seq 的旧 entry 视为 0(保持向前兼容,最坏情况下退化为文件顺序)。
|
||||
// parallel completion order ≠ call order; re-sort by seq so the key index is stable during resume.
|
||||
// Old entries missing seq are treated as 0 (forward compatibility; worst case degrades to file order).
|
||||
return entries.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
|
||||
} catch {
|
||||
return []
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ import { resolveNamedWorkflow } from './namedWorkflows.js'
|
||||
import { parseScript, type ParsedScript } from './script.js'
|
||||
|
||||
export type RunWorkflowOptions = {
|
||||
/** 已解析好的脚本源码。 */
|
||||
/** Already-resolved script source code. */
|
||||
script: string
|
||||
args?: unknown
|
||||
runId: string
|
||||
@@ -20,11 +20,11 @@ export type RunWorkflowOptions = {
|
||||
signal: AbortSignal
|
||||
cwd: string
|
||||
budgetTotal: number | null
|
||||
/** 单次 run 的并发槽位;undefined → DEFAULT_MAX_CONCURRENCY。 */
|
||||
/** Concurrency slots for a single run; undefined → DEFAULT_MAX_CONCURRENCY. */
|
||||
maxConcurrency?: number
|
||||
/** resume:true 时载入既有 journal 重放。 */
|
||||
/** resume: when true, load the existing journal and replay. */
|
||||
resume?: boolean
|
||||
/** resume 时脚本源码 hash 是否变化。true 则忽略 journal 全重跑。 */
|
||||
/** Whether the script source hash changed on resume. When true, ignore the journal and re-run everything. */
|
||||
scriptChanged?: boolean
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function runWorkflow(
|
||||
|
||||
const workflowName = opts.workflowName ?? parsed.meta?.name ?? 'workflow'
|
||||
|
||||
// 载入 journal(仅 resume 且脚本未变)
|
||||
// Load the journal (only on resume and when the script is unchanged)
|
||||
let journal: JournalEntry[] = []
|
||||
let journalInvalidated = false
|
||||
if (opts.resume && !opts.scriptChanged) {
|
||||
@@ -79,14 +79,16 @@ export async function runWorkflow(
|
||||
meta: parsed.meta,
|
||||
})
|
||||
|
||||
// 子 workflow 执行器:复用同一 ctx(共享 journal/并发/预算/计数),临时 +1 depth
|
||||
// Sub-workflow executor: reuses the same ctx (sharing journal/concurrency/budget/counters), temporarily +1 depth
|
||||
const runSubWorkflow: SubWorkflowRunner = async sub => {
|
||||
const script = await resolveSubScript(sub, opts.cwd)
|
||||
let subParsed: ParsedScript
|
||||
try {
|
||||
subParsed = parseScript(script)
|
||||
} catch (e) {
|
||||
throw new WorkflowError(`子 workflow 脚本错误:${(e as Error).message}`)
|
||||
throw new WorkflowError(
|
||||
`Sub-workflow script error: ${(e as Error).message}`,
|
||||
)
|
||||
}
|
||||
const prevDepth = ctx.resources.depth
|
||||
ctx.resources.depth += 1
|
||||
@@ -100,9 +102,9 @@ export async function runWorkflow(
|
||||
|
||||
const hooks = makeHooks(ctx, runSubWorkflow)
|
||||
|
||||
// hook.phase 只在切换 phase 时 emit 上一个 phase 的 phase_done;脚本结束时
|
||||
// currentPhase 是最后一个 phase,没有任何后续 phase() 触发其 phase_done → UI 左栏
|
||||
// 会永远显示 running(agent 列表已 ✓ done)。终态前补一条,所有 path 共用。
|
||||
// hook.phase only emits phase_done for the previous phase when switching phases; when the script ends,
|
||||
// currentPhase is the last phase, and there is no subsequent phase() to trigger its phase_done → the left pane of the UI
|
||||
// would stay running forever (the agent list already shows ✓ done). Emit one before the terminal state — shared by all paths.
|
||||
const emitTerminalPhaseDone = (): void => {
|
||||
if (!ctx.currentPhase) return
|
||||
ports.progressEmitter.emit({
|
||||
@@ -147,8 +149,8 @@ async function resolveSubScript(
|
||||
join(cwd, WORKFLOW_DIR_NAME),
|
||||
sub.name,
|
||||
)
|
||||
if (!found) throw new WorkflowError(`子 workflow "${sub.name}" 未找到`)
|
||||
if (!found) throw new WorkflowError(`Sub-workflow "${sub.name}" not found`)
|
||||
return found.content
|
||||
}
|
||||
throw new WorkflowError('workflow() 需要 name 或 scriptPath')
|
||||
throw new WorkflowError('workflow() requires name or scriptPath')
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 级 AbortController(agent 完成/失败时调;幂等)。
|
||||
* Unregister an agent-level AbortController (called when the agent completes/fails; idempotent).
|
||||
*/
|
||||
unregisterAgentAbort?(runId: string, agentId: number): void
|
||||
/**
|
||||
* 中断单个 agent。返回是否命中(false = agent 已完成/不存在)。
|
||||
* 不影响同 run 其他 agent,workflow 继续跑(被中断 agent 返回 dead → null)。
|
||||
* Abort a single agent. Returns whether it hit (false = agent already completed/does not exist).
|
||||
* Does not affect other agents in the same run; the workflow continues (the aborted agent returns dead → null).
|
||||
*/
|
||||
killAgent?(runId: string, agentId: number): boolean
|
||||
/** 返回当前待处理的 skip/retry 动作,或 null。 */
|
||||
/** Returns the current pending skip/retry action, or null. */
|
||||
pendingAction(runId: string): { kind: 'skip' | 'retry' } | null
|
||||
}
|
||||
|
||||
/** journal 持久化。 */
|
||||
/** Journal persistence. */
|
||||
export type JournalStore = {
|
||||
read(runId: string): Promise<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 agentRunner — hooks.agent routes
|
||||
* to adapter.run via the registry; when omitted, falls back to agentRunner (backward compatibility).
|
||||
*/
|
||||
agentAdapterRegistry?: AgentAdapterRegistry
|
||||
progressEmitter: ProgressEmitter
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,不阻断 run(script 已在内存)。
|
||||
// Inline entry: persist the script to the run directory and return a reusable path (the
|
||||
// inline -> persist -> edit -> resubmit-as-scriptPath iteration loop promised by the ultracode skill).
|
||||
// On write failure degrade to a placeholder + warn, do not abort the run (script is already in memory).
|
||||
if (!workflowFile && input.script) {
|
||||
try {
|
||||
workflowFile = await persistInlineScript(
|
||||
@@ -133,7 +135,7 @@ export function createWorkflowTool(
|
||||
}
|
||||
}
|
||||
|
||||
// detached 执行
|
||||
// Detached execution
|
||||
void runWorkflow({
|
||||
script,
|
||||
...(input.args !== undefined
|
||||
@@ -158,12 +160,12 @@ export function createWorkflowTool(
|
||||
return {
|
||||
data: {
|
||||
output: [
|
||||
'Workflow 已启动(后台执行)。',
|
||||
'Workflow started (running in the background).',
|
||||
`run_id: ${runId}`,
|
||||
`workflow: ${workflowName}`,
|
||||
`script: ${scriptPath}`,
|
||||
'',
|
||||
'完成时会自动通知。用 /workflows 查看实时进度。',
|
||||
'You will be notified on completion. Use /workflows to view live progress.',
|
||||
].join('\n'),
|
||||
},
|
||||
}
|
||||
@@ -207,8 +209,8 @@ function formatValue(v: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 防御性归一化 args:旧 `z.string()` 契约下模型可能发送字符串化的 JSON 对象。
|
||||
* 仅当字符串能 JSON.parse 出对象/数组时归一化;纯字符串、数字等保留原值。
|
||||
* Defensively normalize args: under the legacy `z.string()` contract the model may send a stringified JSON object.
|
||||
* Only normalize when the string JSON.parses to an object/array; plain strings, numbers, etc. are preserved as-is.
|
||||
*/
|
||||
function normalizeArgs(raw: unknown): unknown {
|
||||
if (typeof raw !== 'string') return raw
|
||||
@@ -230,7 +232,7 @@ async function resolveScriptSource(
|
||||
const resolved = resolve(cwd, input.scriptPath)
|
||||
if (!containsPath(cwd, resolved)) {
|
||||
throw new Error(
|
||||
`scriptPath "${input.scriptPath}" 越界(resolve 后 ${resolved} 不在 cwd ${cwd} 之内)`,
|
||||
`scriptPath "${input.scriptPath}" is out of bounds (after resolve, ${resolved} is not within cwd ${cwd})`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
@@ -241,7 +243,7 @@ async function resolveScriptSource(
|
||||
if (input.name) {
|
||||
if (sanitizeWorkflowName(input.name) === null) {
|
||||
throw new Error(
|
||||
`命名 workflow 名字 "${input.name}" 非法(含路径分隔符或为 . / ..)`,
|
||||
`Named workflow name "${input.name}" is invalid (contains path separators or is . / ..)`,
|
||||
)
|
||||
}
|
||||
const found = await resolveNamedWorkflow(
|
||||
@@ -250,10 +252,10 @@ async function resolveScriptSource(
|
||||
)
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`命名 workflow "${input.name}" 未找到(查找目录 ${WORKFLOW_DIR_NAME}/)`,
|
||||
`Named workflow "${input.name}" not found (looked in ${WORKFLOW_DIR_NAME}/)`,
|
||||
)
|
||||
}
|
||||
return { script: found.content, workflowFile: found.path }
|
||||
}
|
||||
throw new Error('必须提供 script、name 或 scriptPath 之一')
|
||||
throw new Error('One of script, name, or scriptPath must be provided')
|
||||
}
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
import { z } from 'zod/v4'
|
||||
|
||||
/** Workflow 工具输入 schema。args 为任意 JSON 值(对象/数组/字符串等)。 */
|
||||
/** Workflow tool input schema. args is any JSON value (object/array/string/etc.). */
|
||||
export const workflowInputSchema = z.object({
|
||||
script: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('自包含的 workflow 脚本源码(inline)'),
|
||||
.describe('Self-contained workflow script source (inline)'),
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('命名 workflow,解析到 .claude/workflows/<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 派生,避免手工 type 与 schema 漂移。
|
||||
* 旧实现里 {@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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 纯类型定义。无运行时依赖。
|
||||
// WorkflowInput 已迁移到 tool/schema.ts,用 z.infer 派生避免与 schema 漂移。
|
||||
// Pure type definitions. No runtime dependencies.
|
||||
// WorkflowInput has been migrated to tool/schema.ts and derived via z.infer to avoid drift from the schema.
|
||||
|
||||
/** 脚本 `export const meta = {...}` 的形状(必须是纯字面量)。 */
|
||||
/** Shape of the script's `export const meta = {...}` (must be a plain literal). */
|
||||
export type WorkflowMeta = {
|
||||
name: string
|
||||
description: string
|
||||
@@ -9,77 +9,77 @@ export type WorkflowMeta = {
|
||||
phases?: Array<{ title: string; detail?: string }>
|
||||
}
|
||||
|
||||
/** agent() 传给 AgentRunner 的参数。 */
|
||||
/** Parameters passed by agent() to the AgentRunner. */
|
||||
export type AgentRunParams = {
|
||||
prompt: string
|
||||
/** JSON Schema;提供时 agent 返回校验对象而非文本。 */
|
||||
/** JSON Schema; when provided, agent returns a validated object instead of text. */
|
||||
schema?: object
|
||||
model?: string
|
||||
/** 输出 token 上限(透传给 agent 后端,如 LLM 的 max_tokens)。 */
|
||||
/** Output token cap (passed through to the agent backend, e.g. LLM max_tokens). */
|
||||
maxTokens?: number
|
||||
/** 自定义子 agent 类型(从 registry 解析)。 */
|
||||
/** Custom subagent type (resolved from the registry). */
|
||||
agentType?: string
|
||||
isolation?: 'worktree'
|
||||
allowedTools?: string[]
|
||||
/** 仅展示用,不计入 journal key。 */
|
||||
/** Display-only; not part of the journal key. */
|
||||
label?: string
|
||||
/** 仅展示用,不计入 journal key。 */
|
||||
/** Display-only; not part of the journal key. */
|
||||
phase?: string
|
||||
}
|
||||
|
||||
/** agent 运行中进度快照(onProgress 回调载荷;后端循环累计 token/tool)。 */
|
||||
/** Progress snapshot while the agent is running (onProgress callback payload; backend loop accumulates tokens/tools). */
|
||||
export type AgentProgressUpdate = {
|
||||
tokenCount: number
|
||||
toolCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentRunner 返回。ok 变体携带 model/toolCount 供面板展示(可选,独立后端可不填)。
|
||||
* Returned by AgentRunner. The ok variant carries model/toolCount for panel display (optional; standalone backends may leave them blank).
|
||||
*
|
||||
* dead 携带可选 reason/detail:journal 历史只记 `{kind:"dead"}` 无信息,
|
||||
* 调试时无法区分"agent 跑完没产 StructuredOutput"还是"runAgent 抛错"。
|
||||
* reason 让 hooks 重试日志、面板、事后审计能立刻看到死因。
|
||||
* dead carries optional reason/detail: the journal history only records `{kind:"dead"}` with no info,
|
||||
* so during debugging you cannot distinguish "agent finished but produced no StructuredOutput" from "runAgent threw".
|
||||
* reason lets the hooks retry log, the panel, and post-hoc auditing see the cause of death immediately.
|
||||
*/
|
||||
export type AgentRunResult =
|
||||
| {
|
||||
kind: 'ok'
|
||||
output: string | object
|
||||
usage: { outputTokens: number }
|
||||
/** 实际解析后的 model id(展示用)。 */
|
||||
/** The actually-resolved model id (display-only). */
|
||||
model?: string
|
||||
/** agent 运行期间工具调用次数。 */
|
||||
/** Number of tool calls during the agent run. */
|
||||
toolCount?: number
|
||||
/** 完成时的 context 总 token 数(展示用;与 agent_progress 实时口径一致)。 */
|
||||
/** Total context tokens at completion (display-only; same basis as the real-time agent_progress). */
|
||||
tokenCount?: number
|
||||
}
|
||||
| { kind: 'skipped' }
|
||||
| {
|
||||
kind: 'dead'
|
||||
/**
|
||||
* 死因分类,方便日志聚合 / 事后审计。可选以兼容旧 journal。
|
||||
* - no-structured-output:agent 完成但 finalize content 无 StructuredOutput(既没调工具也没在文本里产 JSON)
|
||||
* - runagent-threw:runAgent 抛非 abort 错误(API 故障 / context 溢出 / runtime 错误)
|
||||
* - worktree-failed:isolation:'worktree' 创建失败(fail-closed 退化)
|
||||
* - unknown:未分类(兼容旧 backend / 第三方 adapter)
|
||||
* Cause-of-death classification for log aggregation / post-hoc auditing. Optional for backward compatibility with old journals.
|
||||
* - no-structured-output: agent finished but finalize content has no StructuredOutput (neither called tools nor produced JSON in text)
|
||||
* - runagent-threw: runAgent threw a non-abort error (API failure / context overflow / runtime error)
|
||||
* - worktree-failed: isolation:'worktree' creation failed (fail-closed degradation)
|
||||
* - unknown: unclassified (compatible with old backends / third-party adapters)
|
||||
*/
|
||||
reason?:
|
||||
| 'no-structured-output'
|
||||
| 'runagent-threw'
|
||||
| 'worktree-failed'
|
||||
| 'unknown'
|
||||
/** 详细信息(错误 message / 文本预览),用于日志,不展示给最终用户。 */
|
||||
/** Detail (error message / text preview) for logs; not shown to end users. */
|
||||
detail?: string
|
||||
}
|
||||
|
||||
/** journal 中单条记录。seq = agent() 调用序号,read() 据此重排以稳定 resume。 */
|
||||
/** A single record in the journal. seq = agent() call sequence number; read() re-sorts by it to stabilize resume. */
|
||||
export type JournalEntry = {
|
||||
key: string
|
||||
/** agent() 调用顺序(来自 agentIdSeq,跨 sub-workflow 单调递增)。 */
|
||||
/** agent() call order (from agentIdSeq; monotonically increasing across sub-workflows). */
|
||||
seq: number
|
||||
result: AgentRunResult
|
||||
}
|
||||
|
||||
/** 进度事件。所有变体携带 runId,供 adapter 路由到对应 task(多并发 workflow)。 */
|
||||
/** Progress events. All variants carry runId so the adapter can route to the corresponding task (multiple concurrent workflows). */
|
||||
export type ProgressEvent =
|
||||
| {
|
||||
type: 'run_started'
|
||||
@@ -122,7 +122,7 @@ export type ProgressEvent =
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** 引擎运行结果。 */
|
||||
/** Engine run result. */
|
||||
export type WorkflowRunResult = {
|
||||
status: 'completed' | 'failed' | 'killed'
|
||||
returnValue?: unknown
|
||||
|
||||
Reference in New Issue
Block a user