mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +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
|
||||
|
||||
@@ -10,36 +10,36 @@ import { truncateLabel } from '../panel/AgentList.js';
|
||||
import { STATUS_DOT } from '../panel/status.js';
|
||||
import { __resetWorkflowServiceForTests, getWorkflowService } from '../service.js';
|
||||
|
||||
// 纯函数:选中夹紧到有效区间(与面板内 clampSelected 同源)。
|
||||
test('clampSelected:空列表→0;越界→末位;负/NaN→0;正常→原值', () => {
|
||||
// Pure function: clamp selection to valid range (same source as clampSelected inside the panel).
|
||||
test('clampSelected: empty list → 0; out of bounds → last; negative/NaN → 0; normal → original', () => {
|
||||
expect(clampSelected(5, 0)).toBe(0);
|
||||
expect(clampSelected(5, 3)).toBe(2);
|
||||
expect(clampSelected(-3, 3)).toBe(0);
|
||||
expect(clampSelected(1, 3)).toBe(1);
|
||||
expect(clampSelected(0, 1)).toBe(0);
|
||||
// NaN(如未初始化状态)安全回落到 0
|
||||
// NaN (e.g. uninitialized state) safely falls back to 0
|
||||
expect(clampSelected(Number.NaN, 3)).toBe(0);
|
||||
});
|
||||
|
||||
// truncateLabel:短 label 原样;含 `#数字` 后缀时保留后缀,前缀截断 + 省略号;
|
||||
// 无后缀则从右切。让 audit workflow 的 verify:${dim}#${idx} 多 finding 仍可区分。
|
||||
test('truncateLabel:短 label 原样;含 #数字 后缀保留后缀前缀截断;无后缀从右切', () => {
|
||||
// 短 label 原样
|
||||
// truncateLabel: short label as-is; with `#number` suffix keep suffix, truncate prefix + ellipsis;
|
||||
// without suffix, cut from the right. Lets audit workflow's verify:${dim}#${idx} multi-finding still be distinguishable.
|
||||
test('truncateLabel: short label as-is; with #number suffix keep suffix and truncate prefix; without suffix cut from right', () => {
|
||||
// short label as-is
|
||||
expect(truncateLabel('agent-1', 18)).toBe('agent-1');
|
||||
expect(truncateLabel('review:bugs', 18)).toBe('review:bugs');
|
||||
// 刚好 max 长度(边界)
|
||||
// exactly max length (boundary)
|
||||
expect(truncateLabel('review:correctness', 18)).toBe('review:correctness');
|
||||
// 超 max + 含 #数字 后缀:保留后缀,前缀截断 + 省略号
|
||||
// over max + with #number suffix: keep suffix, truncate prefix + ellipsis
|
||||
expect(truncateLabel('verify:correctness#0', 18)).toBe('verify:correctn…#0');
|
||||
expect(truncateLabel('verify:architecture#15', 18)).toBe('verify:archite…#15');
|
||||
// 多位 #idx 也能区分
|
||||
// multi-digit #idx also distinguishable
|
||||
expect(truncateLabel('verify:correctness#2', 18)).toBe('verify:correctn…#2');
|
||||
// 无 #数字 后缀:从右切(旧行为)
|
||||
// without #number suffix: cut from right (legacy behavior)
|
||||
expect(truncateLabel('a-very-long-label-no-suffix', 18)).toBe('a-very-long-label-');
|
||||
});
|
||||
|
||||
// STATUS_DOT 覆盖四种状态,且均为可见圆点字符。
|
||||
test('STATUS_DOT 覆盖 running/completed/failed/killed 且为非空字符', () => {
|
||||
// STATUS_DOT covers four states, all visible dot characters.
|
||||
test('STATUS_DOT covers running/completed/failed/killed and is non-empty character', () => {
|
||||
const statuses = ['running', 'completed', 'failed', 'killed'] as const;
|
||||
for (const s of statuses) {
|
||||
expect(STATUS_DOT[s]).toBeTruthy();
|
||||
@@ -47,9 +47,9 @@ test('STATUS_DOT 覆盖 running/completed/failed/killed 且为非空字符', ()
|
||||
}
|
||||
});
|
||||
|
||||
// 进度数据形态契约:面板读取的字段在典型 RunProgress 上存在/可读,
|
||||
// 防止 store.ts 结构漂移悄悄破坏面板渲染。
|
||||
test('RunProgress 字段契约:面板读取的 key 均存在', () => {
|
||||
// Progress data shape contract: fields read by the panel exist/are readable on a typical RunProgress,
|
||||
// preventing silent panel render breakage from store.ts structural drift.
|
||||
test('RunProgress field contract: keys read by panel all exist', () => {
|
||||
const run: RunProgress = {
|
||||
runId: 'r1',
|
||||
workflowName: 'review',
|
||||
@@ -62,7 +62,7 @@ test('RunProgress 字段契约:面板读取的 key 均存在', () => {
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
// 面板 WorkflowList/Detail 读取的路径
|
||||
// paths read by panel WorkflowList/Detail
|
||||
expect(run.status).toBe('running');
|
||||
expect(STATUS_DOT[run.status]).toBe('●');
|
||||
expect(run.currentPhase).toBe('Review');
|
||||
@@ -72,8 +72,8 @@ test('RunProgress 字段契约:面板读取的 key 均存在', () => {
|
||||
expect(run.agents[0]?.label).toBe('review:api');
|
||||
});
|
||||
|
||||
// 完成/失败形态:returnValue / error 在非 running 时才显示。
|
||||
test('RunProgress 完成/失败形态:returnValue/error 可选', () => {
|
||||
// Completed/failed shape: returnValue / error only shown when not running.
|
||||
test('RunProgress completed/failed shape: returnValue/error optional', () => {
|
||||
const completed: RunProgress = {
|
||||
runId: 'r2',
|
||||
workflowName: 'w',
|
||||
@@ -108,9 +108,9 @@ test('RunProgress 完成/失败形态:returnValue/error 可选', () => {
|
||||
expect(STATUS_DOT['failed']).toBe('✗');
|
||||
});
|
||||
|
||||
// 修复 M:useSyncExternalStore / listNamed / 子组件抛错时不应击穿 REPL。
|
||||
// panelCall 必须把 WorkflowsPanel 包在 SentryErrorBoundary 里。
|
||||
test('panelCall 用 SentryErrorBoundary 包裹 WorkflowsPanel(修复 M 回归)', async () => {
|
||||
// Fix M: useSyncExternalStore / listNamed / child component throwing should not break through REPL.
|
||||
// panelCall must wrap WorkflowsPanel in SentryErrorBoundary.
|
||||
test('panelCall wraps WorkflowsPanel in SentryErrorBoundary (fix M regression)', async () => {
|
||||
const element = (await (panelCall as unknown as (a: unknown, b: unknown, c: unknown) => Promise<React.ReactNode>)(
|
||||
() => {},
|
||||
{ canUseTool: undefined },
|
||||
@@ -126,12 +126,12 @@ test('panelCall 用 SentryErrorBoundary 包裹 WorkflowsPanel(修复 M 回归
|
||||
expect(typeof child.props.onDone).toBe('function');
|
||||
});
|
||||
|
||||
// ---- Task 6: 面板 mount 触发一次 loadPersistedRuns ----
|
||||
// 验证 WorkflowsPanel mount 时调 svc.loadPersistedRuns() 恰好一次。
|
||||
// service 内部 persistedLoaded flag 守护幂等;重渲染/重 mount 不重复调用。
|
||||
// 用 spy 替换单例的 loadPersistedRuns,渲染到 PassThrough 流,等 useEffect 触发。
|
||||
// ---- Task 6: panel mount triggers loadPersistedRuns once ----
|
||||
// Verify that WorkflowsPanel mount calls svc.loadPersistedRuns() exactly once.
|
||||
// The persistedLoaded flag inside service guards idempotency; re-render / re-mount does not repeat the call.
|
||||
// Use a spy to replace the singleton's loadPersistedRuns, render to a PassThrough stream, wait for useEffect to trigger.
|
||||
|
||||
test('WorkflowsPanel mount 触发一次 loadPersistedRuns', async () => {
|
||||
test('WorkflowsPanel mount triggers loadPersistedRuns once', async () => {
|
||||
__resetWorkflowServiceForTests();
|
||||
const svc = getWorkflowService();
|
||||
let calls = 0;
|
||||
@@ -141,7 +141,7 @@ test('WorkflowsPanel mount 触发一次 loadPersistedRuns', async () => {
|
||||
};
|
||||
|
||||
const stdout = new PassThrough();
|
||||
// 消费 data 避免 buffer 撑爆(render 会写多帧)
|
||||
// consume data to avoid buffer overflow (render writes multiple frames)
|
||||
stdout.on('data', () => {});
|
||||
let instance: { unmount: () => void; waitUntilExit: () => Promise<void> } | undefined;
|
||||
try {
|
||||
@@ -152,7 +152,7 @@ test('WorkflowsPanel mount 触发一次 loadPersistedRuns', async () => {
|
||||
}),
|
||||
{ stdout: stdout as unknown as NodeJS.WriteStream, patchConsole: false },
|
||||
);
|
||||
// mount 后 useEffect 异步触发;等 tick 让 React commit + effect 跑完
|
||||
// after mount useEffect triggers asynchronously; wait a tick for React commit + effect to complete
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
|
||||
expect(calls).toBe(1);
|
||||
@@ -163,35 +163,35 @@ test('WorkflowsPanel mount 触发一次 loadPersistedRuns', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// focused run 从 running 转 terminal 时面板自动 onDone()(800ms 延迟让用户看到终态)。
|
||||
// 仅同 runId 的状态转换触发:切到已完成 tab 不退出;打开历史面板也不退出。
|
||||
// 转换判定逻辑抽成 isRunTerminatedTransition 纯函数,便于离线单测(Ink test 模式不
|
||||
// 自动 pump concurrent 状态更新,集成测试不可靠)。
|
||||
test('isRunTerminatedTransition:同 runId running → terminal 触发;其它情况不触发', () => {
|
||||
// When the focused run transitions from running to terminal, the panel auto onDone() (800ms delay lets the user see the terminal state).
|
||||
// Only same-runId state transitions trigger: switching to a completed tab does not exit; opening history panel does not exit either.
|
||||
// Transition detection logic is extracted into the isRunTerminatedTransition pure function for offline unit testing (Ink test mode does not
|
||||
// auto-pump concurrent state updates, integration tests are unreliable).
|
||||
test('isRunTerminatedTransition: same runId running → terminal triggers; other cases do not trigger', () => {
|
||||
const running = { runId: 'r1', status: 'running' as const };
|
||||
const completed = { runId: 'r1', status: 'completed' as const };
|
||||
const failed = { runId: 'r1', status: 'failed' as const };
|
||||
const killed = { runId: 'r1', status: 'killed' as const };
|
||||
|
||||
// 同 run running → terminal:三种 terminal 都触发
|
||||
// same run running → terminal: all three terminal states trigger
|
||||
expect(isRunTerminatedTransition(running, completed)).toBe(true);
|
||||
expect(isRunTerminatedTransition(running, failed)).toBe(true);
|
||||
expect(isRunTerminatedTransition(running, killed)).toBe(true);
|
||||
|
||||
// prev=null(打开历史面板):不触发
|
||||
// prev=null (open history panel): does not trigger
|
||||
expect(isRunTerminatedTransition(null, completed)).toBe(false);
|
||||
// curr=null(runs 清空):不触发
|
||||
// curr=null (runs cleared): does not trigger
|
||||
expect(isRunTerminatedTransition(running, null)).toBe(false);
|
||||
|
||||
// 不同 runId(切 tab):不触发
|
||||
// different runId (switch tab): does not trigger
|
||||
expect(isRunTerminatedTransition({ runId: 'r1', status: 'running' }, { runId: 'r2', status: 'completed' })).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// 同 run 但 prev 非 running(已是 terminal 又重渲染):不触发
|
||||
// same run but prev not running (already terminal and re-rendered): does not trigger
|
||||
expect(isRunTerminatedTransition(completed, completed)).toBe(false);
|
||||
expect(isRunTerminatedTransition(killed, completed)).toBe(false);
|
||||
|
||||
// 同 run running → running(无变化):不触发
|
||||
// same run running → running (no change): does not trigger
|
||||
expect(isRunTerminatedTransition(running, running)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test, mock } from 'bun:test'
|
||||
|
||||
// 注意:mock specifier 必须解析到 impl 实际 import 的同一模块(bun mock.module
|
||||
// 按解析后模块匹配)。impl 用 '@claude-code-best/builtin-tools/...' 与 'src/*' 别名
|
||||
// 路径导入,此处用相同 specifier。
|
||||
// Note: mock specifier must resolve to the same module that impl actually imports (bun mock.module
|
||||
// matches by resolved module). impl uses '@claude-code-best/builtin-tools/...' and 'src/*' alias
|
||||
// path imports, so the same specifier is used here.
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
|
||||
() => ({
|
||||
@@ -43,10 +43,10 @@ mock.module('src/utils/uuid.js', () => ({ createAgentId: () => 'agent-1' }))
|
||||
mock.module('src/services/analytics/index.js', () => ({ logEvent: () => {} }))
|
||||
mock.module('src/utils/debug.js', () => ({ logForDebugging: () => {} }))
|
||||
|
||||
// isolation:'worktree' 测试用:mock worktree 三件套(避免真跑 git worktree add)。
|
||||
// 注意 mock.module 是 process-global;worktreeState 在工厂外定义供测试重置。
|
||||
// 不 mock cwd.js:runWithCwdOverride 真跑 AsyncLocalStorage 对 mock runAgent 无害,
|
||||
// 且避免污染同进程其他依赖 pwd/getCwd 的测试。
|
||||
// isolation:'worktree' tests: mock worktree trio (to avoid actually running git worktree add).
|
||||
// Note mock.module is process-global; worktreeState is defined outside the factory for test reset.
|
||||
// Do not mock cwd.js: runWithCwdOverride actually running AsyncLocalStorage is harmless to mocked runAgent,
|
||||
// and avoids polluting other tests in the same process that depend on pwd/getCwd.
|
||||
const worktreeState = {
|
||||
shouldThrow: false,
|
||||
hasChanges: false,
|
||||
@@ -104,7 +104,7 @@ function ctx() {
|
||||
}),
|
||||
} as never,
|
||||
canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never,
|
||||
// run() 不读 parentMessage;用空对象占位满足 WorkflowHostBundle 类型。
|
||||
// run() does not read parentMessage; use an empty object placeholder to satisfy the WorkflowHostBundle type.
|
||||
parentMessage: {} as never,
|
||||
}),
|
||||
signal: new AbortController().signal,
|
||||
@@ -113,20 +113,20 @@ function ctx() {
|
||||
}
|
||||
}
|
||||
|
||||
test('文本 agent → ok + token/tool/model 计量', async () => {
|
||||
test('text agent → ok + token/tool/model accounting', async () => {
|
||||
const res = await claudeCodeBackend.run({ prompt: 'do it' }, ctx())
|
||||
expect(res.kind).toBe('ok')
|
||||
if (res.kind === 'ok') {
|
||||
expect(res.output).toBe('agent-text')
|
||||
expect(res.usage.outputTokens).toBe(42)
|
||||
// 面板展示字段:tokenCount(=totalTokens) / toolCount / model(fallback mainLoopModel 'm')
|
||||
// panel display fields: tokenCount(=totalTokens) / toolCount / model (fallback mainLoopModel 'm')
|
||||
expect(res.tokenCount).toBe(42)
|
||||
expect(res.toolCount).toBe(3)
|
||||
expect(res.model).toBe('m')
|
||||
}
|
||||
})
|
||||
|
||||
test('isolation:worktree → 创建 worktree + 无变更自动清理;slug 匹配清理正则', async () => {
|
||||
test('isolation:worktree → create worktree + auto-cleanup on no changes; slug matches cleanup regex', async () => {
|
||||
worktreeState.shouldThrow = false
|
||||
worktreeState.hasChanges = false
|
||||
worktreeState.created = []
|
||||
@@ -138,13 +138,13 @@ test('isolation:worktree → 创建 worktree + 无变更自动清理;slug 匹
|
||||
)
|
||||
expect(res.kind).toBe('ok')
|
||||
expect(worktreeState.created).toHaveLength(1)
|
||||
// slug 必须匹配 cleanupStaleAgentWorktrees 的清理正则 ^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$
|
||||
// slug must match cleanupStaleAgentWorktrees cleanup regex ^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$
|
||||
expect(worktreeState.created[0]).toMatch(/^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$/)
|
||||
expect(worktreeState.changesCalls).toBe(1)
|
||||
expect(worktreeState.removed).toHaveLength(1) // 无变更 → auto-remove
|
||||
expect(worktreeState.removed).toHaveLength(1) // no changes → auto-remove
|
||||
})
|
||||
|
||||
test('isolation:worktree 有变更 → 保留 worktree(不 remove)', async () => {
|
||||
test('isolation:worktree has changes → keep worktree (no remove)', async () => {
|
||||
worktreeState.hasChanges = true
|
||||
worktreeState.created = []
|
||||
worktreeState.removed = []
|
||||
@@ -154,11 +154,11 @@ test('isolation:worktree 有变更 → 保留 worktree(不 remove)', async (
|
||||
ctx(),
|
||||
)
|
||||
expect(res.kind).toBe('ok')
|
||||
expect(worktreeState.removed).toHaveLength(0) // 有变更 → 保留
|
||||
expect(worktreeState.removed).toHaveLength(0) // has changes → keep
|
||||
expect(worktreeState.changesCalls).toBe(1)
|
||||
})
|
||||
|
||||
test('isolation:worktree 创建失败 → fail-closed 返 dead(不静默退化共享 cwd)', async () => {
|
||||
test('isolation:worktree creation fails → fail-closed returns dead (does not silently degrade to shared cwd)', async () => {
|
||||
worktreeState.shouldThrow = true
|
||||
const res = await claudeCodeBackend.run(
|
||||
{ prompt: 'do', isolation: 'worktree' },
|
||||
@@ -168,19 +168,19 @@ test('isolation:worktree 创建失败 → fail-closed 返 dead(不静默退化
|
||||
worktreeState.shouldThrow = false
|
||||
})
|
||||
|
||||
test('无 isolation → 不创建 worktree', async () => {
|
||||
test('no isolation → no worktree created', async () => {
|
||||
worktreeState.created = []
|
||||
const res = await claudeCodeBackend.run({ prompt: 'do' }, ctx())
|
||||
expect(res.kind).toBe('ok')
|
||||
expect(worktreeState.created).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('runAgent 抛错 → dead', async () => {
|
||||
// 覆盖 mock 让 runAgent 抛(last-write-wins)
|
||||
test('runAgent throws → dead', async () => {
|
||||
// override mock so runAgent throws (last-write-wins)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
|
||||
() => ({
|
||||
// biome-ignore lint/correctness/useYield: 故意抛错以测试 dead 分支(不 yield)
|
||||
// biome-ignore lint/correctness/useYield: intentionally throws to test dead branch (no yield)
|
||||
runAgent: async function* () {
|
||||
throw new Error('boom')
|
||||
},
|
||||
@@ -190,12 +190,12 @@ test('runAgent 抛错 → dead', async () => {
|
||||
expect(res.kind).toBe('dead')
|
||||
})
|
||||
|
||||
// 下面三组测试覆盖 'x' 无效修复:backend 必须把 ctx.signal 桥接到 runAgent.override
|
||||
// .abortController,并把 AbortError 识别为 abort(throw WorkflowAbortedError,而非吞成 dead)。
|
||||
// 还要验证 registerAgentAbort 注入,让 service.kill(runId, agentId) 能精确中断单个 agent。
|
||||
// The next three groups of tests cover the 'x' invalid fix: backend must bridge ctx.signal to runAgent.override
|
||||
// .abortController, and recognize AbortError as abort (throw WorkflowAbortedError, not swallow as dead).
|
||||
// Also verify registerAgentAbort injection so service.kill(runId, agentId) can precisely abort a single agent.
|
||||
|
||||
test('ctx.signal 预 abort → backend 桥接:override.abortController.signal.aborted=true', async () => {
|
||||
// 用 capturedOverride 暴露 backend 创建的 agentAbort(mock 收到的 override.abortController)
|
||||
test('ctx.signal pre-abort → backend bridge: override.abortController.signal.aborted=true', async () => {
|
||||
// use capturedOverride to expose the agentAbort created by backend (the override.abortController received by mock)
|
||||
let capturedController: AbortController | undefined
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
|
||||
@@ -213,8 +213,8 @@ test('ctx.signal 预 abort → backend 桥接:override.abortController.signal.
|
||||
)
|
||||
const parentAbort = new AbortController()
|
||||
parentAbort.abort()
|
||||
// mock 不抛 → backend 走正常返回路径;但桥接 `if (ctx.signal.aborted) agentAbort.abort()`
|
||||
// 已同步触发,capturedController.signal.aborted 必为 true(kill 桥接根因)
|
||||
// mock does not throw → backend takes the normal return path; but the bridge `if (ctx.signal.aborted) agentAbort.abort()`
|
||||
// has already triggered synchronously, capturedController.signal.aborted must be true (root cause of kill bridge)
|
||||
await claudeCodeBackend.run(
|
||||
{ prompt: 'pre-aborted' },
|
||||
{ ...ctx(), signal: parentAbort.signal },
|
||||
@@ -222,11 +222,11 @@ test('ctx.signal 预 abort → backend 桥接:override.abortController.signal.
|
||||
expect(capturedController?.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
test('runAgent 抛 AbortError → backend throw WorkflowAbortedError(不吞成 dead)', async () => {
|
||||
test('runAgent throws AbortError → backend throws WorkflowAbortedError (not swallowed as dead)', async () => {
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
|
||||
() => ({
|
||||
// biome-ignore lint/correctness/useYield: 故意抛 AbortError 测识别分支
|
||||
// biome-ignore lint/correctness/useYield: intentionally throws AbortError to test recognition branch
|
||||
runAgent: async function* () {
|
||||
const e = new Error('aborted by parent')
|
||||
e.name = 'AbortError'
|
||||
@@ -239,8 +239,8 @@ test('runAgent 抛 AbortError → backend throw WorkflowAbortedError(不吞成
|
||||
).rejects.toBeInstanceOf(WorkflowAbortedError)
|
||||
})
|
||||
|
||||
test('registerAgentAbort/unregisterAgentAbort 注入:key=ctx.agentId(数字),controller 来自桥接', async () => {
|
||||
// 恢复默认 mock(上一个测试把它改成抛 AbortError 了)
|
||||
test('registerAgentAbort/unregisterAgentAbort injection: key=ctx.agentId (number), controller from bridge', async () => {
|
||||
// restore default mock (previous test changed it to throw AbortError)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
|
||||
() => ({
|
||||
@@ -264,40 +264,40 @@ test('registerAgentAbort/unregisterAgentAbort 注入:key=ctx.agentId(数字
|
||||
},
|
||||
)
|
||||
expect(registered).toHaveLength(1)
|
||||
expect(registered[0]?.id).toBe(42) // 引擎数字 agentId(非 coreAgentId 字符串)
|
||||
expect(registered[0]?.id).toBe(42) // engine numeric agentId (not coreAgentId string)
|
||||
expect(registered[0]?.controller).toBeInstanceOf(AbortController)
|
||||
expect(unregistered).toEqual([42]) // finally 清理幂等
|
||||
expect(unregistered).toEqual([42]) // finally cleanup idempotent
|
||||
})
|
||||
|
||||
test('id 与 capabilities 形状', () => {
|
||||
test('id and capabilities shape', () => {
|
||||
expect(claudeCodeBackend.id).toBe('claude-code')
|
||||
expect(claudeCodeBackend.capabilities.structuredOutput).toBe(true)
|
||||
expect(claudeCodeBackend.capabilities.tools).toBe(true)
|
||||
})
|
||||
|
||||
test('resolveAgentDefinition:无 agentType → WORKFLOW_AGENT 兜底', () => {
|
||||
test('resolveAgentDefinition: no agentType → WORKFLOW_AGENT fallback', () => {
|
||||
const tuc = {
|
||||
options: { agentDefinitions: { activeAgents: [] } },
|
||||
} as never
|
||||
expect(resolveAgentDefinition(undefined, tuc)).toBe(WORKFLOW_AGENT)
|
||||
})
|
||||
|
||||
test('resolveAgentDefinition:命中 activeAgents', () => {
|
||||
test('resolveAgentDefinition: hits activeAgents', () => {
|
||||
const fake = { agentType: 'Explore', permissionMode: 'plan' } as never
|
||||
const tuc = {
|
||||
options: { agentDefinitions: { activeAgents: [fake] } },
|
||||
} as never
|
||||
expect(resolveAgentDefinition('Explore', tuc)).toBe(fake)
|
||||
// 未命中仍兜底
|
||||
// miss still falls back
|
||||
expect(resolveAgentDefinition('Nope', tuc)).toBe(WORKFLOW_AGENT)
|
||||
})
|
||||
|
||||
test('mapWorkflowModel 直传', () => {
|
||||
test('mapWorkflowModel passthrough', () => {
|
||||
expect(mapWorkflowModel(undefined)).toBeUndefined()
|
||||
expect(mapWorkflowModel('claude-haiku-*')).toBe('claude-haiku-*')
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:合法 JSON 提取;非法返回 null', () => {
|
||||
test('extractStructuredOutput: valid JSON extracted; invalid returns null', () => {
|
||||
expect(
|
||||
extractStructuredOutput([
|
||||
{ type: 'text', text: 'prefix {"a":1,"b":2} suffix' },
|
||||
@@ -309,7 +309,7 @@ test('extractStructuredOutput:合法 JSON 提取;非法返回 null', () => {
|
||||
expect(extractStructuredOutput([])).toBeNull()
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:fenced code block(剥围栏 + 剥语言标签)', () => {
|
||||
test('extractStructuredOutput: fenced code block (strip fence + strip language tag)', () => {
|
||||
expect(
|
||||
extractStructuredOutput([
|
||||
{
|
||||
@@ -318,13 +318,13 @@ test('extractStructuredOutput:fenced code block(剥围栏 + 剥语言标签
|
||||
},
|
||||
]),
|
||||
).toEqual({ findings: [{ title: 'x' }] })
|
||||
// 无语言标签
|
||||
// no language tag
|
||||
expect(
|
||||
extractStructuredOutput([{ type: 'text', text: '```\n{"a":1}\n```' }]),
|
||||
).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:嵌套对象(括号平衡扫描,原版 indexOf/lastIndexOf 会跨块拼接)', () => {
|
||||
test('extractStructuredOutput: nested object (bracket-balanced scan; legacy indexOf/lastIndexOf would cross-block concat)', () => {
|
||||
const text = 'Result: {"outer":{"inner":{"deep":true}},"n":3} trailing'
|
||||
expect(extractStructuredOutput([{ type: 'text', text }])).toEqual({
|
||||
outer: { inner: { deep: true } },
|
||||
@@ -332,8 +332,8 @@ test('extractStructuredOutput:嵌套对象(括号平衡扫描,原版 index
|
||||
})
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:字符串里的括号不当配对计', () => {
|
||||
// 字符串内的 } 不会让 depth 归零,扫描能跳到真正的配对 }
|
||||
test('extractStructuredOutput: brackets inside strings are not counted as pairing', () => {
|
||||
// } inside a string does not zero out depth, scan can skip to the real pairing }
|
||||
const text = '{"note":"this } char is in a string","ok":true}'
|
||||
expect(extractStructuredOutput([{ type: 'text', text }])).toEqual({
|
||||
note: 'this } char is in a string',
|
||||
@@ -341,7 +341,7 @@ test('extractStructuredOutput:字符串里的括号不当配对计', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:转义引号不破字符串边界', () => {
|
||||
test('extractStructuredOutput: escaped quotes do not break string boundary', () => {
|
||||
const text = '{"escaped":"he said \\"hi\\"","n":1}'
|
||||
expect(extractStructuredOutput([{ type: 'text', text }])).toEqual({
|
||||
escaped: 'he said "hi"',
|
||||
@@ -349,13 +349,13 @@ test('extractStructuredOutput:转义引号不破字符串边界', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:多个 JSON 块 → 返回第一个 parse 成功的', () => {
|
||||
// 第一个不平衡(无配对 }),跳到第二个
|
||||
test('extractStructuredOutput: multiple JSON blocks → return first parse success', () => {
|
||||
// first one unbalanced (no pairing }), skip to the second
|
||||
const text = 'broken { stuff\n{"real":1}\n{"ignored":2}'
|
||||
expect(extractStructuredOutput([{ type: 'text', text }])).toEqual({ real: 1 })
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:array / number / string / null 不算 object', () => {
|
||||
test('extractStructuredOutput: array / number / string / null do not count as object', () => {
|
||||
expect(
|
||||
extractStructuredOutput([{ type: 'text', text: '[1,2,3]' }]),
|
||||
).toBeNull()
|
||||
@@ -366,7 +366,7 @@ test('extractStructuredOutput:array / number / string / null 不算 object', (
|
||||
expect(extractStructuredOutput([{ type: 'text', text: 'null' }])).toBeNull()
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:多 text block → 跨块找第一个成功', () => {
|
||||
test('extractStructuredOutput: multiple text blocks → cross-block find first success', () => {
|
||||
expect(
|
||||
extractStructuredOutput([
|
||||
{ type: 'text', text: 'no json' },
|
||||
@@ -375,13 +375,13 @@ test('extractStructuredOutput:多 text block → 跨块找第一个成功', ()
|
||||
).toEqual({ k: 'v' })
|
||||
})
|
||||
|
||||
test('extractStructuredOutput:损坏 JSON 返回 null(不抛)', () => {
|
||||
test('extractStructuredOutput: broken JSON returns null (does not throw)', () => {
|
||||
expect(
|
||||
extractStructuredOutput([
|
||||
{ type: 'text', text: '{broken: missing quotes}' },
|
||||
]),
|
||||
).toBeNull()
|
||||
expect(
|
||||
extractStructuredOutput([{ type: 'text', text: '{"a":1,}' }]), // 尾逗号——不做语法修复
|
||||
extractStructuredOutput([{ type: 'text', text: '{"a":1,}' }]), // trailing comma — no syntax repair
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@ function makeRun(
|
||||
}
|
||||
|
||||
describe('installWorkflowNotifications', () => {
|
||||
test('running → completed 触发通知(含 workflow 名)', async () => {
|
||||
test('running → completed triggers notification (incl. workflow name)', async () => {
|
||||
const { installWorkflowNotifications } = await import('../notifications.js')
|
||||
const { service, emit, setRuns } = makeMockService([
|
||||
makeRun('r1', 'running'),
|
||||
@@ -64,7 +64,7 @@ describe('installWorkflowNotifications', () => {
|
||||
calls.push(msg),
|
||||
)
|
||||
|
||||
// 第一次 emit:listener 记录初始 running 状态,不发通知
|
||||
// first emit: listener records initial running state, no notification
|
||||
emit()
|
||||
expect(calls.length).toBe(0)
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('installWorkflowNotifications', () => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
test('running → failed 触发通知,含 error 文字', async () => {
|
||||
test('running → failed triggers notification, includes error text', async () => {
|
||||
const { installWorkflowNotifications } = await import('../notifications.js')
|
||||
const { service, emit, setRuns } = makeMockService([
|
||||
makeRun('r1', 'running'),
|
||||
@@ -86,7 +86,7 @@ describe('installWorkflowNotifications', () => {
|
||||
const calls: string[] = []
|
||||
installWorkflowNotifications(service, msg => calls.push(msg))
|
||||
|
||||
emit() // 记录初始 running
|
||||
emit() // record initial running
|
||||
setRuns([makeRun('r1', 'failed', { error: 'agent X boom' })])
|
||||
emit()
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('installWorkflowNotifications', () => {
|
||||
expect(calls[0]).toMatch(/agent X boom/)
|
||||
})
|
||||
|
||||
test('running → killed 触发通知', async () => {
|
||||
test('running → killed triggers notification', async () => {
|
||||
const { installWorkflowNotifications } = await import('../notifications.js')
|
||||
const { service, emit, setRuns } = makeMockService([
|
||||
makeRun('r1', 'running'),
|
||||
@@ -103,7 +103,7 @@ describe('installWorkflowNotifications', () => {
|
||||
const calls: string[] = []
|
||||
installWorkflowNotifications(service, msg => calls.push(msg))
|
||||
|
||||
emit() // 记录初始 running
|
||||
emit() // record initial running
|
||||
setRuns([makeRun('r1', 'killed')])
|
||||
emit()
|
||||
|
||||
@@ -111,20 +111,20 @@ describe('installWorkflowNotifications', () => {
|
||||
expect(calls[0]).toMatch(/was stopped/)
|
||||
})
|
||||
|
||||
test('初次见到 run(无 prev)不发通知(避免启动时通知历史 run)', async () => {
|
||||
test('first time seeing run (no prev) does not notify (avoid notifying historical runs on startup)', async () => {
|
||||
const { installWorkflowNotifications } = await import('../notifications.js')
|
||||
const { service, emit, setRuns } = makeMockService([])
|
||||
const calls: string[] = []
|
||||
installWorkflowNotifications(service, msg => calls.push(msg))
|
||||
|
||||
// 启动后第一次 emit,看到 r1 已 completed——不应通知(不是从 running 转换来)
|
||||
// first emit after startup, sees r1 already completed — should not notify (not a transition from running)
|
||||
setRuns([makeRun('r1', 'completed')])
|
||||
emit()
|
||||
|
||||
expect(calls.length).toBe(0)
|
||||
})
|
||||
|
||||
test('running → running 不发通知', async () => {
|
||||
test('running → running does not notify', async () => {
|
||||
const { installWorkflowNotifications } = await import('../notifications.js')
|
||||
const { service, emit, setRuns } = makeMockService([
|
||||
makeRun('r1', 'running'),
|
||||
@@ -132,14 +132,14 @@ describe('installWorkflowNotifications', () => {
|
||||
const calls: string[] = []
|
||||
installWorkflowNotifications(service, msg => calls.push(msg))
|
||||
|
||||
emit() // 记录初始 running
|
||||
emit() // record initial running
|
||||
setRuns([makeRun('r1', 'running', { agentCount: 1 })])
|
||||
emit()
|
||||
|
||||
expect(calls.length).toBe(0)
|
||||
})
|
||||
|
||||
test('已 completed 的 run 再次 emit 不重复通知', async () => {
|
||||
test('already completed run emitting again does not repeat notification', async () => {
|
||||
const { installWorkflowNotifications } = await import('../notifications.js')
|
||||
const { service, emit, setRuns } = makeMockService([
|
||||
makeRun('r1', 'running'),
|
||||
@@ -147,7 +147,7 @@ describe('installWorkflowNotifications', () => {
|
||||
const calls: string[] = []
|
||||
installWorkflowNotifications(service, msg => calls.push(msg))
|
||||
|
||||
emit() // 记录初始 running
|
||||
emit() // record initial running
|
||||
setRuns([makeRun('r1', 'completed')])
|
||||
emit()
|
||||
expect(calls.length).toBe(1)
|
||||
@@ -156,7 +156,7 @@ describe('installWorkflowNotifications', () => {
|
||||
expect(calls.length).toBe(1)
|
||||
})
|
||||
|
||||
test('unsubscribe 后不再发通知', async () => {
|
||||
test('after unsubscribe no more notifications', async () => {
|
||||
const { installWorkflowNotifications } = await import('../notifications.js')
|
||||
const { service, emit, setRuns } = makeMockService([
|
||||
makeRun('r1', 'running'),
|
||||
@@ -166,7 +166,7 @@ describe('installWorkflowNotifications', () => {
|
||||
calls.push(msg),
|
||||
)
|
||||
|
||||
emit() // 记录初始 running
|
||||
emit() // record initial running
|
||||
unsubscribe()
|
||||
setRuns([makeRun('r1', 'completed')])
|
||||
emit()
|
||||
|
||||
@@ -33,7 +33,7 @@ function makeRun(over: Partial<RunProgress> = {}): RunProgress {
|
||||
} as RunProgress
|
||||
}
|
||||
|
||||
test('writeRunState → readRunState 往返一致(returnValue 为对象)', async () => {
|
||||
test('writeRunState → readRunState round-trip consistent (returnValue is object)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
||||
try {
|
||||
const run = makeRun({
|
||||
@@ -49,7 +49,7 @@ test('writeRunState → readRunState 往返一致(returnValue 为对象)', a
|
||||
}
|
||||
})
|
||||
|
||||
test('readRunState 缺文件 → null', async () => {
|
||||
test('readRunState missing file → null', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
||||
try {
|
||||
const got = await readRunState(dir, 'never-exists')
|
||||
@@ -59,7 +59,7 @@ test('readRunState 缺文件 → null', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('readRunState 损坏 JSON → null', async () => {
|
||||
test('readRunState corrupt JSON → null', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
||||
try {
|
||||
await mkdir(join(dir, 'rX'), { recursive: true })
|
||||
@@ -71,7 +71,7 @@ test('readRunState 损坏 JSON → null', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('readRunState schemaVersion 不符 → null', async () => {
|
||||
test('readRunState schemaVersion mismatch → null', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
||||
try {
|
||||
await mkdir(join(dir, 'rX'), { recursive: true })
|
||||
@@ -87,7 +87,7 @@ test('readRunState schemaVersion 不符 → null', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('writeRunState 原子写:成功后无 tmp 残留', async () => {
|
||||
test('writeRunState atomic write: no tmp residue after success', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
||||
try {
|
||||
await writeRunState(dir, makeRun({ runId: 'rAtom' }))
|
||||
@@ -99,10 +99,10 @@ test('writeRunState 原子写:成功后无 tmp 残留', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('listPersistedRuns 扫多子目录、跳过无 state.json 的目录、按 updatedAt 降序', async () => {
|
||||
test('listPersistedRuns scans multiple subdirs, skips dirs without state.json, sorts by updatedAt desc', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
||||
try {
|
||||
// 三个有效 run + 一个只有 journal 没 state.json 的半残目录
|
||||
// three valid runs + one half-broken dir with only journal, no state.json
|
||||
await writeRunState(dir, makeRun({ runId: 'old', updatedAt: 1000 }))
|
||||
await writeRunState(dir, makeRun({ runId: 'mid', updatedAt: 2000 }))
|
||||
await writeRunState(dir, makeRun({ runId: 'new', updatedAt: 3000 }))
|
||||
@@ -115,7 +115,7 @@ test('listPersistedRuns 扫多子目录、跳过无 state.json 的目录、按 u
|
||||
}
|
||||
})
|
||||
|
||||
test('listPersistedRuns 扫到损坏 state.json → 跳过该单个,继续扫其余', async () => {
|
||||
test('listPersistedRuns scans a corrupt state.json → skip that single one, continue scanning the rest', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
||||
try {
|
||||
await writeRunState(dir, makeRun({ runId: 'good' }))
|
||||
@@ -129,7 +129,7 @@ test('listPersistedRuns 扫到损坏 state.json → 跳过该单个,继续扫
|
||||
}
|
||||
})
|
||||
|
||||
test('writeRunState 不抛 returnValue 为 null/字符串/数组', async () => {
|
||||
test('writeRunState does not throw when returnValue is null/string/array', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
||||
try {
|
||||
await writeRunState(dir, makeRun({ runId: 'n', returnValue: null }))
|
||||
@@ -143,7 +143,7 @@ test('writeRunState 不抛 returnValue 为 null/字符串/数组', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('writeRunState 覆盖写:同 runId 二次写覆盖旧内容', async () => {
|
||||
test('writeRunState overwrite: same runId second write overwrites old content', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
||||
try {
|
||||
await writeRunState(dir, makeRun({ runId: 'rOV', status: 'running' }))
|
||||
@@ -155,7 +155,7 @@ test('writeRunState 覆盖写:同 runId 二次写覆盖旧内容', async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test('writeRunState 写入完整 AgentProgress(不含 output 内容,含 label/phase/token 等)', async () => {
|
||||
test('writeRunState writes full AgentProgress (no output content, includes label/phase/token etc.)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
||||
try {
|
||||
const run = makeRun({
|
||||
@@ -192,8 +192,8 @@ test('writeRunState 写入完整 AgentProgress(不含 output 内容,含 labe
|
||||
}
|
||||
})
|
||||
|
||||
test('getRunsDir 返回 <projectRoot>/.claude/workflow-runs 形态', () => {
|
||||
test('getRunsDir returns <projectRoot>/.claude/workflow-runs shape', () => {
|
||||
const dir = getRunsDir()
|
||||
// 不 hard-code projectRoot(跨机器不同),只校验后缀结构
|
||||
// do not hard-code projectRoot (differs across machines), only check suffix structure
|
||||
expect(dir.endsWith(`${join('.claude', 'workflow-runs')}`)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
// 注意:本测试不 mock bootstrap/state、utils/cwd、analytics、debug。
|
||||
// 原因:mock.module 是进程全局的(last-write-wins),mock 这些公共模块会污染
|
||||
// 同进程其他测试(如 src/commands/__tests__/autonomy.test.ts 经其依赖链 import
|
||||
// 真实 bootstrap/state)。ports 在测试环境下能正常解析 getProjectRoot/getCwd,
|
||||
// logEvent/logForDebugging 在 sink 未 attach 时为静默 no-op,无需 mock。
|
||||
// Note: this test does not mock bootstrap/state, utils/cwd, analytics, debug.
|
||||
// Reason: mock.module is process-global (last-write-wins); mocking these common modules would pollute
|
||||
// other tests in the same process (e.g. src/commands/__tests__/autonomy.test.ts imports the real
|
||||
// bootstrap/state via its dependency chain). ports can resolve getProjectRoot/getCwd normally in the test env,
|
||||
// logEvent/logForDebugging are silent no-ops when sink is not attached, no need to mock.
|
||||
|
||||
import { buildRegistry } from '../registry.js'
|
||||
import { createWorkflowPorts } from '../ports.js'
|
||||
@@ -13,7 +13,7 @@ import { getProjectRoot } from '../../bootstrap/state.js'
|
||||
import type { SetAppState } from '../../Task.js'
|
||||
import type { AppState } from '../../state/AppState.tsx'
|
||||
|
||||
test('buildRegistry 注册 claude-code 为默认且 resolve 命中', () => {
|
||||
test('buildRegistry registers claude-code as default and resolve hits', () => {
|
||||
const reg = buildRegistry()
|
||||
expect(reg.has('claude-code')).toBe(true)
|
||||
expect(reg.resolve({ prompt: 'x' }).id).toBe('claude-code')
|
||||
@@ -22,7 +22,7 @@ test('buildRegistry 注册 claude-code 为默认且 resolve 命中', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('createWorkflowPorts 组装完整端口(含 agentAdapterRegistry 与 progressEmitter→bus)', () => {
|
||||
test('createWorkflowPorts assembles full ports (incl. agentAdapterRegistry and progressEmitter→bus)', () => {
|
||||
const bus = createProgressBus()
|
||||
const store = createProgressStoreFromBus(bus)
|
||||
const ports = createWorkflowPorts({ bus, store })
|
||||
@@ -34,11 +34,11 @@ test('createWorkflowPorts 组装完整端口(含 agentAdapterRegistry 与 prog
|
||||
expect(typeof ports.taskRegistrar.register).toBe('function')
|
||||
expect(typeof ports.taskRegistrar.kill).toBe('function')
|
||||
expect(typeof ports.hostFactory).toBe('function')
|
||||
// agentRunner 兜底字段仍存在(WorkflowPorts 必填)
|
||||
// agentRunner fallback fields still exist (WorkflowPorts required)
|
||||
expect(ports.agentRunner).toBeDefined()
|
||||
expect(typeof ports.agentRunner.runAgentToResult).toBe('function')
|
||||
|
||||
// progressEmitter 经 bus → store:发一个 run_started,store 能看到
|
||||
// progressEmitter via bus → store: emit a run_started, store can see it
|
||||
ports.progressEmitter.emit({
|
||||
type: 'run_started',
|
||||
runId: 't',
|
||||
@@ -48,12 +48,12 @@ test('createWorkflowPorts 组装完整端口(含 agentAdapterRegistry 与 prog
|
||||
expect(store.get('t')?.workflowName).toBe('w')
|
||||
})
|
||||
|
||||
test('taskRegistrar.register/complete/kill 经 RunBinding 路由(真 setAppState,无 mock)', () => {
|
||||
test('taskRegistrar.register/complete/kill routes via RunBinding (real setAppState, no mock)', () => {
|
||||
const bus = createProgressBus()
|
||||
const store = createProgressStoreFromBus(bus)
|
||||
const ports = createWorkflowPorts({ bus, store })
|
||||
|
||||
// 真 setAppState:用一个本地 AppState 对象承载 tasks,registerTask 走真实代码路径。
|
||||
// real setAppState: use a local AppState object to hold tasks, registerTask goes through the real code path.
|
||||
const state = { tasks: {} } as unknown as AppState
|
||||
const setAppState: SetAppState = f => {
|
||||
Object.assign(state, f(state))
|
||||
@@ -81,24 +81,24 @@ test('taskRegistrar.register/complete/kill 经 RunBinding 路由(真 setAppSta
|
||||
expect(typeof runId).toBe('string')
|
||||
expect(signal).toBeInstanceOf(AbortSignal)
|
||||
|
||||
// complete/fail/kill 不抛(RunBinding 命中)
|
||||
// complete/fail/kill do not throw (RunBinding hit)
|
||||
expect(() => ports.taskRegistrar.complete(runId, 'done')).not.toThrow()
|
||||
expect(() => ports.taskRegistrar.kill(runId)).not.toThrow()
|
||||
// 未知 runId 安全 no-op
|
||||
// unknown runId safe no-op
|
||||
expect(() => ports.taskRegistrar.complete('nope')).not.toThrow()
|
||||
expect(ports.taskRegistrar.pendingAction('nope')).toBeNull()
|
||||
|
||||
// 终态后 binding 回收:再次 complete 同 runId 应安全 no-op(不抛错、不重复调用 workflow task fn)
|
||||
// after terminal state binding is reclaimed: calling complete on the same runId again should be safe no-op (no throw, no repeated call to workflow task fn)
|
||||
ports.taskRegistrar.complete(runId)
|
||||
ports.taskRegistrar.kill(runId)
|
||||
})
|
||||
|
||||
// agent 级 kill 桥接:register → killAgent 精确中断;kill(runId) 顺带 abort 所有 agent。
|
||||
test('taskRegistrar agentAbortControllers:register/killAgent 精确中断;kill(runId) 批量 abort', () => {
|
||||
// agent-level kill bridge: register → killAgent precisely aborts; kill(runId) aborts all agents.
|
||||
test('taskRegistrar agentAbortControllers: register/killAgent precise abort; kill(runId) batch abort', () => {
|
||||
const bus = createProgressBus()
|
||||
const store = createProgressStoreFromBus(bus)
|
||||
const ports = createWorkflowPorts({ bus, store })
|
||||
// 实现always provides these — cast 把 optional 拍平为 required(避免每行 ! 断言)
|
||||
// impl always provides these — cast flattens optional to required (avoids per-line ! assertion)
|
||||
const tr = ports.taskRegistrar as Required<typeof ports.taskRegistrar>
|
||||
|
||||
const state = { tasks: {} } as unknown as AppState
|
||||
@@ -120,7 +120,7 @@ test('taskRegistrar agentAbortControllers:register/killAgent 精确中断;ki
|
||||
hostCtx.handle,
|
||||
)
|
||||
|
||||
// 注册两个 agent 的 AbortController(模拟 backend 在启动 agent 时调用)
|
||||
// register AbortController for two agents (simulating backend calling when launching agent)
|
||||
const ac1 = new AbortController()
|
||||
const ac2 = new AbortController()
|
||||
tr.registerAgentAbort(runId, 1, ac1)
|
||||
@@ -128,26 +128,26 @@ test('taskRegistrar agentAbortControllers:register/killAgent 精确中断;ki
|
||||
expect(ac1.signal.aborted).toBe(false)
|
||||
expect(ac2.signal.aborted).toBe(false)
|
||||
|
||||
// killAgent 精确中断 agent #1:仅 ac1 abort,ac2 不受影响
|
||||
// killAgent precisely aborts agent #1: only ac1 aborts, ac2 unaffected
|
||||
expect(tr.killAgent(runId, 1)).toBe(true)
|
||||
expect(ac1.signal.aborted).toBe(true)
|
||||
expect(ac2.signal.aborted).toBe(false)
|
||||
// 重复 kill 同 agent:controller 已 delete,返回 false(幂等)
|
||||
// repeat kill on same agent: controller already deleted, returns false (idempotent)
|
||||
expect(tr.killAgent(runId, 1)).toBe(false)
|
||||
|
||||
// 未知 agentId / 未知 runId 安全返回 false
|
||||
// unknown agentId / unknown runId safe returns false
|
||||
expect(tr.killAgent(runId, 999)).toBe(false)
|
||||
expect(tr.killAgent('nope', 1)).toBe(false)
|
||||
|
||||
// kill(runId) 批量 abort 剩余 agent(ac2)
|
||||
// kill(runId) batch aborts remaining agent (ac2)
|
||||
tr.kill(runId)
|
||||
expect(ac2.signal.aborted).toBe(true)
|
||||
|
||||
// run 终态后 binding 已回收:再 killAgent 返回 false
|
||||
// after run terminal state binding is reclaimed: killAgent returns false
|
||||
expect(tr.killAgent(runId, 2)).toBe(false)
|
||||
})
|
||||
|
||||
test('unregisterAgentAbort 从 Map 删除(backend finally 清理幂等)', () => {
|
||||
test('unregisterAgentAbort deletes from Map (backend finally cleanup idempotent)', () => {
|
||||
const bus = createProgressBus()
|
||||
const store = createProgressStoreFromBus(bus)
|
||||
const ports = createWorkflowPorts({ bus, store })
|
||||
@@ -173,19 +173,19 @@ test('unregisterAgentAbort 从 Map 删除(backend finally 清理幂等)', ()
|
||||
)
|
||||
const ac = new AbortController()
|
||||
tr.registerAgentAbort(runId, 5, ac)
|
||||
// 注销后 killAgent 无目标,返 false(不抛)
|
||||
// after unregister killAgent has no target, returns false (does not throw)
|
||||
tr.unregisterAgentAbort(runId, 5)
|
||||
expect(tr.killAgent(runId, 5)).toBe(false)
|
||||
// 重复注销幂等(backend finally 不抛)
|
||||
// repeat unregister idempotent (backend finally does not throw)
|
||||
expect(() => tr.unregisterAgentAbort(runId, 5)).not.toThrow()
|
||||
// 未知 runId 安全 no-op
|
||||
// unknown runId safe no-op
|
||||
expect(() => tr.unregisterAgentAbort('nope', 5)).not.toThrow()
|
||||
})
|
||||
|
||||
test('hostFactory.cwd 与 journalStore 同根(getProjectRoot)—— 修复 K 回归', () => {
|
||||
// 历史 bug:hostFactory.cwd 用 getCwd()、journalStore 用 getProjectRoot(),
|
||||
// 用户进入 worktree/子目录时两者不同 → 命名 workflow 解析与 journal 落盘不同步。
|
||||
// 修复后两者都用 projectRoot,本测试 lock-in 该选择,防止回归。
|
||||
test('hostFactory.cwd and journalStore share root (getProjectRoot) — fix K regression', () => {
|
||||
// historical bug: hostFactory.cwd used getCwd(), journalStore used getProjectRoot(),
|
||||
// when user enters worktree/subdirectory the two differ → named workflow resolution and journal persist out of sync.
|
||||
// After fix both use projectRoot, this test locks-in that choice, preventing regression.
|
||||
const bus = createProgressBus()
|
||||
const store = createProgressStoreFromBus(bus)
|
||||
const ports = createWorkflowPorts({ bus, store })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test, mock } from 'bun:test'
|
||||
import { createProgressBus } from '../progress/bus.js'
|
||||
|
||||
test('emit 广播给所有订阅者', () => {
|
||||
test('emit broadcasts to all subscribers', () => {
|
||||
const bus = createProgressBus()
|
||||
const a = mock(() => {})
|
||||
const b = mock(() => {})
|
||||
@@ -13,7 +13,7 @@ test('emit 广播给所有订阅者', () => {
|
||||
expect(b).toHaveBeenCalledWith(ev)
|
||||
})
|
||||
|
||||
test('subscribe 返回取消订阅', () => {
|
||||
test('subscribe returns unsubscribe', () => {
|
||||
const bus = createProgressBus()
|
||||
const fn = mock(() => {})
|
||||
const unsub = bus.subscribe(fn)
|
||||
|
||||
@@ -17,7 +17,7 @@ function newStore() {
|
||||
return { bus, store: createProgressStoreFromBus(bus) }
|
||||
}
|
||||
|
||||
test('run_started 建条目;phase_started/done 更新 phases', () => {
|
||||
test('run_started creates entry; phase_started/done updates phases', () => {
|
||||
const { bus, store } = newStore()
|
||||
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
|
||||
bus.emit({ type: 'phase_started', runId: 'r1', phase: 'A' })
|
||||
@@ -31,7 +31,7 @@ test('run_started 建条目;phase_started/done 更新 phases', () => {
|
||||
expect(r.currentPhase).toBe('B')
|
||||
})
|
||||
|
||||
test('并发 agent_done 按 agentId 精确关联(回归旧 LIFO 竞态)', () => {
|
||||
test('concurrent agent_done correlates by agentId precisely (regression of old LIFO race)', () => {
|
||||
const { bus, store } = newStore()
|
||||
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
|
||||
bus.emit({
|
||||
@@ -71,7 +71,7 @@ test('并发 agent_done 按 agentId 精确关联(回归旧 LIFO 竞态)', ()
|
||||
expect(agents.find(x => x.id === 1)?.label).toBe('b')
|
||||
})
|
||||
|
||||
test('journal 命中(仅 agent_done 无 started)按 id 补建 done 条目', () => {
|
||||
test('journal hit (agent_done without started) backfills done entry by id', () => {
|
||||
const { bus, store } = newStore()
|
||||
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
|
||||
bus.emit({
|
||||
@@ -86,7 +86,7 @@ test('journal 命中(仅 agent_done 无 started)按 id 补建 done 条目',
|
||||
expect(a.status).toBe('done')
|
||||
})
|
||||
|
||||
test('run_done 终态 + list 排序 + subscribe 通知', () => {
|
||||
test('run_done terminal state + list sort + subscribe notification', () => {
|
||||
const { bus, store } = newStore()
|
||||
let calls = 0
|
||||
store.subscribe(() => calls++)
|
||||
@@ -104,7 +104,7 @@ test('run_done 终态 + list 排序 + subscribe 通知', () => {
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test('run_done failed 终态记录 error', () => {
|
||||
test('run_done failed terminal state records error', () => {
|
||||
const { bus, store } = newStore()
|
||||
bus.emit({ type: 'run_started', runId: 'r2', workflowName: 'w', meta: null })
|
||||
bus.emit({ type: 'run_done', runId: 'r2', status: 'failed', error: 'boom' })
|
||||
@@ -113,17 +113,17 @@ test('run_done failed 终态记录 error', () => {
|
||||
expect(r.error).toBe('boom')
|
||||
})
|
||||
|
||||
test('log 事件不触发 notify', () => {
|
||||
test('log event does not trigger notify', () => {
|
||||
const { bus, store } = newStore()
|
||||
let calls = 0
|
||||
store.subscribe(() => calls++)
|
||||
bus.emit({ type: 'run_started', runId: 'r3', workflowName: 'w', meta: null })
|
||||
const before = calls
|
||||
bus.emit({ type: 'log', runId: 'r3', message: 'hi' })
|
||||
expect(calls).toBe(before) // log 不应触发 notify
|
||||
expect(calls).toBe(before) // log should not trigger notify
|
||||
})
|
||||
|
||||
test('run_started 落地 declaredPhases(来自 meta.phases,顺序保留)', () => {
|
||||
test('run_started persists declaredPhases (from meta.phases, order preserved)', () => {
|
||||
const { bus, store } = newStore()
|
||||
bus.emit({
|
||||
type: 'run_started',
|
||||
@@ -138,13 +138,13 @@ test('run_started 落地 declaredPhases(来自 meta.phases,顺序保留)',
|
||||
expect(store.get('r1')!.declaredPhases).toEqual(['Find', 'Review', 'Verify'])
|
||||
})
|
||||
|
||||
test('run_started meta 为 null → declaredPhases = []', () => {
|
||||
test('run_started meta is null → declaredPhases = []', () => {
|
||||
const { bus, store } = newStore()
|
||||
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
|
||||
expect(store.get('r1')!.declaredPhases).toEqual([])
|
||||
})
|
||||
|
||||
test('agent_done 落地 outputShape(ok·object / ok·text / dead 无)', () => {
|
||||
test('agent_done persists outputShape (ok·object / ok·text / dead none)', () => {
|
||||
const { bus, store } = newStore()
|
||||
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
|
||||
bus.emit({ type: 'agent_started', runId: 'r1', agentId: 0, phase: 'A' })
|
||||
@@ -177,7 +177,7 @@ test('agent_done 落地 outputShape(ok·object / ok·text / dead 无)', () =
|
||||
expect(agents.find(a => a.id === 2)?.outputShape).toBeUndefined()
|
||||
})
|
||||
|
||||
test('agent_progress 实时更新 token/tool(按 agentId 关联)', () => {
|
||||
test('agent_progress real-time updates token/tool (correlated by agentId)', () => {
|
||||
const { bus, store } = newStore()
|
||||
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
|
||||
bus.emit({
|
||||
@@ -209,7 +209,7 @@ test('agent_progress 实时更新 token/tool(按 agentId 关联)', () => {
|
||||
expect(a.toolCount).toBe(3)
|
||||
})
|
||||
|
||||
test('agent_done 落地 model/tokenCount/toolCount(ok 变体)', () => {
|
||||
test('agent_done persists model/tokenCount/toolCount (ok variant)', () => {
|
||||
const { bus, store } = newStore()
|
||||
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
|
||||
bus.emit({ type: 'agent_started', runId: 'r1', agentId: 0, phase: 'A' })
|
||||
@@ -233,9 +233,9 @@ test('agent_done 落地 model/tokenCount/toolCount(ok 变体)', () => {
|
||||
expect(a.toolCount).toBe(1)
|
||||
})
|
||||
|
||||
// ---- hydrate:从磁盘注入历史 run(跨重启恢复)----
|
||||
// ---- hydrate: inject historical run from disk (cross-restart recovery) ----
|
||||
|
||||
test('hydrate 注入新 run → get 命中 + list 含该项 + 通知 listener', () => {
|
||||
test('hydrate injects new run → get hits + list includes it + notifies listener', () => {
|
||||
const { store } = newStore()
|
||||
let notified = 0
|
||||
store.subscribe(() => notified++)
|
||||
@@ -260,7 +260,7 @@ test('hydrate 注入新 run → get 命中 + list 含该项 + 通知 listener',
|
||||
expect(notified).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('hydrate 已存在的 runId → 跳过(内存优先,不被磁盘覆盖)', () => {
|
||||
test('hydrate existing runId → skip (memory first, not overwritten by disk)', () => {
|
||||
const { bus, store } = newStore()
|
||||
bus.emit({
|
||||
type: 'run_started',
|
||||
|
||||
@@ -7,14 +7,14 @@ import { createProgressBus } from '../progress/bus.js'
|
||||
import { createProgressStoreFromBus } from '../progress/store.js'
|
||||
|
||||
/**
|
||||
* attachRunStatePersistence 的契约测试(调整后 Task 4):
|
||||
* 直接测 bus + store 组合,不走 makeService(保持 makeService 签名 (ports, store, cwdOverride?) 不变)。
|
||||
* Contract test for attachRunStatePersistence (adjusted Task 4):
|
||||
* directly test the bus + store combination, bypassing makeService (keeps makeService signature (ports, store, cwdOverride?) unchanged).
|
||||
*
|
||||
* runsDir 通过 attachRunStatePersistence 的第三个参数 runsDirProvider 注入 tmpdir,
|
||||
* 避免写真实项目目录(Bun ESM 模块命名空间只读,无法 monkey-patch getRunsDir)。
|
||||
* runsDir is injected as tmpdir via attachRunStatePersistence's third parameter runsDirProvider,
|
||||
* to avoid writing to the real project directory (Bun ESM module namespace is read-only, cannot monkey-patch getRunsDir).
|
||||
*/
|
||||
|
||||
test('run_done completed → 写盘 state.json,returnValue 一致', async () => {
|
||||
test('run_done completed → writes state.json to disk, returnValue consistent', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-persist-'))
|
||||
try {
|
||||
const bus = createProgressBus()
|
||||
@@ -34,7 +34,7 @@ test('run_done completed → 写盘 state.json,returnValue 一致', async () =
|
||||
returnValue: { ok: true, n: 3 },
|
||||
})
|
||||
|
||||
// writeRunState 是 async(订阅里 void writeRunState(...));让 microtask 跑完
|
||||
// writeRunState is async (void writeRunState(...) in the subscription); let the microtask complete
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
const got = await readRunState(dir, 'rW')
|
||||
@@ -46,7 +46,7 @@ test('run_done completed → 写盘 state.json,returnValue 一致', async () =
|
||||
}
|
||||
})
|
||||
|
||||
test('run_done failed → 写盘 status=failed + error 字段', async () => {
|
||||
test('run_done failed → writes status=failed + error field to disk', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-persist-'))
|
||||
try {
|
||||
const bus = createProgressBus()
|
||||
@@ -76,7 +76,7 @@ test('run_done failed → 写盘 status=failed + error 字段', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('run_done killed → 写盘 status=killed', async () => {
|
||||
test('run_done killed → writes status=killed to disk', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-persist-'))
|
||||
try {
|
||||
const bus = createProgressBus()
|
||||
@@ -99,23 +99,23 @@ test('run_done killed → 写盘 status=killed', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('writeRunState 内部 IO 异常被吞掉:attachRunStatePersistence 不传播,bus emit 不中断', async () => {
|
||||
test('writeRunState internal IO exception is swallowed: attachRunStatePersistence does not propagate, bus emit does not break', async () => {
|
||||
const blockerDir = await mkdtemp(join(tmpdir(), 'wf-persist-'))
|
||||
// 先创建一个同名文件,让子路径 mkdir 失败 → writeRunState 内部 catch 吞掉
|
||||
// first create a same-named file, so subdir mkdir fails → writeRunState internal catch swallows it
|
||||
await writeFile(join(blockerDir, 'not-a-dir.txt'), 'blocker', 'utf-8')
|
||||
try {
|
||||
const bus = createProgressBus()
|
||||
const store = createProgressStoreFromBus(bus)
|
||||
// runsDir 指向一个父路径是文件的目录:mkdir recursive 失败
|
||||
// runsDir points to a dir whose parent path is a file: mkdir recursive fails
|
||||
attachRunStatePersistence(bus, store, () =>
|
||||
join(blockerDir, 'not-a-dir.txt'),
|
||||
)
|
||||
|
||||
// 额外的订阅者,验证它仍被通知(bus emit 不应因持久化 listener 内部异常中断)
|
||||
// an extra subscriber to verify it still gets notified (bus emit should not break due to internal exception in persistence listener)
|
||||
let otherNotified = 0
|
||||
bus.subscribe(() => otherNotified++)
|
||||
|
||||
// bus.emit 不应抛——writeRunState 内部吞异常
|
||||
// bus.emit should not throw — writeRunState swallows the exception internally
|
||||
expect(() => {
|
||||
bus.emit({
|
||||
type: 'run_started',
|
||||
@@ -131,10 +131,10 @@ test('writeRunState 内部 IO 异常被吞掉:attachRunStatePersistence 不传
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
// 让 writeRunState 的 microtask 跑完(异常在内部被吞)
|
||||
// let writeRunState's microtask complete (exception swallowed internally)
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
// store 这条订阅者仍正常工作(收到了 run_started + run_done 两次事件)
|
||||
// this store subscriber still works normally (received both run_started + run_done events)
|
||||
expect(otherNotified).toBeGreaterThanOrEqual(2)
|
||||
expect(store.get('rErr')?.status).toBe('completed')
|
||||
} finally {
|
||||
@@ -142,14 +142,14 @@ test('writeRunState 内部 IO 异常被吞掉:attachRunStatePersistence 不传
|
||||
}
|
||||
})
|
||||
|
||||
test('attachRunStatePersistence 返回 unsubscribe;调用后不再写盘', async () => {
|
||||
test('attachRunStatePersistence returns unsubscribe; after calling it no more disk writes', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-persist-'))
|
||||
try {
|
||||
const bus = createProgressBus()
|
||||
const store = createProgressStoreFromBus(bus)
|
||||
const unsub = attachRunStatePersistence(bus, store, () => dir)
|
||||
|
||||
// 先发一个 run_done,验证写盘生效
|
||||
// first emit a run_done, verify disk write takes effect
|
||||
bus.emit({
|
||||
type: 'run_started',
|
||||
runId: 'r1',
|
||||
@@ -160,7 +160,7 @@ test('attachRunStatePersistence 返回 unsubscribe;调用后不再写盘', asy
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(await readRunState(dir, 'r1')).not.toBeNull()
|
||||
|
||||
// unsubscribe 后再发 run_done,不应再写盘
|
||||
// after unsubscribe, emit run_done again, should not write to disk
|
||||
unsub()
|
||||
bus.emit({
|
||||
type: 'run_started',
|
||||
|
||||
@@ -23,7 +23,7 @@ function run(partial: Partial<RunProgress>): RunProgress {
|
||||
}
|
||||
}
|
||||
|
||||
test('mergePhases:声明顺序优先,实际 phase 追加未声明的,计数 done/total', () => {
|
||||
test('mergePhases: declared order first, actual phases append undeclared ones, counts done/total', () => {
|
||||
const r = run({
|
||||
declaredPhases: ['Find', 'Review', 'Verify'],
|
||||
phases: [
|
||||
@@ -49,7 +49,7 @@ test('mergePhases:声明顺序优先,实际 phase 追加未声明的,计
|
||||
])
|
||||
})
|
||||
|
||||
test('mergePhases:实际出现但未声明的 phase 追加到末尾', () => {
|
||||
test('mergePhases: actual but undeclared phase appended to the end', () => {
|
||||
const r = run({
|
||||
declaredPhases: ['Find'],
|
||||
phases: [
|
||||
@@ -61,7 +61,7 @@ test('mergePhases:实际出现但未声明的 phase 追加到末尾', () => {
|
||||
expect(mergePhases(r).map(p => p.title)).toEqual(['Find', 'Adhoc'])
|
||||
})
|
||||
|
||||
test('filterAgentsByPhase:All / undefined → 全部;指定 → 仅该 phase', () => {
|
||||
test('filterAgentsByPhase: All / undefined → all; specified → only that phase', () => {
|
||||
const agents: AgentProgress[] = [
|
||||
{ id: 1, phase: 'A', status: 'running' },
|
||||
{
|
||||
@@ -77,6 +77,6 @@ test('filterAgentsByPhase:All / undefined → 全部;指定 → 仅该 phase
|
||||
expect(filterAgentsByPhase(agents, 'A')).toEqual([agents[0]])
|
||||
})
|
||||
|
||||
test('tabLabel:workflow 名 + runId 后 4 位短码', () => {
|
||||
test('tabLabel: workflow name + last 4 chars short code of runId', () => {
|
||||
expect(tabLabel('review-changes', 'wf_abc123def')).toBe('review-changes#3def')
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
// DI 模式:不使用 mock.module(进程全局、last-write-wins,会污染同进程其他测试如
|
||||
// autonomy.test.ts)。改为手工构造 FAKE WorkflowPorts:registry.run 返回固定 ok
|
||||
// 结果,taskRegistrar 维护 abort 绑定,journalStore 内存空实现。真实 runWorkflow
|
||||
// 因此跑完且无需 LLM 或 mock。
|
||||
// DI pattern: do not use mock.module (process-global, last-write-wins, would pollute other tests in the same process such as
|
||||
// autonomy.test.ts). Instead hand-construct FAKE WorkflowPorts: registry.run returns a fixed ok
|
||||
// result, taskRegistrar maintains abort bindings, journalStore is an in-memory empty impl. The real runWorkflow
|
||||
// thus runs to completion without needing LLM or mocks.
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -19,10 +19,10 @@ import type {
|
||||
WorkflowPorts,
|
||||
} from '@claude-code-best/workflow-engine'
|
||||
|
||||
// 构造 FAKE ports:registry.run 返回固定 AgentRunResult,taskRegistrar 带 binding,
|
||||
// journalStore 内存空实现。progressEmitter.emit → bus.emit(store 已在构造时订阅 bus)。
|
||||
// 注意:runWorkflow 自身会发 run_started/run_done;taskRegistrar 只管 abort 绑定,
|
||||
// 不重复发事件(避免 store reducer 收到重复 run_done)。
|
||||
// Construct FAKE ports: registry.run returns a fixed AgentRunResult, taskRegistrar has bindings,
|
||||
// journalStore is an in-memory empty impl. progressEmitter.emit → bus.emit (store subscribes to bus at construction).
|
||||
// Note: runWorkflow itself emits run_started/run_done; taskRegistrar only manages abort bindings,
|
||||
// does not re-emit events (avoids store reducer receiving duplicate run_done).
|
||||
type RegistrarCall =
|
||||
| { kind: 'complete'; runId: string; summary?: string }
|
||||
| { kind: 'fail'; runId: string; error?: string }
|
||||
@@ -38,22 +38,22 @@ type RegistrarCall =
|
||||
|
||||
function fakePorts(
|
||||
opts: {
|
||||
/** adapter.run 抛错(模拟 agent 后端崩溃)。 */
|
||||
/** adapter.run throws (simulates agent backend crash). */
|
||||
adapterThrow?: string
|
||||
/** adapter.run 返回值(默认 ok)。 */
|
||||
/** adapter.run return value (default ok). */
|
||||
adapterResult?: AgentRunResult
|
||||
/** agentRunner.runAgentToResult 返回值(fallback 路径,默认 throw)。 */
|
||||
/** agentRunner.runAgentToResult return value (fallback path, default throws). */
|
||||
runnerResult?: AgentRunResult
|
||||
} = {},
|
||||
): {
|
||||
ports: WorkflowPorts
|
||||
store: ReturnType<typeof createProgressStoreFromBus>
|
||||
killed: string[]
|
||||
/** taskRegistrar 调用记录(complete/fail/kill/registerAgentAbort/...)。 */
|
||||
/** taskRegistrar call records (complete/fail/kill/registerAgentAbort/...). */
|
||||
calls: RegistrarCall[]
|
||||
/** runId → (agentId → AbortController)。测试模拟 backend 注册用。 */
|
||||
/** runId → (agentId → AbortController). Used by tests to simulate backend registration. */
|
||||
agentBindings: Map<string, Map<number, AbortController>>
|
||||
/** adapter.run 被调次数(重试时累加)。holder 引用,测试读 adapterCalls.value。 */
|
||||
/** adapter.run call count (accumulates on retry). holder reference, tests read adapterCalls.value. */
|
||||
adapterCallsRef: { value: number }
|
||||
} {
|
||||
const bus = createProgressBus()
|
||||
@@ -61,16 +61,16 @@ function fakePorts(
|
||||
const killed: string[] = []
|
||||
const calls: RegistrarCall[] = []
|
||||
const bindings = new Map<string, { abort: AbortController }>()
|
||||
// agentId → AbortController(每个 runId 独立)。killAgent 据此精确中断。
|
||||
// agentId → AbortController (per runId). killAgent uses this to abort precisely.
|
||||
const agentBindings = new Map<string, Map<number, AbortController>>()
|
||||
// adapter.run 被调次数(重试时累加)。用 holder object 避免 closure/getter
|
||||
// 在 Bun test runner 里的快照语义问题——返回时 shorthand 取当前值(=0),
|
||||
// 后续 outer 变量 ++ 不会反映到 returned object 字段。holder 引用稳定。
|
||||
// adapter.run call count (accumulates on retry). Use holder object to avoid closure/getter
|
||||
// snapshot semantics issues in Bun test runner — when returning, shorthand takes the current value (=0),
|
||||
// subsequent outer variable ++ does not reflect into the returned object field. holder reference is stable.
|
||||
const adapterCallsRef = { value: 0 }
|
||||
let seq = 0
|
||||
const ports = {
|
||||
// hostFactory 实际不被 service.launch 路径调用(service 自建 host handle),
|
||||
// 但 WorkflowPorts 类型要求存在;保留一个最小实现。
|
||||
// hostFactory is not actually called by the service.launch path (service builds its own host handle),
|
||||
// but the WorkflowPorts type requires it to exist; keep a minimal impl.
|
||||
hostFactory: () => ({
|
||||
handle: {} as never,
|
||||
cwd: '/tmp',
|
||||
@@ -175,12 +175,12 @@ function fakePorts(
|
||||
const stubTUC = { agentId: 'a1', toolUseId: 'tu' } as never
|
||||
const stubCanUseTool = (() => Promise.resolve({ behavior: 'allow' })) as never
|
||||
|
||||
/** 等待 detached runWorkflow 完成(detached 调用,需让微任务/宏任务排空)。 */
|
||||
/** Wait for detached runWorkflow to complete (detached call, need to drain microtasks/macrotasks). */
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise(r => setTimeout(r, 60))
|
||||
}
|
||||
|
||||
test('launch → completed;store 出现该 run', async () => {
|
||||
test('launch → completed; store shows this run', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store } = fakePorts()
|
||||
const svc = makeService(ports, store)
|
||||
@@ -192,12 +192,12 @@ test('launch → completed;store 出现该 run', async () => {
|
||||
await settle()
|
||||
const r = svc.getRun(runId)
|
||||
expect(r).toBeDefined()
|
||||
// detached 执行可能在 settle 窗口内仍 running,或已 completed——两者皆可接受。
|
||||
// detached execution may still be running within the settle window, or already completed — both are acceptable.
|
||||
expect(['completed', 'running']).toContain(r!.status)
|
||||
expect(r!.workflowName).toBe('workflow')
|
||||
})
|
||||
|
||||
test('launch inline script → 返回 scriptPath(持久化到 cwdOverride 目录)', async () => {
|
||||
test('launch inline script → returns scriptPath (persisted to cwdOverride dir)', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-svc-'))
|
||||
try {
|
||||
@@ -220,7 +220,7 @@ test('launch inline script → 返回 scriptPath(持久化到 cwdOverride 目
|
||||
}
|
||||
})
|
||||
|
||||
test('kill 走 taskRegistrar.kill', async () => {
|
||||
test('kill goes through taskRegistrar.kill', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store, killed } = fakePorts()
|
||||
const svc = makeService(ports, store)
|
||||
@@ -233,7 +233,7 @@ test('kill 走 taskRegistrar.kill', async () => {
|
||||
expect(killed).toContain(runId)
|
||||
})
|
||||
|
||||
test('killAgent 走 taskRegistrar.killAgent:精确中断单个 agent', async () => {
|
||||
test('killAgent goes through taskRegistrar.killAgent: precisely aborts a single agent', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store, calls, agentBindings } = fakePorts()
|
||||
const svc = makeService(ports, store)
|
||||
@@ -242,10 +242,10 @@ test('killAgent 走 taskRegistrar.killAgent:精确中断单个 agent', async (
|
||||
stubTUC,
|
||||
stubCanUseTool,
|
||||
)
|
||||
// 模拟 backend 启动 agent 时注册 AbortController
|
||||
// simulate backend registering AbortController when launching agent
|
||||
const ac = new AbortController()
|
||||
agentBindings.get(runId)!.set(7, ac)
|
||||
// service.killAgent 路由到 taskRegistrar.killAgent,后者真 abort 对应 controller
|
||||
// service.killAgent routes to taskRegistrar.killAgent, which actually aborts the corresponding controller
|
||||
expect(svc.killAgent(runId, 7)).toBe(true)
|
||||
expect(ac.signal.aborted).toBe(true)
|
||||
expect(
|
||||
@@ -253,14 +253,14 @@ test('killAgent 走 taskRegistrar.killAgent:精确中断单个 agent', async (
|
||||
c => c.kind === 'killAgent' && c.runId === runId && c.agentId === 7,
|
||||
),
|
||||
).toBe(true)
|
||||
// 已 abort 后 controller 从 Map 删除:再次 killAgent 同 agent 返 false(幂等)
|
||||
// after abort controller is deleted from Map: calling killAgent on same agent again returns false (idempotent)
|
||||
expect(svc.killAgent(runId, 7)).toBe(false)
|
||||
// 未知 agentId / 未知 runId 安全返 false
|
||||
// unknown agentId / unknown runId safe returns false
|
||||
expect(svc.killAgent(runId, 999)).toBe(false)
|
||||
expect(svc.killAgent('nope', 1)).toBe(false)
|
||||
})
|
||||
|
||||
test('listRuns/subscribe 来自 store', () => {
|
||||
test('listRuns/subscribe come from store', () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store } = fakePorts()
|
||||
const svc = makeService(ports, store)
|
||||
@@ -274,16 +274,16 @@ test('listRuns/subscribe 来自 store', () => {
|
||||
expect(n).toBe(0)
|
||||
})
|
||||
|
||||
test('listNamed 委托 namedWorkflows(空目录→[];有文件→列出)', async () => {
|
||||
test('listNamed delegates to namedWorkflows (empty dir → []; with files → lists)', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store } = fakePorts()
|
||||
const svc = makeService(ports, store)
|
||||
// 不存在的目录 → []
|
||||
// non-existent dir → []
|
||||
const empty = await svc.listNamed(
|
||||
join(tmpdir(), `wf-nope-${Math.random().toString(36).slice(2)}`),
|
||||
)
|
||||
expect(empty).toEqual([])
|
||||
// 有命名文件的目录 → 列出 name(去扩展名,排序)
|
||||
// dir with named files → lists names (extension stripped, sorted)
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-named-'))
|
||||
try {
|
||||
await writeFile(
|
||||
@@ -298,7 +298,7 @@ test('listNamed 委托 namedWorkflows(空目录→[];有文件→列出)',
|
||||
}
|
||||
})
|
||||
|
||||
test('缺 script/name/scriptPath → 抛错', async () => {
|
||||
test('missing script/name/scriptPath → throws', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store } = fakePorts()
|
||||
const svc = makeService(ports, store)
|
||||
@@ -307,7 +307,7 @@ test('缺 script/name/scriptPath → 抛错', async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('scriptPath 读取文件内容并校验', async () => {
|
||||
test('scriptPath reads file content and validates', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store } = fakePorts()
|
||||
const svc = makeService(ports, store)
|
||||
@@ -329,23 +329,23 @@ test('scriptPath 读取文件内容并校验', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('parseScript 校验失败 → launch 抛错', async () => {
|
||||
test('parseScript validation failed → launch throws', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store } = fakePorts()
|
||||
const svc = makeService(ports, store)
|
||||
// 触发 ScriptError:meta 字面量缺 description(validateMeta 要求 name+description 均为字符串)
|
||||
// trigger ScriptError: meta literal missing description (validateMeta requires both name+description to be strings)
|
||||
await expect(
|
||||
svc.launch(
|
||||
{ script: `export const meta = { name: "x" }\nreturn 1` },
|
||||
stubTUC,
|
||||
stubCanUseTool,
|
||||
),
|
||||
).rejects.toThrow(/校验失败/)
|
||||
).rejects.toThrow(/Script validation failed/i)
|
||||
})
|
||||
|
||||
// ---- 服务层失败路由覆盖(审查 gap:.then/.catch → taskRegistrar 路径)----
|
||||
// ---- Service-layer failure routing coverage (review gap: .then/.catch → taskRegistrar path) ----
|
||||
|
||||
test('脚本运行抛错 → service 路由到 taskRegistrar.fail,带 error 文本', async () => {
|
||||
test('script run throws → service routes to taskRegistrar.fail, with error text', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store, calls } = fakePorts()
|
||||
const svc = makeService(ports, store)
|
||||
@@ -360,27 +360,27 @@ test('脚本运行抛错 → service 路由到 taskRegistrar.fail,带 error
|
||||
expect(fail?.kind === 'fail' && fail.error).toMatch(/script boom/)
|
||||
})
|
||||
|
||||
test('adapter 抛错 → 重试仍抛 → 降级 dead → workflow completed(不 fail)', async () => {
|
||||
test('adapter throws → retry still throws → degrade to dead → workflow completed (not fail)', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
// 新语义:agent 非 abort 抛错 → 重试一次 → 仍抛 → 降级 dead(agent 返 null),
|
||||
// workflow 继续并 completed。重试容许临时故障(429/网络),但一个 agent
|
||||
// 永久坏也不击穿整个 workflow(与 parallel/pipeline 的 null-on-error 契约一致)。
|
||||
// new semantics: agent non-abort throw → retry once → still throws → degrade to dead (agent returns null),
|
||||
// workflow continues and completes. Retry tolerates transient failures (429/network), but a permanently
|
||||
// broken agent does not break through the entire workflow (consistent with parallel/pipeline null-on-error contract).
|
||||
const { ports, store, calls, adapterCallsRef } = fakePorts({
|
||||
adapterThrow: 'adapter boom',
|
||||
})
|
||||
const svc = makeService(ports, store)
|
||||
await svc.launch({ script: `return agent('x')` }, stubTUC, stubCanUseTool)
|
||||
await settle()
|
||||
// 重试一次 → adapter 被调 2 次
|
||||
// retry once → adapter called 2 times
|
||||
expect(adapterCallsRef.value).toBe(2)
|
||||
// workflow 正常 completed,未 failed
|
||||
// workflow normal completed, not failed
|
||||
const complete = calls.find(c => c.kind === 'complete')
|
||||
expect(complete).toBeDefined()
|
||||
const fail = calls.find(c => c.kind === 'fail')
|
||||
expect(fail).toBeUndefined()
|
||||
})
|
||||
|
||||
test('脚本正常完成 → service 路由到 taskRegistrar.complete', async () => {
|
||||
test('script completes normally → service routes to taskRegistrar.complete', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store, calls } = fakePorts()
|
||||
const svc = makeService(ports, store)
|
||||
@@ -389,12 +389,12 @@ test('脚本正常完成 → service 路由到 taskRegistrar.complete', async ()
|
||||
expect(calls.some(c => c.kind === 'complete')).toBe(true)
|
||||
})
|
||||
|
||||
// ---- 修复 N:shutdown 清理 ----
|
||||
// ---- Fix N: shutdown cleanup ----
|
||||
|
||||
test('shutdown 杀掉所有 running run(taskRegistrar.kill 调用每个)', async () => {
|
||||
test('shutdown kills all running runs (taskRegistrar.kill called for each)', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store, killed } = fakePorts()
|
||||
// 让 adapter 慢一点,settle 期间 run 仍在 running
|
||||
// make adapter slower, so during settle the run is still running
|
||||
const slowPorts = {
|
||||
...ports,
|
||||
agentAdapterRegistry: {
|
||||
@@ -425,7 +425,7 @@ test('shutdown 杀掉所有 running run(taskRegistrar.kill 调用每个)', a
|
||||
expect(killed).toContain(b)
|
||||
})
|
||||
|
||||
test('shutdown 不重复杀已完成 run;幂等(多次调用安全)', async () => {
|
||||
test('shutdown does not re-kill completed runs; idempotent (multiple calls safe)', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store, killed } = fakePorts()
|
||||
const svc = makeService(ports, store)
|
||||
@@ -434,24 +434,24 @@ test('shutdown 不重复杀已完成 run;幂等(多次调用安全)', asyn
|
||||
stubTUC,
|
||||
stubCanUseTool,
|
||||
)
|
||||
await settle() // 完成
|
||||
await settle() // complete
|
||||
killed.length = 0
|
||||
svc.shutdown()
|
||||
// 已完成的不应再被 kill
|
||||
// already completed should not be killed again
|
||||
expect(killed).not.toContain(runId)
|
||||
// 幂等
|
||||
// idempotent
|
||||
expect(() => svc.shutdown()).not.toThrow()
|
||||
})
|
||||
|
||||
// ---- Task 5: loadPersistedRuns + getRunAsync fallback ----
|
||||
// runsDirProvider 作为 makeService 第四个可选参数注入 tmpdir,避免写真实项目目录
|
||||
// (Bun ESM 模块命名空间只读,无法 monkey-patch getRunsDir)。
|
||||
// runsDirProvider is injected as makeService's fourth optional parameter with tmpdir, to avoid writing to the real project dir
|
||||
// (Bun ESM module namespace is read-only, cannot monkey-patch getRunsDir).
|
||||
|
||||
test('loadPersistedRuns 扫盘 hydrate 历史 run;已有内存 run 不被覆盖', async () => {
|
||||
test('loadPersistedRuns scans disk to hydrate historical runs; existing in-memory runs are not overwritten', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-svc-'))
|
||||
try {
|
||||
// 磁盘先有两个历史 run
|
||||
// disk first has two historical runs
|
||||
const { writeRunState } = await import('../persistence.js')
|
||||
const historicalA = {
|
||||
runId: 'hA',
|
||||
@@ -483,7 +483,7 @@ test('loadPersistedRuns 扫盘 hydrate 历史 run;已有内存 run 不被覆
|
||||
await writeRunState(dir, historicalB)
|
||||
|
||||
const { ports, store } = fakePorts()
|
||||
// 内存先有一个本次会话 run(通过 ports.progressEmitter.emit 走 bus → store)
|
||||
// in-memory first has one current-session run (via ports.progressEmitter.emit through bus → store)
|
||||
ports.progressEmitter.emit({
|
||||
type: 'run_started',
|
||||
runId: 'live',
|
||||
@@ -498,7 +498,7 @@ test('loadPersistedRuns 扫盘 hydrate 历史 run;已有内存 run 不被覆
|
||||
expect(ids).toContain('hA')
|
||||
expect(ids).toContain('hB')
|
||||
expect(ids).toContain('live')
|
||||
// 内存优先:live 仍是 running(不被磁盘覆盖;磁盘里没有 live 也不会注入 STALE)
|
||||
// memory first: live is still running (not overwritten by disk; disk has no live so no STALE injected)
|
||||
expect(svc.getRun('live')!.status).toBe('running')
|
||||
expect(svc.getRun('hA')!.returnValue).toBe('a')
|
||||
} finally {
|
||||
@@ -506,7 +506,7 @@ test('loadPersistedRuns 扫盘 hydrate 历史 run;已有内存 run 不被覆
|
||||
}
|
||||
})
|
||||
|
||||
test('loadPersistedRuns 重复调用仅扫盘一次(persistedLoaded flag)', async () => {
|
||||
test('loadPersistedRuns repeated calls scan disk only once (persistedLoaded flag)', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-svc-'))
|
||||
try {
|
||||
@@ -517,14 +517,14 @@ test('loadPersistedRuns 重复调用仅扫盘一次(persistedLoaded flag)',
|
||||
await svc.loadPersistedRuns()
|
||||
await svc.loadPersistedRuns()
|
||||
|
||||
// 重复调用不抛错、不改变 listRuns 结果(空目录)
|
||||
// repeated calls do not throw, do not change listRuns result (empty dir)
|
||||
expect(svc.listRuns()).toEqual([])
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('getRunAsync 内存命中 → 不读盘', async () => {
|
||||
test('getRunAsync memory hit → no disk read', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-svc-'))
|
||||
try {
|
||||
@@ -544,7 +544,7 @@ test('getRunAsync 内存命中 → 不读盘', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('getRunAsync 内存 miss + 磁盘命中 → 返回磁盘值,且不注入内存(再次 get 仍读盘)', async () => {
|
||||
test('getRunAsync memory miss + disk hit → returns disk value, and does not inject into memory (subsequent get still reads disk)', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-svc-'))
|
||||
try {
|
||||
@@ -569,9 +569,9 @@ test('getRunAsync 内存 miss + 磁盘命中 → 返回磁盘值,且不注入
|
||||
|
||||
const got = await svc.getRunAsync('hist-only')
|
||||
expect(got?.returnValue).toEqual({ x: 1 })
|
||||
// 不注入内存:内存 list 不含(未 hydrate)
|
||||
// not injected into memory: in-memory list does not contain (not hydrated)
|
||||
expect(svc.listRuns().map(r => r.runId)).not.toContain('hist-only')
|
||||
// 再次 get 仍能返回(每次走 readRunState fallback)
|
||||
// subsequent get still returns (each goes through readRunState fallback)
|
||||
const got2 = await svc.getRunAsync('hist-only')
|
||||
expect(got2?.returnValue).toEqual({ x: 1 })
|
||||
} finally {
|
||||
@@ -579,7 +579,7 @@ test('getRunAsync 内存 miss + 磁盘命中 → 返回磁盘值,且不注入
|
||||
}
|
||||
})
|
||||
|
||||
test('getRunAsync 内存 miss + 磁盘 miss → undefined', async () => {
|
||||
test('getRunAsync memory miss + disk miss → undefined', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-svc-'))
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
agentMetaText,
|
||||
} from '../panel/status.js'
|
||||
|
||||
test('STATUS_DOT / RUN_STATUS_COLOR / RUN_STATUS_TEXT 覆盖四种 run 状态', () => {
|
||||
test('STATUS_DOT / RUN_STATUS_COLOR / RUN_STATUS_TEXT cover four run states', () => {
|
||||
const statuses: RunProgress['status'][] = [
|
||||
'running',
|
||||
'completed',
|
||||
@@ -31,19 +31,19 @@ test('STATUS_DOT / RUN_STATUS_COLOR / RUN_STATUS_TEXT 覆盖四种 run 状态',
|
||||
expect(RUN_STATUS_TEXT.running).toBe('running')
|
||||
})
|
||||
|
||||
test('PHASE_MARK / PHASE_COLOR 覆盖 running/done/pending', () => {
|
||||
test('PHASE_MARK / PHASE_COLOR cover running/done/pending', () => {
|
||||
expect(PHASE_MARK.running).toBe('●')
|
||||
expect(PHASE_MARK.done).toBe('✓')
|
||||
expect(PHASE_MARK.pending).toBe('○')
|
||||
expect(PHASE_COLOR.pending).toBe('subtle')
|
||||
})
|
||||
|
||||
test('agentVisual:running → ● warning', () => {
|
||||
test('agentVisual: running → ● warning', () => {
|
||||
const a: AgentProgress = { id: 1, status: 'running' }
|
||||
expect(agentVisual(a)).toEqual({ mark: '●', color: 'warning' })
|
||||
})
|
||||
|
||||
test('agentVisual:done·ok → ✓ success(不再带 outputShape 后缀)', () => {
|
||||
test('agentVisual: done·ok → ✓ success (no longer carries outputShape suffix)', () => {
|
||||
const a: AgentProgress = {
|
||||
id: 1,
|
||||
status: 'done',
|
||||
@@ -53,12 +53,12 @@ test('agentVisual:done·ok → ✓ success(不再带 outputShape 后缀)',
|
||||
expect(agentVisual(a)).toEqual({ mark: '✓', color: 'success' })
|
||||
})
|
||||
|
||||
test('agentVisual:dead → ✗ error', () => {
|
||||
test('agentVisual: dead → ✗ error', () => {
|
||||
const a: AgentProgress = { id: 1, status: 'done', resultKind: 'dead' }
|
||||
expect(agentVisual(a)).toEqual({ mark: '✗', color: 'error' })
|
||||
})
|
||||
|
||||
test('formatTokenCount:<1000 原值,≥1000 保留 1 位小数 + k', () => {
|
||||
test('formatTokenCount: <1000 original value, ≥1000 keeps 1 decimal + k', () => {
|
||||
expect(formatTokenCount(undefined)).toBe('0')
|
||||
expect(formatTokenCount(0)).toBe('0')
|
||||
expect(formatTokenCount(42)).toBe('42')
|
||||
@@ -66,7 +66,7 @@ test('formatTokenCount:<1000 原值,≥1000 保留 1 位小数 + k', () => {
|
||||
expect(formatTokenCount(22900)).toBe('22.9k')
|
||||
})
|
||||
|
||||
test('agentMetaText:model · Nk tok · N tool', () => {
|
||||
test('agentMetaText: model · Nk tok · N tool', () => {
|
||||
const a: AgentProgress = {
|
||||
id: 1,
|
||||
status: 'done',
|
||||
@@ -77,7 +77,7 @@ test('agentMetaText:model · Nk tok · N tool', () => {
|
||||
expect(agentMetaText(a)).toBe('glm-5.2 · 22.9k tok · 1 tool')
|
||||
})
|
||||
|
||||
test('agentMetaText:无 model 时省略前段', () => {
|
||||
test('agentMetaText: omits prefix when no model', () => {
|
||||
const a: AgentProgress = {
|
||||
id: 1,
|
||||
status: 'running',
|
||||
|
||||
@@ -18,7 +18,7 @@ test('x → killAgent;K → killWorkflow;r → resume;n → newRun', () =>
|
||||
expect(routeWorkflowKey('n', {})).toBe('newRun')
|
||||
})
|
||||
|
||||
test('confirm 模式:y/Enter → confirmYes;n/Esc/q → confirmNo;其他键 → null', () => {
|
||||
test('confirm mode: y/Enter → confirmYes; n/Esc/q → confirmNo; other keys → null', () => {
|
||||
expect(routeWorkflowKey('y', {}, 'confirm')).toBe('confirmYes')
|
||||
expect(routeWorkflowKey('Y', {}, 'confirm')).toBe('confirmYes')
|
||||
expect(routeWorkflowKey('', { return: true }, 'confirm')).toBe('confirmYes')
|
||||
@@ -26,20 +26,20 @@ test('confirm 模式:y/Enter → confirmYes;n/Esc/q → confirmNo;其他
|
||||
expect(routeWorkflowKey('N', {}, 'confirm')).toBe('confirmNo')
|
||||
expect(routeWorkflowKey('', { escape: true }, 'confirm')).toBe('confirmNo')
|
||||
expect(routeWorkflowKey('q', {}, 'confirm')).toBe('confirmNo')
|
||||
// confirm 模式吞掉导航/编辑键,防误触
|
||||
// confirm mode swallows navigation/edit keys, preventing accidental triggers
|
||||
expect(routeWorkflowKey('x', {}, 'confirm')).toBeNull()
|
||||
expect(routeWorkflowKey('', { tab: true }, 'confirm')).toBeNull()
|
||||
expect(routeWorkflowKey('', { upArrow: true }, 'confirm')).toBeNull()
|
||||
})
|
||||
|
||||
test('←/→ 切焦点列;↑/↓ 列内移动', () => {
|
||||
test('←/→ switch focus column; ↑/↓ move within column', () => {
|
||||
expect(routeWorkflowKey('', { leftArrow: true })).toBe('focusLeft')
|
||||
expect(routeWorkflowKey('', { rightArrow: true })).toBe('focusRight')
|
||||
expect(routeWorkflowKey('', { upArrow: true })).toBe('moveUp')
|
||||
expect(routeWorkflowKey('', { downArrow: true })).toBe('moveDown')
|
||||
})
|
||||
|
||||
test('无关输入 → null', () => {
|
||||
test('unrelated input → null', () => {
|
||||
expect(routeWorkflowKey('z', {})).toBeNull()
|
||||
expect(routeWorkflowKey('', {})).toBeNull()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 深度集成后端:从活会话解析 agent/model/tools,委托核心 runAgent。
|
||||
// 实现 AgentAdapter 接口,由 registry(U5)注册并路由。
|
||||
// Deeply-integrated backend: parses agent/model/tools from the live session, delegates to the core runAgent.
|
||||
// Implements the AgentAdapter interface, registered and routed by the registry (U5).
|
||||
import {
|
||||
type AgentAdapter,
|
||||
type AgentAdapterContext,
|
||||
@@ -32,10 +32,10 @@ import type { Message } from '../../types/message.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import { readHostBundle } from '../hostHandle.js'
|
||||
|
||||
/** workflow 子 agent 的兜底定义(agentType 未命中真实注册表时用)。 */
|
||||
/** Fallback definition for workflow subagents (used when agentType does not match a real registry entry). */
|
||||
export const WORKFLOW_AGENT: BuiltInAgentDefinition = {
|
||||
agentType: 'workflow-worker',
|
||||
whenToUse: 'workflow 脚本内 agent() 钩子派发的子任务',
|
||||
whenToUse: 'subtask dispatched by the agent() hook inside a workflow script',
|
||||
tools: ['*'],
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
@@ -43,7 +43,7 @@ export const WORKFLOW_AGENT: BuiltInAgentDefinition = {
|
||||
'You are a workflow sub-agent. Complete the task concisely; your final text is the return value relayed to the workflow.',
|
||||
}
|
||||
|
||||
/** agentType → 真实 agent 注册表(activeAgents 命中即用,否则兜底)。已导出便于单测。 */
|
||||
/** agentType -> real agent registry (use if activeAgents hits, otherwise fallback). Exported for unit test coverage. */
|
||||
export function resolveAgentDefinition(
|
||||
agentType: string | undefined,
|
||||
toolUseContext: ToolUseContext,
|
||||
@@ -55,7 +55,7 @@ export function resolveAgentDefinition(
|
||||
return found ?? WORKFLOW_AGENT
|
||||
}
|
||||
|
||||
/** model 别名 → 当前 provider 实际 model id。v1 直传(保留映射扩展点)。已导出便于单测。 */
|
||||
/** model alias -> the actual model id of the current provider. v1 passes it through directly (keeps a mapping extension point). Exported for unit test coverage. */
|
||||
export function mapWorkflowModel(
|
||||
model: string | undefined,
|
||||
): string | undefined {
|
||||
@@ -63,21 +63,21 @@ export function mapWorkflowModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 agent 最终消息中提取 schema 模式产出的 JSON 对象;失败返回 null。已导出便于单测。
|
||||
* Extract the JSON object produced under schema mode from the agent's final message; returns null on failure. Exported for unit test coverage.
|
||||
*
|
||||
* 鲁棒性策略(按优先级,第一个 parse 成功的返回):
|
||||
* 1. fenced code block(```json ... ``` 或 ``` ... ```)—— agent 常自发加围栏
|
||||
* 2. 裸文本里第一个"括号平衡"的 {...} 片段—— 处理前后叙述 / 多段输出
|
||||
* Robustness strategy (in priority order, returns the first that successfully parses):
|
||||
* 1. fenced code block (```json ... ``` or ``` ... ```) - agents often spontaneously add fences
|
||||
* 2. the first "brace-balanced" {...} fragment in the bare text - handles preceding/trailing narration / multi-segment output
|
||||
*
|
||||
* 用括号栈扫描而非 `indexOf('{')..lastIndexOf('}')`:能正确处理嵌套对象、
|
||||
* 字符串字面量内的 `{}`、转义字符。不会跨多个不相关 JSON 拼接(原版会)。
|
||||
* Uses a brace-stack scan instead of `indexOf('{')..lastIndexOf('}')`: correctly handles nested objects,
|
||||
* `{}` inside string literals, and escape characters. Will not concatenate multiple unrelated JSON fragments (the original version did).
|
||||
*
|
||||
* 不做语法修复(尾逗号、单引号→双引号、注释删除)—— agent 不会产非标 JSON,
|
||||
* 修了反而可能在字符串内误改(如 `"http://..."` 被 // 注释正则吃掉)。
|
||||
* parse 失败直接 skip 到下一个候选。
|
||||
* Does not do syntax repair (trailing commas, single quotes -> double quotes, comment removal) - agents do not produce non-standard JSON,
|
||||
* and fixing it may instead cause wrong edits inside strings (e.g. `"http://..."` getting eaten by a // comment regex).
|
||||
* On parse failure it directly skips to the next candidate.
|
||||
*
|
||||
* 只返回 plain object(typeof === 'object' && !null && !Array);
|
||||
* schema 模式契约是 object,array/number/string 一律视为 agent 跑题。
|
||||
* Only returns a plain object (typeof === 'object' && !null && !Array);
|
||||
* the schema mode contract is object, array/number/string are all treated as the agent going off-track.
|
||||
*/
|
||||
export function extractStructuredOutput(
|
||||
content: Array<{ type: string; text?: string }>,
|
||||
@@ -90,16 +90,16 @@ export function extractStructuredOutput(
|
||||
return null
|
||||
}
|
||||
|
||||
/** 在 text 中找第一个能 parse 成 plain object 的 JSON 片段。 */
|
||||
/** Find the first JSON fragment in text that can be parsed as a plain object. */
|
||||
function findFirstJsonObject(text: string): unknown | null {
|
||||
// 1. fenced code blocks——优先(agent 自然倾向,剥围栏后 parse 整块)
|
||||
// 1. fenced code blocks - priority (agents naturally tend to add them; strip the fence and parse the whole block)
|
||||
for (const m of text.matchAll(
|
||||
/```[\t ]*[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n?```/g,
|
||||
)) {
|
||||
const parsed = tryParseObject(m[1] ?? '')
|
||||
if (parsed !== null) return parsed
|
||||
}
|
||||
// 2. 裸文本:扫每个 '{',找平衡配对后 try parse
|
||||
// 2. bare text: scan each '{', find a balanced pair and try parse
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] !== '{') continue
|
||||
const end = findBalancedObjectEnd(text, i)
|
||||
@@ -111,9 +111,9 @@ function findFirstJsonObject(text: string): unknown | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 start(必须是 `{`)开始找配对的 `}` 索引;不平衡返 -1。
|
||||
* 跳过字符串字面量内的括号、转义字符。不做注释跳过(JSON 标准不允许注释,
|
||||
* agent 不会产;做了反而风险——见函数 doc)。
|
||||
* Find the matching `}` index starting from start (which must be `{`); returns -1 when unbalanced.
|
||||
* Skips braces inside string literals and escape characters. Does not skip comments (the JSON standard does not allow comments,
|
||||
* agents do not produce them; doing so is a risk - see the function doc).
|
||||
*/
|
||||
function findBalancedObjectEnd(text: string, start: number): number {
|
||||
let depth = 0
|
||||
@@ -122,7 +122,7 @@ function findBalancedObjectEnd(text: string, start: number): number {
|
||||
const c = text[i]
|
||||
if (inString) {
|
||||
if (c === '\\')
|
||||
i++ // 跳过转义符和下一个字符
|
||||
i++ // skip the escape char and the next character
|
||||
else if (c === '"') inString = false
|
||||
continue
|
||||
}
|
||||
@@ -136,7 +136,7 @@ function findBalancedObjectEnd(text: string, start: number): number {
|
||||
return -1
|
||||
}
|
||||
|
||||
/** try parse candidate;只有 plain object 才返回,其它(array/number/null)返 null。 */
|
||||
/** try parse the candidate; only returns a plain object, others (array/number/null) return null. */
|
||||
function tryParseObject(candidate: string): unknown | null {
|
||||
const trimmed = candidate.trim()
|
||||
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null
|
||||
@@ -151,10 +151,10 @@ function tryParseObject(candidate: string): unknown | null {
|
||||
type WorkflowWorktreeInfo = Awaited<ReturnType<typeof createAgentWorktree>>
|
||||
|
||||
/**
|
||||
* 为 workflow agent 的 worktree 隔离生成 slug:sha256(runId:agentId) 派生 hex 段,
|
||||
* 匹配 cleanupStaleAgentWorktrees 的清理正则 `^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$`。
|
||||
* taskId 是 `w`+base36(非 UUID),不能直接塞 runId 进正则段;sha256 是确定性映射,
|
||||
* agentId 保证同 runId 多 agent 的 slug 唯一(无共享计数器,无线程安全问题)。
|
||||
* Generate a slug for the worktree isolation of a workflow agent: derive hex segments from sha256(runId:agentId),
|
||||
* matching the cleanup regex of cleanupStaleAgentWorktrees `^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$`.
|
||||
* taskId is `w`+base36 (not a UUID), so runId cannot be placed directly into the regex segment; sha256 is a deterministic mapping,
|
||||
* and agentId ensures slug uniqueness for multiple agents under the same runId (no shared counter, no thread safety issues).
|
||||
*/
|
||||
function makeWorkflowWorktreeSlug(runId: string, agentId: string): string {
|
||||
const h = createHash('sha256').update(`${runId}:${agentId}`).digest('hex')
|
||||
@@ -162,9 +162,9 @@ function makeWorkflowWorktreeSlug(runId: string, agentId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* agent 完成后清理 worktree:hookBased 保留(无法检测 VCS 变更);否则用
|
||||
* hasWorktreeChanges(fail-closed)检测,无变更 auto-remove,有变更/检测失败保留
|
||||
* 并 log 路径(v1 用日志而非扩 AgentRunResult,避免动 journal 序列化)。
|
||||
* Clean up the worktree after the agent finishes: hookBased keeps it (cannot detect VCS changes); otherwise uses
|
||||
* hasWorktreeChanges (fail-closed) to detect, auto-removes when there is no change, keeps it on change/detection failure
|
||||
* and logs the path (v1 uses logs rather than extending AgentRunResult, to avoid touching journal serialization).
|
||||
*/
|
||||
async function cleanupWorkflowWorktree(
|
||||
info: WorkflowWorktreeInfo,
|
||||
@@ -199,7 +199,7 @@ async function cleanupWorkflowWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
/** 深度集成后端:从活会话解析 agent/model/tools,委托核心 runAgent。 */
|
||||
/** Deeply-integrated backend: parses agent/model/tools from the live session, delegates to the core runAgent. */
|
||||
export const claudeCodeBackend: AgentAdapter = {
|
||||
id: 'claude-code',
|
||||
capabilities: { structuredOutput: true, tools: true },
|
||||
@@ -212,11 +212,11 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
const appState = toolUseContext.getAppState()
|
||||
const agentDef = resolveAgentDefinition(params.agentType, toolUseContext)
|
||||
const model = mapWorkflowModel(params.model)
|
||||
// coreAgentId:core 层子 agent 跟踪 ID(字符串,runAgent 内部用)。
|
||||
// 与 ctx.agentId(引擎 number seq,用于面板/killAgent 路由)是两个不同概念,不可混用。
|
||||
// coreAgentId: the tracking ID for the core-layer subagent (a string, used inside runAgent).
|
||||
// Different from ctx.agentId (the engine's number seq, used for panel / killAgent routing) - two distinct concepts, must not be mixed up.
|
||||
const coreAgentId = createAgentId()
|
||||
|
||||
// isolation:'worktree' — 在独立 git worktree 里跑 agent,并发写互不冲突。
|
||||
// isolation:'worktree' - run the agent inside an independent git worktree, so concurrent writes do not conflict.
|
||||
let worktreeInfo: WorkflowWorktreeInfo | null = null
|
||||
if (params.isolation === 'worktree') {
|
||||
try {
|
||||
@@ -224,7 +224,7 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
makeWorkflowWorktreeSlug(ctx.runId, coreAgentId),
|
||||
)
|
||||
} catch (e) {
|
||||
// fail-closed:隔离未达成不静默退化为共享 cwd(否则并发写数据竞争)
|
||||
// fail-closed: when isolation fails, do not silently fall back to a shared cwd (otherwise concurrent writes race on data)
|
||||
const detail = (e as Error).message
|
||||
logForDebugging(
|
||||
`workflow worktree creation failed (${agentDef.agentType}): ${detail}`,
|
||||
@@ -232,17 +232,17 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
return { kind: 'dead', reason: 'worktree-failed', detail }
|
||||
}
|
||||
}
|
||||
// runWithCwdOverride 让 agent 内的 Bash/Read 等工具看到 worktree 路径
|
||||
// (AsyncLocalStorage 跨 await 保持);runAgent 的 worktreePath 参数仅写 metadata。
|
||||
// runWithCwdOverride makes tools such as Bash/Read inside the agent see the worktree path
|
||||
// (AsyncLocalStorage is preserved across awaits); the worktreePath parameter of runAgent only writes metadata.
|
||||
const runInCwd = worktreeInfo
|
||||
? <T>(fn: () => T): T =>
|
||||
runWithCwdOverride(worktreeInfo!.worktreePath, fn)
|
||||
: <T>(fn: () => T): T => fn()
|
||||
|
||||
// 桥接 ctx.signal → runAgent.override.abortController。否则 workflow 被 kill
|
||||
// 时 runAgent 不知道('x' 无效根因):abort 信号到不了内部 fetch,agent 跑到完成。
|
||||
// 单 agent kill 走 service.kill(runId, agentId) → ports.taskRegistrar.killAgent →
|
||||
// agentAbortControllers.get(agentId).abort();同一 controller 接管两条路径。
|
||||
// Bridge ctx.signal -> runAgent.override.abortController. Otherwise, when the workflow is killed
|
||||
// runAgent is unaware (root cause of 'x' being ineffective): the abort signal cannot reach the internal fetch, and the agent runs to completion.
|
||||
// Single-agent kill goes through service.kill(runId, agentId) -> ports.taskRegistrar.killAgent ->
|
||||
// agentAbortControllers.get(agentId).abort(); the same controller takes over both paths.
|
||||
const agentAbort = new AbortController()
|
||||
const onParentAbort = (): void => agentAbort.abort()
|
||||
if (ctx.signal.aborted) {
|
||||
@@ -263,12 +263,12 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
appState.mcp.tools,
|
||||
)
|
||||
|
||||
// schema → 指示 agent 在最后文本块里直接 emit JSON。
|
||||
// 不要求调 StructuredOutput 工具——它不在 workflow sub-agent 的工具集里(只有
|
||||
// stop_hook 路径显式注入;workflow 走 assembleToolPool 默认池不含)。
|
||||
// 历史上 prompt 要求"call StructuredOutput tool"导致 8/12 agent 拒绝收尾/纠结调用,
|
||||
// 实测 dead 主因是工具不可达而非"忘记"。改契约:raw JSON 文本,extractStructuredOutput
|
||||
// 容错 fenced 围栏 + 前后叙述 + 多段。
|
||||
// schema -> instructs the agent to directly emit JSON in the final text block.
|
||||
// Does not require calling the StructuredOutput tool - it is not in the workflow subagent's tool set (only
|
||||
// the stop_hook path explicitly injects it; workflow goes through assembleToolPool whose default pool does not include it).
|
||||
// Historically the prompt required "call StructuredOutput tool", causing 8/12 agents to refuse to wrap up or struggle to call it;
|
||||
// empirically the main cause of dead is the tool being unreachable rather than "forgetting". Change the contract: raw JSON text, extractStructuredOutput
|
||||
// tolerates fenced fences + preceding/trailing narration + multiple segments.
|
||||
const promptText = params.schema
|
||||
? [
|
||||
params.prompt,
|
||||
@@ -289,7 +289,7 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
const promptMessages = [createUserMessage({ content: promptText })]
|
||||
const messages: Message[] = []
|
||||
const startTime = Date.now()
|
||||
// 运行中进度累计(onProgress 推送 → agent_progress 事件 → 面板实时刷新 token/tool)。
|
||||
// Accumulate running progress (onProgress push -> agent_progress event -> panel refreshes token/tool in real time).
|
||||
let tokenCount = 0
|
||||
let toolCount = 0
|
||||
|
||||
@@ -303,15 +303,15 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
isAsync: true,
|
||||
querySource: toolUseContext.options.querySource ?? 'workflow',
|
||||
availableTools: workerTools,
|
||||
// override 同一对象:coreAgentId(core 子 agent 跟踪)+ abortController(kill 桥接)。
|
||||
// runAgent 的 model 是顶层 ModelAlias;workflow 的 model 是任意别名串,
|
||||
// 类型上不兼容,运行时由 provider 层解析。双重断言透传(优于 as any/never)。
|
||||
// override the same object: coreAgentId (core subagent tracking) + abortController (kill bridge).
|
||||
// runAgent's model is the top-level ModelAlias; workflow's model is an arbitrary alias string,
|
||||
// the types are incompatible and resolved by the provider layer at runtime. Passes through via double assertion (better than as any/never).
|
||||
override: { agentId: coreAgentId, abortController: agentAbort },
|
||||
...(model ? { model: model as unknown as ModelAlias } : {}),
|
||||
...(worktreeInfo ? { worktreePath: worktreeInfo.worktreePath } : {}),
|
||||
})) {
|
||||
messages.push(msg as Message)
|
||||
// 累计运行中进度:assistant message 带 usage(累积值→覆盖)、content 内 tool_use(增量)。
|
||||
// Accumulate running progress: assistant message carries usage (cumulative value -> overwrite), tool_use inside content (incremental).
|
||||
if (msg.type === 'assistant' && msg.message) {
|
||||
const usage = msg.message.usage as
|
||||
| Parameters<typeof getTokenCountFromUsage>[0]
|
||||
@@ -327,9 +327,9 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
// abort(kill workflow / kill agent):识别后必须重抛 WorkflowAbortedError,
|
||||
// 否则 hooks.agent 会把 abort 当作普通失败吞成 dead,workflow 不知道被 kill
|
||||
// (kill 路径 'x' 无效的另一面:信号虽然到了,但结果被伪装成正常完成)。
|
||||
// abort (kill workflow / kill agent): must rethrow WorkflowAbortedError after detection,
|
||||
// otherwise hooks.agent will swallow the abort as an ordinary failure into dead, and the workflow won't know it was killed
|
||||
// (the other side of the 'x' kill path being ineffective: the signal did arrive, but the result was disguised as a normal completion).
|
||||
if (agentAbort.signal.aborted || (e as Error)?.name === 'AbortError') {
|
||||
throw new WorkflowAbortedError()
|
||||
}
|
||||
@@ -340,7 +340,7 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
logEvent('tengu_workflow_agent', { ok: 0 })
|
||||
return { kind: 'dead', reason: 'runagent-threw', detail }
|
||||
} finally {
|
||||
// 清理(幂等):listener removeEventListener / Map.delete 重复调用安全。
|
||||
// cleanup (idempotent): listener removeEventListener / Map.delete are safe to call repeatedly.
|
||||
if (typeof ctx.unregisterAgentAbort === 'function') {
|
||||
ctx.unregisterAgentAbort(ctx.agentId)
|
||||
}
|
||||
@@ -362,7 +362,7 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
})
|
||||
const outputTokens =
|
||||
finalized.usage?.output_tokens ?? finalized.totalTokens ?? 0
|
||||
// 面板展示用:完成时 context 总 token、工具调用次数、解析后 model id。
|
||||
// For panel display: total context tokens, tool-call count, parsed model id at completion.
|
||||
const finalTokenCount = finalized.totalTokens ?? 0
|
||||
const finalToolCount = finalized.totalToolUseCount ?? 0
|
||||
const resolvedModel = model ?? toolUseContext.options.mainLoopModel
|
||||
@@ -371,9 +371,9 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
if (params.schema) {
|
||||
const structured = extractStructuredOutput(finalized.content)
|
||||
if (structured === null) {
|
||||
// agent 跑完所有工具调用但最终文本块里没找到 plain-object JSON。
|
||||
// 典型场景:长 tool chain 后忘记 emit JSON、JSON 嵌套不平衡、parse 失败。
|
||||
// 把最后文本预览进 detail,让 hooks 重试日志和面板能立刻看到 agent 实际说了什么。
|
||||
// The agent finished all tool calls but no plain-object JSON was found in the final text block.
|
||||
// Typical scenarios: forgot to emit JSON after a long tool chain, unbalanced JSON nesting, parse failure.
|
||||
// Put a preview of the last text into detail so the hooks retry log and the panel can immediately see what the agent actually said.
|
||||
const preview = extractTextContent(finalized.content, '\n').slice(
|
||||
0,
|
||||
200,
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { AssistantMessage } from '../types/message.js'
|
||||
import type { AgentId } from '../types/ids.js'
|
||||
import type { ToolUseContext } from '../Tool.js'
|
||||
|
||||
/** HostHandle 内含的不透明 bundle(核心侧解包后使用)。 */
|
||||
/** Opaque bundle held inside HostHandle (unpacked on the core side). */
|
||||
export type WorkflowHostBundle = {
|
||||
toolUseContext: ToolUseContext
|
||||
canUseTool: CanUseToolFn
|
||||
@@ -17,8 +17,8 @@ export type WorkflowHostBundle = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 共享:从 toolUseContext/canUseTool 构造 host bundle。
|
||||
* parentMessage 可选(面板启动路径无——claudeCodeBackend 从不读它)。
|
||||
* Shared: builds the host bundle from toolUseContext/canUseTool.
|
||||
* parentMessage is optional (absent on the panel launch path — claudeCodeBackend never reads it).
|
||||
*/
|
||||
export function buildHostBundle(
|
||||
toolUseContext: WorkflowHostBundle['toolUseContext'],
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import type { Command } from '../types/command.js'
|
||||
import { getProjectRoot } from '../bootstrap/state.js'
|
||||
|
||||
/** 扫描 .claude/workflows/ 下 *.ts|*.js|*.mjs,每个生成一个 /<name> 命令。 */
|
||||
/** Scan *.ts|*.js|*.mjs under .claude/workflows/ and generate a /<name> command for each. */
|
||||
export async function getWorkflowCommands(
|
||||
cwd: string = getProjectRoot(),
|
||||
): Promise<Command[]> {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* Workflow 状态变更通知桥接。
|
||||
* Bridge for workflow status-change notifications.
|
||||
*
|
||||
* 引擎通过 progressEmitter.emit({ type: 'run_done', ... }) 发事件,
|
||||
* progress/store reducer 把状态记到 RunProgress。但旧实现没有任何代码
|
||||
* 把状态转换桥接到 host 通知机制——WorkflowTool 返回文本承诺的"完成时
|
||||
* 会自动通知"实际落空。
|
||||
* The engine emits events via progressEmitter.emit({ type: 'run_done', ... }),
|
||||
* and the progress/store reducer records the status into RunProgress. But the
|
||||
* old implementation had no code bridging status transitions to the host
|
||||
* notification mechanism — the "notifies automatically on completion" promise
|
||||
* in WorkflowTool's return text went unfulfilled.
|
||||
*
|
||||
* 本模块订阅 WorkflowService.subscribe,监听 status 从 running →
|
||||
* completed/failed/killed 的转换,通过注入的 notifier 回调发 host
|
||||
* notification(默认走 enqueuePendingNotification task-notification mode)。
|
||||
* This module subscribes to WorkflowService.subscribe, watches status transitions
|
||||
* from running → completed/failed/killed, and emits a host notification via the
|
||||
* injected notifier callback (defaults to enqueuePendingNotification task-notification mode).
|
||||
*/
|
||||
import {
|
||||
STATUS_TAG,
|
||||
@@ -23,7 +24,7 @@ import type { WorkflowService } from './service.js'
|
||||
|
||||
const WORKFLOW_TASK_TYPE = 'local_workflow'
|
||||
|
||||
/** 通知发送器抽象(便于测试注入 spy)。 */
|
||||
/** Notifier abstraction (lets tests inject a spy). */
|
||||
export type WorkflowNotifier = (message: string) => void
|
||||
|
||||
const TERMINAL_STATUSES: ReadonlySet<RunProgress['status']> = new Set([
|
||||
@@ -32,7 +33,7 @@ const TERMINAL_STATUSES: ReadonlySet<RunProgress['status']> = new Set([
|
||||
'killed',
|
||||
])
|
||||
|
||||
/** 默认通知器:走 host message queue 的 task-notification 模式。 */
|
||||
/** Default notifier: uses the host message queue's task-notification mode. */
|
||||
const defaultNotifier: WorkflowNotifier = message => {
|
||||
enqueuePendingNotification({ value: message, mode: 'task-notification' })
|
||||
}
|
||||
@@ -47,13 +48,13 @@ export function installWorkflowNotifications(
|
||||
const runs = service.listRuns()
|
||||
for (const run of runs) {
|
||||
const prev = prevStatus.get(run.runId)
|
||||
// 初次见到这个 run:仅记录当前状态,不发通知
|
||||
// (避免安装时把已有历史 run 当作新通知触发)
|
||||
// First time seeing this run: just record the current status without notifying
|
||||
// (avoids treating existing historical runs as new notifications on install)
|
||||
if (prev === undefined) {
|
||||
prevStatus.set(run.runId, run.status)
|
||||
continue
|
||||
}
|
||||
// 状态变化 + 进入终态 → 发通知
|
||||
// Status changed + entered terminal state → emit notification
|
||||
if (prev !== run.status && TERMINAL_STATUSES.has(run.status)) {
|
||||
notify(buildMessage(run))
|
||||
}
|
||||
|
||||
@@ -9,27 +9,27 @@ const FRAME_MS = 120;
|
||||
const LABEL_MAX = 18;
|
||||
|
||||
/**
|
||||
* 截断 label 到 max 字符。保留尾部 `#数字` 后缀(audit workflow 的
|
||||
* `verify:${dim}#${findingIdx}` 格式)——同 dimension 多 finding 的 verify
|
||||
* agent label 仍可区分(前缀用 `…` 省略)。无后缀则从右切(旧行为)。
|
||||
* 已导出便于单测覆盖。
|
||||
* Truncate the label to at most max characters. Preserves the trailing `#number` suffix (the audit workflow
|
||||
* `verify:${dim}#${findingIdx}` format) - so verify agent labels with multiple findings under the same dimension
|
||||
* stay distinguishable (the prefix is elided with `…`). When there is no suffix, truncates from the right (legacy behavior).
|
||||
* Exported for unit test coverage.
|
||||
*/
|
||||
export function truncateLabel(raw: string, max: number): string {
|
||||
if (raw.length <= max) return raw;
|
||||
const m = raw.match(/#\d+$/);
|
||||
if (!m) return raw.slice(0, max);
|
||||
const suffix = m[0]; // 含 # 号
|
||||
const suffix = m[0]; // includes the # sign
|
||||
const prefix = raw.slice(0, raw.length - suffix.length);
|
||||
const available = max - suffix.length - 1; // -1 留给 …
|
||||
const available = max - suffix.length - 1; // -1 reserved for …
|
||||
return `${prefix.slice(0, available)}…${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 右 agent 列表(已按选中 phase 过滤)。
|
||||
* 选中行:仅在本列聚焦(focused=true)时铺 selectionBg 底(保留 fg,非反色);
|
||||
* 焦点不在本列时不铺底色,避免“虚假聚焦”。
|
||||
* running agent 的状态符由 useAnimationFrame 驱动 spinner 动画(共享 clock,全局同步);
|
||||
* 右侧 `model · Nk tok · N tool` 由 agent_progress / agent_done 实时刷新。
|
||||
* Right-side agent list (already filtered by the selected phase).
|
||||
* Selected row: only when this column has focus (focused=true) does it paint a selectionBg background (keeps fg, not inverse color);
|
||||
* when focus is not on this column it does not paint the background color, to avoid a "fake focus".
|
||||
* The status mark of a running agent is driven by useAnimationFrame via a spinner animation (shared clock, globally synchronized);
|
||||
* the right side `model · Nk tok · N tool` is refreshed in real time by agent_progress / agent_done.
|
||||
*/
|
||||
export function AgentList({
|
||||
agents,
|
||||
@@ -40,7 +40,7 @@ export function AgentList({
|
||||
selectedIndex: number;
|
||||
focused: boolean;
|
||||
}): React.ReactNode {
|
||||
// 顶层订阅一次动画帧:所有 running agent 共享同一 frame(同步动画,省去逐行 hook)。
|
||||
// Subscribe once to the animation frame at the top level: all running agents share the same frame (synchronized animation, avoids a per-row hook).
|
||||
const [ref, time] = useAnimationFrame(FRAME_MS);
|
||||
const frame = SPINNER_FRAMES[Math.floor(time / FRAME_MS) % SPINNER_FRAMES.length];
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ type PhaseRow = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 左 phase 侧栏:第一行 All(汇总 done/total),其后 merged phases(含 pending ○)。
|
||||
* 选中行:仅在本列聚焦(focused=true)时铺 selectionBg 底(保留 fg,非反色)+ `>` 标记;
|
||||
* 焦点不在本列时不铺底色,避免“虚假聚焦”。running phase 状态符由 useAnimationFrame 驱动 spinner 动画。
|
||||
* 样式对齐参考图:`> ✓ Scan 3/3`。
|
||||
* Left phase sidebar: the first row is All (aggregating done/total), followed by the merged phases (including pending ○).
|
||||
* Selected row: only when this column has focus (focused=true) does it paint a selectionBg background (keeps fg, not inverse color) + a `>` marker;
|
||||
* when focus is not on this column it does not paint the background color, to avoid a "fake focus". The status mark of a running phase is driven by useAnimationFrame via a spinner animation.
|
||||
* Style aligns with the reference image: `> ✓ Scan 3/3`.
|
||||
*/
|
||||
export function PhaseSidebar({
|
||||
phases,
|
||||
|
||||
@@ -6,8 +6,8 @@ import { RUN_STATUS_COLOR, STATUS_DOT } from './status.js';
|
||||
import { tabLabel } from './selectors.js';
|
||||
|
||||
/**
|
||||
* 顶部 run tab 行:每个 run 一个 tab(状态点 + 名 + #短码)。
|
||||
* 当前 tab 用橙色 ═ 下划线高亮。
|
||||
* Top run tab row: one tab per run (status dot + name + #short code).
|
||||
* The current tab is highlighted with an orange ═ underline.
|
||||
*/
|
||||
export function TabsBar({ runs, activeRunId }: { runs: RunProgress[]; activeRunId: string | null }): React.ReactNode {
|
||||
if (runs.length === 0) {
|
||||
|
||||
@@ -12,8 +12,8 @@ import { type FocusColumn, type WorkflowKeyboardHandlers, useWorkflowKeyboard }
|
||||
import { ALL_PHASE, filterAgentsByPhase, formatDuration, mergePhases } from './selectors.js';
|
||||
|
||||
/**
|
||||
* 夹紧选中索引到有效区间(空列表→0;越界→末位;负/NaN→0)。
|
||||
* 抽成模块级纯函数:面板内调用 + 单测覆盖同一逻辑,避免行为漂移。
|
||||
* Clamp the selected index to a valid range (empty list -> 0; out of range -> last position; negative/NaN -> 0).
|
||||
* Extracted into a module-level pure function: called inside the panel + unit tested for the same logic, to avoid behavior drift.
|
||||
*/
|
||||
export function clampSelected(selected: number, len: number): number {
|
||||
if (len === 0) return 0;
|
||||
@@ -23,13 +23,13 @@ export function clampSelected(selected: number, len: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 focused run 是否完成了 running → terminal 的状态转换(用于面板自动退出)。
|
||||
* 抽成纯函数便于单测;面板 useEffect 内部直接调用。
|
||||
* Determine whether the focused run completed the running -> terminal state transition (used for panel auto-exit).
|
||||
* Extracted into a pure function for easy unit testing; called directly inside the panel's useEffect.
|
||||
*
|
||||
* 触发条件:prev 与 curr 是同一 runId,prev 是 running,curr 是 completed/failed/killed。
|
||||
* - 打开历史面板(prev=null):不触发
|
||||
* - 切到已完成 tab(不同 runId):不触发
|
||||
* - 同 run running → terminal:触发
|
||||
* Trigger condition: prev and curr are the same runId, prev is running, curr is completed/failed/killed.
|
||||
* - Opening the history panel (prev=null): does not trigger
|
||||
* - Switching to an already completed tab (different runId): does not trigger
|
||||
* - Same run running -> terminal: triggers
|
||||
*/
|
||||
export function isRunTerminatedTransition(
|
||||
prev: { runId: string; status: RunProgress['status'] } | null,
|
||||
@@ -42,11 +42,11 @@ export function isRunTerminatedTransition(
|
||||
}
|
||||
|
||||
/**
|
||||
* /workflows 主面板:三区焦点模型(顶 tab + 左 phase 侧栏 + 右 agent 列表)。
|
||||
* /workflows main panel: three-region focus model (top tab + left phase sidebar + right agent list).
|
||||
*
|
||||
* - useSyncExternalStore 订阅 WorkflowService(store 返回稳定快照,无变更不重渲染)。
|
||||
* - 焦点状态:activeRunId / focusColumn('phases'|'agents') / selectedPhaseIndex(0=All) / selectedAgentIndex。
|
||||
* - 键位:Tab 切 run · ←/→ 切焦点列 · ↑/↓ 列内移动 · x kill · r resume · q/Esc 退出。
|
||||
* - useSyncExternalStore subscribes to WorkflowService (the store returns stable snapshots, no re-render without change).
|
||||
* - Focus state: activeRunId / focusColumn('phases'|'agents') / selectedPhaseIndex(0=All) / selectedAgentIndex.
|
||||
* - Keybindings: Tab switch run · Left/Right switch focus column · Up/Down move within column · x kill · r resume · q/Esc quit.
|
||||
*/
|
||||
export function WorkflowsPanel({
|
||||
onDone,
|
||||
@@ -66,17 +66,17 @@ export function WorkflowsPanel({
|
||||
const [focusColumn, setFocusColumn] = useState<FocusColumn>('phases');
|
||||
const [selectedPhaseIndex, setSelectedPhaseIndex] = useState(0);
|
||||
const [selectedAgentIndex, setSelectedAgentIndex] = useState(0);
|
||||
// kill 二次确认。null = 无弹窗;'workflow' = 杀整个 run;'agent' = 杀当前选中 agent。
|
||||
// 非 null 时键盘进入 confirm 模式(仅 y/Enter/n/Esc/q 响应)。
|
||||
// kill secondary confirmation. null = no dialog; 'workflow' = kill the whole run; 'agent' = kill the currently selected agent.
|
||||
// When non-null the keyboard enters confirm mode (only y/Enter/n/Esc/q respond).
|
||||
const [confirmKill, setConfirmKill] = useState<null | 'agent' | 'workflow'>(null);
|
||||
|
||||
// mount 时触发一次扫盘 hydrate 历史 run(service 内部 persistedLoaded flag 守护幂等)。
|
||||
// 重 mount/重渲染不会重复扫盘(flag 进程单例守护)。svc 引用稳定(getWorkflowService 单例)。
|
||||
// On mount, trigger a single disk scan to hydrate historical runs (the service's internal persistedLoaded flag guards idempotency).
|
||||
// Re-mount / re-render does not scan again (guarded by the process-singleton flag). The svc reference is stable (getWorkflowService singleton).
|
||||
useEffect(() => {
|
||||
void svc.loadPersistedRuns();
|
||||
}, [svc]);
|
||||
|
||||
// runs 变化时:activeRunId 失效(被 kill / 首次)→ 夹紧到首个
|
||||
// On runs change: activeRunId invalidated (killed / first time) -> clamp to the first one
|
||||
useEffect(() => {
|
||||
if (runs.length === 0) {
|
||||
if (activeRunId !== null) setActiveRunId(null);
|
||||
@@ -89,13 +89,13 @@ export function WorkflowsPanel({
|
||||
|
||||
const focused: RunProgress | undefined = runs.find(r => r.runId === activeRunId);
|
||||
const phases = focused ? mergePhases(focused) : [];
|
||||
// 侧栏含 All 行:phases 数组前补一项 → 总行数 = phases.length + 1
|
||||
// The sidebar includes the All row: prepend one item to the phases array -> total rows = phases.length + 1
|
||||
const phaseRowCount = phases.length + 1;
|
||||
const clampedPhase = clampSelected(selectedPhaseIndex, phaseRowCount);
|
||||
|
||||
// focused run 从 running 转 terminal 时自动退出面板(800ms 延迟让用户看到 ✓/✗ 终态)。
|
||||
// 仅同 runId 的状态转换触发:切到已完成的 tab(prev 是别的 run)不退出;打开历史面板
|
||||
// (prev=null)也不退出。否则 agent 在 Workflow tool 等结果时被面板挡住,用户必须手动 q。
|
||||
// Auto-exit the panel when the focused run transitions from running to terminal (800ms delay so the user sees the ✓/✗ terminal state).
|
||||
// Only triggered by a state transition on the same runId: switching to an already completed tab (prev was a different run) does not exit; opening the history panel
|
||||
// (prev=null) does not exit either. Otherwise the agent is blocked by the panel while waiting for the Workflow tool result, and the user must press q manually.
|
||||
const prevFocusedRef = useRef<{ runId: string; status: RunProgress['status'] } | null>(null);
|
||||
useEffect(() => {
|
||||
const curr = focused ? { runId: focused.runId, status: focused.status } : null;
|
||||
@@ -108,7 +108,7 @@ export function WorkflowsPanel({
|
||||
};
|
||||
}, [focused?.runId, focused?.status, onDone]);
|
||||
|
||||
// 选中 phase title(0 = All = undefined)
|
||||
// Selected phase title (0 = All = undefined)
|
||||
const selectedPhaseTitle = clampedPhase === 0 ? undefined : phases[clampedPhase - 1]?.title;
|
||||
|
||||
const visibleAgents = focused ? filterAgentsByPhase(focused.agents, selectedPhaseTitle) : [];
|
||||
@@ -148,9 +148,9 @@ export function WorkflowsPanel({
|
||||
else setSelectedAgentIndex(s => clampSelected(s + 1, visibleAgents.length));
|
||||
},
|
||||
killAgent: () => {
|
||||
// 仅在 agents 列聚焦时弹 agent 确认(在 phases 列按 x 无目标,no-op)。
|
||||
// 选中 agent 由 visibleAgents[clampedAgent] 决定;保存到 confirmKill 后由
|
||||
// confirmYes 实际执行——避免在两次渲染间 visibleAgents 变化导致误杀。
|
||||
// Only pop the agent confirmation when the agents column is focused (pressing x in the phases column has no target, no-op).
|
||||
// The selected agent is decided by visibleAgents[clampedAgent]; saved into confirmKill and then
|
||||
// actually executed by confirmYes - to avoid mis-killing caused by visibleAgents changing between two renders.
|
||||
if (focusColumn !== 'agents' || !focused) return;
|
||||
const agent = visibleAgents[clampedAgent];
|
||||
if (!agent) return;
|
||||
@@ -173,8 +173,8 @@ export function WorkflowsPanel({
|
||||
},
|
||||
newRun: () => onDone('Tip: start a named workflow with /<name>, or pass name via the Workflow tool.'),
|
||||
quit: () => {
|
||||
// confirm 模式下 q = 取消确认(routeWorkflowKey 已路由到 confirmNo);
|
||||
// 非 confirm 模式才真退出面板。
|
||||
// In confirm mode q = cancel confirmation (routeWorkflowKey already routed to confirmNo);
|
||||
// only in non-confirm mode does it really exit the panel.
|
||||
if (confirmKill !== null) {
|
||||
setConfirmKill(null);
|
||||
return;
|
||||
@@ -184,9 +184,9 @@ export function WorkflowsPanel({
|
||||
confirmYes: () => {
|
||||
if (confirmKill === 'workflow' && focused) {
|
||||
svc.kill(focused.runId);
|
||||
// 杀掉整个 workflow 后立即回主 chat:run_done 事件 → store reducer 把 status 改为
|
||||
// killed → notifications.ts 桥接 enqueuePendingNotification,主 chat 展示
|
||||
// `Workflow "<name>" was stopped`。继续停在面板反而让用户错过"已停止"反馈。
|
||||
// After killing the entire workflow, immediately return to the main chat: the run_done event -> the store reducer changes the status to
|
||||
// killed -> notifications.ts bridges enqueuePendingNotification, and the main chat shows
|
||||
// `Workflow "<name>" was stopped`. Staying on the panel would instead make the user miss the "stopped" feedback.
|
||||
setConfirmKill(null);
|
||||
onDone();
|
||||
return;
|
||||
@@ -204,7 +204,7 @@ export function WorkflowsPanel({
|
||||
const done = runs.length - running;
|
||||
const phaseHeader = selectedPhaseTitle ?? ALL_PHASE;
|
||||
const agentDone = focused ? focused.agents.filter(a => a.status === 'done').length : 0;
|
||||
// 每秒刷新 header 耗时(共享 clock;订阅即触发重渲染,耗时走墙钟)。
|
||||
// Refresh the header duration every second (shared clock; subscribing triggers re-render, duration follows wall clock).
|
||||
const [clockRef] = useAnimationFrame(1000);
|
||||
const elapsed = focused ? Date.now() - focused.startedAt : 0;
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import { SentryErrorBoundary } from '../../components/SentryErrorBoundary.js';
|
||||
import { WorkflowsPanel } from './WorkflowsPanel.js';
|
||||
|
||||
/**
|
||||
* /workflows 的 local-jsx call:构造面板元素返回给 Ink 渲染。
|
||||
* local-jsx call for /workflows: builds the panel element and returns it for Ink to render.
|
||||
*
|
||||
* 用 SentryErrorBoundary 包裹:useSyncExternalStore / listNamed / 子组件
|
||||
* 抛错时不让异常击穿到 REPL 顶层导致整个会话崩溃;boundary 落到本地错误卡片。
|
||||
* onDone/context 由命令运行时注入;args 未使用(面板无参数化行为)。
|
||||
* Wrapped in SentryErrorBoundary: when useSyncExternalStore / listNamed / child components
|
||||
* throw, the exception must not break through to the REPL top level and crash the whole session; the boundary falls back to a local error card.
|
||||
* onDone/context are injected by the command runtime; args is unused (the panel has no parameterized behavior).
|
||||
*/
|
||||
export const call: LocalJSXCommandCall = async (onDone, context, _args) => (
|
||||
<SentryErrorBoundary name="WorkflowsPanel">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AgentProgress, RunProgress } from '../progress/store.js'
|
||||
import type { PhaseStatus } from './status.js'
|
||||
|
||||
/** 「不筛选」固定项的 title(侧栏第一行)。 */
|
||||
/** Title of the fixed "no filter" item (first row of the sidebar). */
|
||||
export const ALL_PHASE = 'All'
|
||||
|
||||
/** 合并后的 phase(含 pending),带该 phase 下 agent 的 done/total 计数。 */
|
||||
/** Merged phase (including pending), with done/total counts of agents under that phase. */
|
||||
export type MergedPhase = {
|
||||
title: string
|
||||
status: PhaseStatus
|
||||
@@ -13,10 +13,10 @@ export type MergedPhase = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 declaredPhases(meta 声明)与 run.phases(实际 running/done):
|
||||
* - 声明顺序优先;未在 declared 但实际出现的 phase 追加末尾。
|
||||
* - 实际无记录 → pending;否则取实际 status。
|
||||
* - done/total = 该 phase 下 done / 全部 agent 数。
|
||||
* Merge declaredPhases (declared by meta) and run.phases (actually running/done):
|
||||
* - Declared order takes priority; phases present in actual but not declared are appended at the end.
|
||||
* - No actual record -> pending; otherwise take the actual status.
|
||||
* - done/total = done under that phase / total agents under that phase.
|
||||
*/
|
||||
export function mergePhases(
|
||||
run: Pick<RunProgress, 'declaredPhases' | 'phases' | 'agents'>,
|
||||
@@ -43,8 +43,8 @@ export function mergePhases(
|
||||
}
|
||||
|
||||
/**
|
||||
* 按选中 phase 筛选 agent。
|
||||
* selectedPhase 为 undefined 或 ALL_PHASE → 全部。
|
||||
* Filter agents by the selected phase.
|
||||
* selectedPhase undefined or ALL_PHASE -> all.
|
||||
*/
|
||||
export function filterAgentsByPhase(
|
||||
agents: AgentProgress[],
|
||||
@@ -54,12 +54,12 @@ export function filterAgentsByPhase(
|
||||
return agents.filter(a => a.phase === selectedPhase)
|
||||
}
|
||||
|
||||
/** tab 标签:workflow 名 + `#` + runId 末 4 位(同名 run 消歧)。 */
|
||||
/** tab label: workflow name + `#` + last 4 chars of runId (disambiguates same-name runs). */
|
||||
export function tabLabel(workflowName: string, runId: string): string {
|
||||
return `${workflowName}#${runId.slice(-4)}`
|
||||
}
|
||||
|
||||
/** 毫秒 → 紧凑耗时(<60s → `Ns`;<60m → `MmSSs`;否则 `HhMMm`)。面板 header 用。 */
|
||||
/** milliseconds -> compact duration (<60s -> `Ns`; <60m -> `MmSSs`; otherwise `HhMMm`). Used by the panel header. */
|
||||
export function formatDuration(ms: number): string {
|
||||
const s = Math.floor(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentProgress, RunProgress } from '../progress/store.js'
|
||||
|
||||
/** run 状态 → 圆点字符(顶部 tab 用)。 */
|
||||
/** run status -> dot character (used by top tab). */
|
||||
export const STATUS_DOT: Record<RunProgress['status'], string> = {
|
||||
running: '●',
|
||||
completed: '✓',
|
||||
@@ -8,7 +8,7 @@ export const STATUS_DOT: Record<RunProgress['status'], string> = {
|
||||
killed: '■',
|
||||
}
|
||||
|
||||
/** run 状态 → ink theme 颜色 token(沿用现有 WorkflowList 配色)。 */
|
||||
/** run status -> ink theme color token (follows existing WorkflowList palette). */
|
||||
export const RUN_STATUS_COLOR: Record<RunProgress['status'], string> = {
|
||||
running: 'warning',
|
||||
completed: 'success',
|
||||
@@ -16,7 +16,7 @@ export const RUN_STATUS_COLOR: Record<RunProgress['status'], string> = {
|
||||
killed: 'subtle',
|
||||
}
|
||||
|
||||
/** run 状态 → 展示文字(header 用;对齐参考图 done/running)。 */
|
||||
/** run status -> display text (used by header; aligns with reference image done/running). */
|
||||
export const RUN_STATUS_TEXT: Record<RunProgress['status'], string> = {
|
||||
running: 'running',
|
||||
completed: 'done',
|
||||
@@ -24,7 +24,7 @@ export const RUN_STATUS_TEXT: Record<RunProgress['status'], string> = {
|
||||
killed: 'killed',
|
||||
}
|
||||
|
||||
/** phase 在侧栏的合并状态(含 pending:meta 声明但未启动)。 */
|
||||
/** merged phase status in the sidebar (includes pending: declared by meta but not started). */
|
||||
export type PhaseStatus = 'running' | 'done' | 'pending'
|
||||
|
||||
export const PHASE_MARK: Record<PhaseStatus, string> = {
|
||||
@@ -39,14 +39,14 @@ export const PHASE_COLOR: Record<PhaseStatus, string> = {
|
||||
pending: 'subtle',
|
||||
}
|
||||
|
||||
/** agent 行的视觉:标记字符 + 颜色(running 由 UI 用 spinner 动画覆盖 mark)。 */
|
||||
/** visual for an agent row: mark character + color (running has the mark overridden by a spinner animation in UI). */
|
||||
export type AgentVisual = { mark: string; color: string }
|
||||
|
||||
/**
|
||||
* agent 状态 → 视觉。
|
||||
* - running → ● warning(UI 用 spinner 动画覆盖 mark)
|
||||
* - done·dead → ✗ error
|
||||
* - done·ok → ✓ success
|
||||
* agent status -> visual.
|
||||
* - running -> ● warning (UI overrides mark with spinner animation)
|
||||
* - done·dead -> ✗ error
|
||||
* - done·ok -> ✓ success
|
||||
*/
|
||||
export function agentVisual(a: AgentProgress): AgentVisual {
|
||||
if (a.status === 'running') return { mark: '●', color: 'warning' }
|
||||
@@ -54,15 +54,15 @@ export function agentVisual(a: AgentProgress): AgentVisual {
|
||||
return { mark: '✓', color: 'success' }
|
||||
}
|
||||
|
||||
/** token 数 → 展示字符串(<1000 原值;否则保留 1 位小数 + k)。 */
|
||||
/** token count -> display string (<1000 keeps the raw value; otherwise keeps 1 decimal + k). */
|
||||
export function formatTokenCount(n: number | undefined): string {
|
||||
if (!n) return '0'
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
|
||||
}
|
||||
|
||||
/**
|
||||
* agent 行右侧统计文本:`model · Nk tok · N tool`。
|
||||
* 无 model 时省略前段;running 中 token/tool 由 agent_progress 实时刷新。
|
||||
* right-side stats text for an agent row: `model · Nk tok · N tool`.
|
||||
* Omits the prefix when there is no model; token/tool refresh in real time via agent_progress while running.
|
||||
*/
|
||||
export function agentMetaText(a: AgentProgress): string {
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useInput } from '@anthropic/ink'
|
||||
|
||||
/** 焦点所在列。 */
|
||||
/** The column that currently has focus. */
|
||||
export type FocusColumn = 'phases' | 'agents'
|
||||
|
||||
/** 键盘模式:normal=正常导航;confirm=弹了 Dialog,等用户 y/n 确认。 */
|
||||
/** Keyboard mode: normal = regular navigation; confirm = a Dialog is open, waiting for the user's y/n confirmation. */
|
||||
export type WorkflowKeyboardMode = 'normal' | 'confirm'
|
||||
|
||||
/** useInput 的 key 对象子集(仅声明用到的字段,避免耦合 ink Key 类型)。 */
|
||||
/** Subset of the useInput key object (only declares the fields we use, to avoid coupling to the ink Key type). */
|
||||
type KeyEvent = {
|
||||
tab?: boolean
|
||||
shift?: boolean
|
||||
@@ -18,7 +18,7 @@ type KeyEvent = {
|
||||
downArrow?: boolean
|
||||
}
|
||||
|
||||
/** 键 → 动作(纯函数,便于单测;无渲染依赖)。 */
|
||||
/** key -> action (pure function, easy to unit test; no rendering dependencies). */
|
||||
export type WorkflowKeyAction =
|
||||
| 'nextTab'
|
||||
| 'prevTab'
|
||||
@@ -39,7 +39,7 @@ export function routeWorkflowKey(
|
||||
key: KeyEvent,
|
||||
mode: WorkflowKeyboardMode = 'normal',
|
||||
): WorkflowKeyAction | null {
|
||||
// confirm 模式:仅 y/Enter 确认,n/Esc/q 取消,其他键吞掉(防误触)
|
||||
// confirm mode: only y/Enter confirms, n/Esc/q cancels, all other keys are swallowed (prevent mis-touch)
|
||||
if (mode === 'confirm') {
|
||||
if (input === 'y' || input === 'Y' || key.return) return 'confirmYes'
|
||||
if (input === 'n' || input === 'N' || key.escape || input === 'q') {
|
||||
@@ -47,11 +47,11 @@ export function routeWorkflowKey(
|
||||
}
|
||||
return null
|
||||
}
|
||||
// @anthropic/ink 的 key.tab 对 Tab 键置 true;个别环境回落到 '\t'
|
||||
// @anthropic/ink sets key.tab to true for the Tab key; some environments fall back to '\t'
|
||||
if (key.tab || input === '\t') return key.shift ? 'prevTab' : 'nextTab'
|
||||
if (key.escape || input === 'q') return 'quit'
|
||||
// 大写 K = 杀整个 workflow;小写 x = 杀当前选中 agent(仅 agents 列)。
|
||||
// 大小写区分避免 x 误触发 workflow kill;K 显式需要 Shift 暗示"重操作"。
|
||||
// Capital K = kill the entire workflow; lowercase x = kill the currently selected agent (agents column only).
|
||||
// Case distinction avoids x accidentally triggering workflow kill; K explicitly requires Shift, hinting at a "heavy operation".
|
||||
if (input === 'K') return 'killWorkflow'
|
||||
if (input === 'x') return 'killAgent'
|
||||
if (input === 'r') return 'resume'
|
||||
@@ -63,7 +63,7 @@ export function routeWorkflowKey(
|
||||
return null
|
||||
}
|
||||
|
||||
/** 焦点模型回调(WorkflowsPanel 注入)。 */
|
||||
/** Focus model callbacks (injected by WorkflowsPanel). */
|
||||
export type WorkflowKeyboardHandlers = {
|
||||
nextTab: () => void
|
||||
prevTab: () => void
|
||||
@@ -71,27 +71,27 @@ export type WorkflowKeyboardHandlers = {
|
||||
focusRight: () => void
|
||||
moveUp: () => void
|
||||
moveDown: () => void
|
||||
/** 请求杀当前选中 agent(panel 弹 Dialog 二次确认)。 */
|
||||
/** Request killing the currently selected agent (panel pops a Dialog for secondary confirmation). */
|
||||
killAgent: () => void
|
||||
/** 请求杀整个 workflow(panel 弹 Dialog 二次确认)。 */
|
||||
/** Request killing the entire workflow (panel pops a Dialog for secondary confirmation). */
|
||||
killWorkflow: () => void
|
||||
resumeFocused: () => void
|
||||
newRun: () => void
|
||||
quit: () => void
|
||||
/** confirm 模式下用户确认(y/Enter)。 */
|
||||
/** User confirms in confirm mode (y/Enter). */
|
||||
confirmYes: () => void
|
||||
/** confirm 模式下用户取消(n/Esc/q)。 */
|
||||
/** User cancels in confirm mode (n/Esc/q). */
|
||||
confirmNo: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* /workflows 面板键位(焦点轮转模型):
|
||||
* - Tab / Shift+Tab:切顶部 run tab
|
||||
* - ← / →:phases ↔ agents 焦点切换
|
||||
* - ↑ / ↓:当前焦点列内移动
|
||||
* - x kill 单 agent · K kill 整个 workflow(带 Dialog 二次确认) · r resume · n new · q / Esc quit
|
||||
* /workflows panel keybindings (focus rotation model):
|
||||
* - Tab / Shift+Tab: switch the top run tab
|
||||
* - Left / Right: switch focus between phases and agents
|
||||
* - Up / Down: move within the currently focused column
|
||||
* - x kill single agent · K kill the entire workflow (with Dialog secondary confirmation) · r resume · n new · q / Esc quit
|
||||
*
|
||||
* @param mode confirm 时只接受 y/n/Esc/q,其他键吞掉——避免在确认弹窗里误导航。
|
||||
* @param mode In confirm mode only y/n/Esc/q are accepted, all other keys are swallowed - avoid mis-navigation inside the confirmation dialog.
|
||||
*/
|
||||
export function useWorkflowKeyboard(
|
||||
h: WorkflowKeyboardHandlers,
|
||||
|
||||
@@ -5,15 +5,15 @@ import { logForDebugging } from '../utils/debug.js'
|
||||
import type { ProgressBus } from './progress/bus.js'
|
||||
import type { ProgressStore, RunProgress } from './progress/store.js'
|
||||
|
||||
/** state.json 当前 schema 版本;升级时引入迁移链。 */
|
||||
/** Current schema version of state.json; introduces a migration chain on upgrade. */
|
||||
const SCHEMA_VERSION = 1
|
||||
const STATE_FILE = 'state.json'
|
||||
const STATE_TMP = 'state.json.tmp'
|
||||
|
||||
/**
|
||||
* runsDir 统一来源:与 ports.ts journalStore 同根(${projectRoot}/.claude/workflow-runs)。
|
||||
* 提取为函数:消除 ports.ts 与持久化逻辑的路径拼接重复,进入 worktree/子目录时保持同根。
|
||||
* 测试用 monkey-patch 本函数指向 tmpdir。
|
||||
* Single source for runsDir: shares the same root as ports.ts journalStore (${projectRoot}/.claude/workflow-runs).
|
||||
* Extracted as a function: eliminates duplicated path concatenation between ports.ts and persistence logic, staying in the same root when entering worktree/subdirectory.
|
||||
* Tests monkey-patch this function to point at a tmpdir.
|
||||
*/
|
||||
export function getRunsDir(): string {
|
||||
return join(getProjectRoot(), '.claude', 'workflow-runs')
|
||||
@@ -25,9 +25,9 @@ type StateFile = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子覆盖写终态 RunProgress 到 <runsDir>/<runId>/state.json。
|
||||
* 原子性:writeFile(tmp) → rename(tmp, target),rename 原子;最坏留 tmp,下次写覆盖。
|
||||
* 失败 best-effort:IO 异常只 log warn,不抛(workflow 已成功,持久化失败只意味着重启后取不到)。
|
||||
* Atomically overwrite the terminal RunProgress to <runsDir>/<runId>/state.json.
|
||||
* Atomicity: writeFile(tmp) → rename(tmp, target), rename is atomic; worst case leaves tmp, next write overwrites it.
|
||||
* Failure is best-effort: IO exceptions only log a warn, do not throw (workflow already succeeded; persistence failure only means it cannot be retrieved after restart).
|
||||
*/
|
||||
export async function writeRunState(
|
||||
runsDir: string,
|
||||
@@ -49,9 +49,9 @@ export async function writeRunState(
|
||||
}
|
||||
|
||||
/**
|
||||
* 读 <runsDir>/<runId>/state.json,容错:
|
||||
* - 文件不存在 → null(调用方按 miss 处理)
|
||||
* - JSON 解析失败 / schema 结构不符 / schemaVersion 不符 → null(log warn,不崩)
|
||||
* Read <runsDir>/<runId>/state.json with fault tolerance:
|
||||
* - File does not exist → null (caller treats it as a miss)
|
||||
* - JSON parse failure / schema structure mismatch / schemaVersion mismatch → null (log warn, do not crash)
|
||||
*/
|
||||
export async function readRunState(
|
||||
runsDir: string,
|
||||
@@ -81,11 +81,11 @@ export async function readRunState(
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描 runsDir 下所有子目录,读取每个 state.json,返回非空 RunProgress 列表。
|
||||
* - runsDir 不存在 → 空数组
|
||||
* - 某子目录无 state.json(半残 run)→ 跳过
|
||||
* - 某子目录 state.json 损坏 → 跳过该单个,继续扫其余
|
||||
* - 按 updatedAt 降序(与 store.list() 排序一致)
|
||||
* Scan all subdirectories under runsDir, read each state.json, return a list of non-null RunProgress.
|
||||
* - runsDir does not exist → empty array
|
||||
* - A subdirectory without state.json (half-written run) → skip
|
||||
* - A subdirectory whose state.json is corrupted → skip that single one, keep scanning the rest
|
||||
* - Sort by updatedAt descending (consistent with store.list() ordering)
|
||||
*/
|
||||
export async function listPersistedRuns(
|
||||
runsDir: string,
|
||||
@@ -105,17 +105,17 @@ export async function listPersistedRuns(
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅 bus 的 run_done 事件,把终态 RunProgress 写到磁盘 state.json。
|
||||
* 覆盖 completed/failed/killed 三态(shutdown-kill 也走 run_done killed)。
|
||||
* store 先于本订阅注册到 bus,故 listener 执行时 store.get(runId) 已是终态。
|
||||
* 返回 unsubscribe 函数(测试清理用)。
|
||||
* Subscribe to the bus's run_done event and write the terminal RunProgress to state.json on disk.
|
||||
* Covers all three terminal states (completed/failed/killed; shutdown-kill also routes to run_done killed).
|
||||
* The store registers to the bus before this subscription, so when the listener runs store.get(runId) is already terminal.
|
||||
* Returns an unsubscribe function (for test cleanup).
|
||||
*
|
||||
* 写盘 best-effort:writeRunState 内部吞 IO 异常只 log,不传播——
|
||||
* 因此 bus 的其他订阅者(store 等)不受持久化失败影响。
|
||||
* Disk write is best-effort: writeRunState swallows IO exceptions and only logs, does not propagate —
|
||||
* so other bus subscribers (store, etc.) are not affected by persistence failures.
|
||||
*
|
||||
* @param runsDirProvider 可选的 runsDir 解析器(默认 getRunsDir)。
|
||||
* 生产路径走默认值;测试注入 tmpdir 避免写真实项目目录(Bun ESM 模块命名空间只读,
|
||||
* 无法 monkey-patch getRunsDir 本身)。
|
||||
* @param runsDirProvider Optional runsDir resolver (defaults to getRunsDir).
|
||||
* Production path uses the default; tests inject a tmpdir to avoid writing to the real project directory (Bun ESM module namespace is read-only,
|
||||
* cannot monkey-patch getRunsDir itself).
|
||||
*/
|
||||
export function attachRunStatePersistence(
|
||||
bus: ProgressBus,
|
||||
|
||||
@@ -34,11 +34,11 @@ type RunBinding = {
|
||||
setAppState: SetAppState
|
||||
abortController: AbortController
|
||||
workflowName: string
|
||||
/** agentId → AbortController。backend 启动 agent 时注册;killAgent 据此精确中断。 */
|
||||
/** agentId → AbortController. Registered when backend starts an agent; killAgent uses it for precise abort. */
|
||||
agentAbortControllers: Map<number, AbortController>
|
||||
}
|
||||
|
||||
/** 每次工具调用从 toolUseContext 构造 WorkflowHostContext。 */
|
||||
/** Constructs a WorkflowHostContext from toolUseContext on each tool invocation. */
|
||||
function makeHostFactory(): WorkflowPorts['hostFactory'] {
|
||||
return ({ context, canUseTool, parentMessage }) => {
|
||||
const ctx = context as WorkflowHostBundle['toolUseContext'] & {
|
||||
@@ -52,20 +52,21 @@ function makeHostFactory(): WorkflowPorts['hostFactory'] {
|
||||
parentMessage as AssistantMessage | undefined,
|
||||
),
|
||||
),
|
||||
// 用 projectRoot 而非 getCwd():与 journalStore 的 runsDir 同根,
|
||||
// 否则用户进入 worktree/子目录时命名 workflow 解析与 journal 落盘不同步。
|
||||
// 引擎内部 ctx.cwd 仅用于解析(scriptPath/name),不影响 agent 执行 cwd
|
||||
// (agent 通过 host bundle 内的 toolUseContext 拿到自己的 cwd)。
|
||||
// Use projectRoot rather than getCwd(): shares the same root as journalStore's runsDir,
|
||||
// otherwise named workflow resolution and journal persistence diverge when the user
|
||||
// enters a worktree/sub-directory. The engine's internal ctx.cwd is only used for
|
||||
// resolution (scriptPath/name) and does not affect the agent's execution cwd
|
||||
// (the agent gets its own cwd via the toolUseContext inside the host bundle).
|
||||
cwd: getProjectRoot(),
|
||||
budgetTotal: null, // turn 级预算注入点(未来从 settings 读)
|
||||
budgetTotal: null, // turn-level budget injection point (read from settings in the future)
|
||||
...(ctx.toolUseId ? { toolUseId: ctx.toolUseId } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装完整 WorkflowPorts。bus/store 由调用方传入(service 单例共享)。
|
||||
* taskRegistrar 维护 runId → RunBinding 供 kill 路由。
|
||||
* Assembles the complete WorkflowPorts. bus/store are passed in by the caller (shared via the service singleton).
|
||||
* taskRegistrar maintains runId → RunBinding for kill routing.
|
||||
*/
|
||||
export function createWorkflowPorts(opts: {
|
||||
bus: ProgressBus
|
||||
@@ -75,8 +76,8 @@ export function createWorkflowPorts(opts: {
|
||||
const runsDir = getRunsDir()
|
||||
const registry = buildRegistry()
|
||||
|
||||
// 遥测订阅(独立于 store)。LogEventMetadata 只接受 boolean/number/undefined,
|
||||
// runId 为字符串——用 analytics 模块自带的 brand cast(已验证非代码/路径)放行。
|
||||
// Telemetry subscription (independent of store). LogEventMetadata only accepts boolean/number/undefined,
|
||||
// and runId is a string — use the brand cast provided by the analytics module (verified non-code/path) to pass it through.
|
||||
opts.bus.subscribe((e: ProgressEvent) => {
|
||||
if (e.type === 'run_done') {
|
||||
logEvent('tengu_workflow_done', {
|
||||
@@ -133,13 +134,13 @@ export function createWorkflowPorts(opts: {
|
||||
kill(runId) {
|
||||
const b = bindings.get(runId)
|
||||
if (!b) return
|
||||
killWorkflowTask(b.taskId, b.setAppState) // 内部 abort controller
|
||||
// 杀 run 同时中断所有 in-flight agent(防止 backend 没接到 task abort 的极端时序)
|
||||
killWorkflowTask(b.taskId, b.setAppState) // internal abort controller
|
||||
// Killing the run also aborts all in-flight agents (guards against the edge timing where the backend misses the task abort)
|
||||
for (const ac of b.agentAbortControllers.values()) {
|
||||
try {
|
||||
ac.abort()
|
||||
} catch {
|
||||
// no-op:abort 内部不会抛,但 fail-closed
|
||||
// no-op: abort won't throw internally, but fail-closed
|
||||
}
|
||||
}
|
||||
b.agentAbortControllers.clear()
|
||||
@@ -169,7 +170,7 @@ export function createWorkflowPorts(opts: {
|
||||
return true
|
||||
},
|
||||
pendingAction() {
|
||||
return null // v1:skip/retry 不接线(seam 保留)
|
||||
return null // v1: skip/retry not wired (seam retained)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -177,7 +178,7 @@ export function createWorkflowPorts(opts: {
|
||||
hostFactory: makeHostFactory(),
|
||||
agentAdapterRegistry: registry,
|
||||
agentRunner: {
|
||||
// 死代码兜底:hooks 始终走 agentAdapterRegistry(ports 必设)。若到此说明 registry 未注册——fail-fast。
|
||||
// Dead-code fallback: hooks always go through agentAdapterRegistry (required on ports). Reaching here means the registry was not registered — fail-fast.
|
||||
async runAgentToResult() {
|
||||
throw new Error(
|
||||
'workflow agentRunner fallback reached — agentAdapterRegistry must be set on ports',
|
||||
@@ -186,12 +187,12 @@ export function createWorkflowPorts(opts: {
|
||||
},
|
||||
progressEmitter: {
|
||||
emit(event) {
|
||||
opts.bus.emit(event) // → store reducer + 遥测
|
||||
opts.bus.emit(event) // → store reducer + telemetry
|
||||
},
|
||||
},
|
||||
taskRegistrar,
|
||||
journalStore: createFileJournalStore(runsDir),
|
||||
permissionGate: { isAborted: () => false }, // 引擎用 ctx.signal 判 abort
|
||||
permissionGate: { isAborted: () => false }, // engine uses ctx.signal to check abort
|
||||
logger: {
|
||||
debug: msg => logForDebugging(msg),
|
||||
warn: msg => logForDebugging(`[workflow warn] ${msg}`),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ProgressEvent } from '@claude-code-best/workflow-engine'
|
||||
|
||||
/** 类型化进度事件总线。引擎 progressEmitter.emit → 广播给所有订阅者(store / 遥测)。 */
|
||||
/** Typed progress event bus. engine progressEmitter.emit -> broadcasts to all subscribers (store / telemetry). */
|
||||
export type ProgressBus = {
|
||||
emit(event: ProgressEvent): void
|
||||
subscribe(listener: (event: ProgressEvent) => void): () => void
|
||||
|
||||
@@ -2,19 +2,19 @@ import type { ProgressEvent } from '@claude-code-best/workflow-engine'
|
||||
import type { ProgressBus } from './bus.js'
|
||||
|
||||
export type AgentProgress = {
|
||||
/** 引擎盖戳的唯一 id,精确关联 started/done(修旧 LIFO 竞态)。 */
|
||||
/** Unique id stamped by the engine, precisely correlates started/done (fixes the old LIFO race condition). */
|
||||
id: number
|
||||
label?: string
|
||||
phase?: string
|
||||
status: 'running' | 'done'
|
||||
resultKind?: string
|
||||
/** 仅 done·ok 时有意义:output 是对象→'object',否则→'text'。dead/skipped 无。 */
|
||||
/** Only meaningful when done·ok: output is an object -> 'object', otherwise -> 'text'. None for dead/skipped. */
|
||||
outputShape?: 'text' | 'object'
|
||||
/** 实际解析后的 model id(agent_done 带入;运行中无)。 */
|
||||
/** Actually parsed model id (carried in by agent_done; none while running). */
|
||||
model?: string
|
||||
/** context 总 token(agent_progress 实时 / agent_done 落地最终值)。 */
|
||||
/** Cumulative context tokens (live via agent_progress / final value settled by agent_done). */
|
||||
tokenCount?: number
|
||||
/** 累计工具调用次数(agent_progress 实时 / agent_done 落地最终值)。 */
|
||||
/** Cumulative tool-call count (live via agent_progress / final value settled by agent_done). */
|
||||
toolCount?: number
|
||||
}
|
||||
|
||||
@@ -23,16 +23,16 @@ export type RunProgress = {
|
||||
workflowName: string
|
||||
status: 'running' | 'completed' | 'failed' | 'killed'
|
||||
phases: Array<{ title: string; status: 'running' | 'done' }>
|
||||
/** 来自 run_started.meta.phases[].title;面板据此显示 pending(○) phase。无 meta → []。 */
|
||||
/** From run_started.meta.phases[].title; the panel uses this to show pending(○) phases. [] when no meta. */
|
||||
declaredPhases: string[]
|
||||
currentPhase: string | null
|
||||
agents: AgentProgress[]
|
||||
agentCount: number
|
||||
returnValue?: unknown
|
||||
error?: string
|
||||
/** run_started 时间戳(面板算运行耗时用)。 */
|
||||
/** run_started timestamp (used by the panel to compute run duration). */
|
||||
startedAt: number
|
||||
/** workflow 描述(来自 run_started.meta.description)。 */
|
||||
/** workflow description (from run_started.meta.description). */
|
||||
description?: string
|
||||
updatedAt: number
|
||||
}
|
||||
@@ -41,14 +41,14 @@ export type ProgressStore = {
|
||||
apply(event: ProgressEvent): void
|
||||
list(): RunProgress[]
|
||||
get(runId: string): RunProgress | undefined
|
||||
/** 直接注入磁盘读出的 run(绕过 bus);已存在的 runId 跳过——内存优先。 */
|
||||
/** Directly inject a run read from disk (bypassing bus); skips existing runId - in-memory takes priority. */
|
||||
hydrate(run: RunProgress): void
|
||||
/** 供 useSyncExternalStore:返回稳定引用,无变更时同一数组。 */
|
||||
/** For useSyncExternalStore: returns a stable reference, the same array when no change. */
|
||||
subscribe(listener: () => void): () => void
|
||||
getSnapshot(): RunProgress[]
|
||||
}
|
||||
|
||||
/** 从 bus 构造 reactive store:订阅 bus,归约事件,通知 React 订阅者。 */
|
||||
/** Build a reactive store from the bus: subscribe to the bus, reduce events, notify React subscribers. */
|
||||
export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore {
|
||||
const byId = new Map<string, RunProgress>()
|
||||
let snapshot: RunProgress[] = []
|
||||
@@ -80,7 +80,7 @@ export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore {
|
||||
}
|
||||
|
||||
const apply = (event: ProgressEvent): void => {
|
||||
// log 不产生可见状态变更(面板无日志视图):早退,避免无谓的快照重建与 React 重渲染
|
||||
// log produces no visible state change (panel has no log view): early exit to avoid pointless snapshot rebuild and React re-render
|
||||
if (event.type === 'log') return
|
||||
const runId = event.runId
|
||||
const p = ensure(
|
||||
@@ -125,7 +125,7 @@ export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore {
|
||||
break
|
||||
}
|
||||
case 'agent_progress': {
|
||||
// 实时进度:仅更新 token/tool(高频,但每 agent message 一次,频率可控)。
|
||||
// live progress: only update token/tool (high frequency, but once per agent message, frequency is controllable).
|
||||
const ap = p.agents.find(x => x.id === event.agentId)
|
||||
if (ap) {
|
||||
ap.tokenCount = event.tokenCount
|
||||
|
||||
@@ -2,8 +2,9 @@ import { AgentAdapterRegistry } from '@claude-code-best/workflow-engine'
|
||||
import { claudeCodeBackend } from './backends/claudeCodeBackend.js'
|
||||
|
||||
/**
|
||||
* 构建多后端 registry。v1(depth B)只注册单一 claude-code adapter 为默认,
|
||||
* 不预填路由规则——扩第二个 provider adapter 时再补 .route(...)。
|
||||
* Build a multi-backend registry. v1 (depth B) only registers a single
|
||||
* claude-code adapter as default, without prefilling routing rules — add
|
||||
* .route(...) when extending with a second provider adapter.
|
||||
*/
|
||||
export function buildRegistry(): AgentAdapterRegistry {
|
||||
const reg = new AgentAdapterRegistry()
|
||||
|
||||
@@ -32,17 +32,17 @@ import type { CanUseToolFn } from '../hooks/useCanUseTool.js'
|
||||
import type { ToolUseContext } from '../Tool.js'
|
||||
|
||||
/**
|
||||
* WorkflowService:工具(U7)与面板(U9)共享的唯一入口。
|
||||
* WorkflowService: the single entry shared by the tool (U7) and panel (U9).
|
||||
*
|
||||
* - `ports`:共享的 WorkflowPorts,工具描述符透传给引擎。
|
||||
* - `launch`:解析脚本 → parseScript 快速校验 → taskRegistrar.register(拿 runId+signal)
|
||||
* → detached runWorkflow → 结束后 complete/fail/kill。
|
||||
* - `kill/listRuns/getRun/subscribe/listNamed`:面板与工具的辅助查询。
|
||||
* - `ports`: shared WorkflowPorts; tool descriptors are passed through to the engine.
|
||||
* - `launch`: parse script → parseScript quick validation → taskRegistrar.register (gets runId+signal)
|
||||
* → detached runWorkflow → on completion routes to complete/fail/kill.
|
||||
* - `kill/listRuns/getRun/subscribe/listNamed`: auxiliary queries for panel and tool.
|
||||
*/
|
||||
export type WorkflowService = {
|
||||
/** 共享端口(工具描述符用)。 */
|
||||
/** Shared ports (used by tool descriptors). */
|
||||
ports: WorkflowPorts
|
||||
/** 面板/工具启动 workflow:解析脚本 → register → detached runWorkflow。 */
|
||||
/** Panel/tool launches a workflow: parse script → register → detached runWorkflow. */
|
||||
launch(
|
||||
input: Pick<
|
||||
WorkflowInput,
|
||||
@@ -60,25 +60,25 @@ export type WorkflowService = {
|
||||
): Promise<{ runId: string; scriptPath?: string }>
|
||||
kill(runId: string): void
|
||||
/**
|
||||
* 中断单个 agent(不影响同 run 其他 agent,workflow 继续跑)。
|
||||
* 返回是否命中(false = agent 已完成/不存在)。agent 被 abort 后返回 dead → null。
|
||||
* Aborts a single agent (does not affect other agents in the same run; workflow keeps running).
|
||||
* Returns whether the agent was hit (false = agent already finished/does not exist). An aborted agent returns dead → null.
|
||||
*/
|
||||
killAgent(runId: string, agentId: number): boolean
|
||||
/**
|
||||
* 进程退出 / 配置卸载时清理:杀掉所有 running run,避免孤儿 task。
|
||||
* 已完成/失败的 run 不受影响。幂等——多次调用安全。
|
||||
* Cleanup on process exit / config unload: kill all running runs to avoid orphan tasks.
|
||||
* Completed/failed runs are unaffected. Idempotent — safe to call multiple times.
|
||||
*/
|
||||
shutdown(): void
|
||||
listRuns(): RunProgress[]
|
||||
getRun(runId: string): RunProgress | undefined
|
||||
/**
|
||||
* 异步按 runId 查:内存命中则返回;miss 读盘 state.json(不注入内存)。
|
||||
* 供"按 runId 取历史 return"场景;面板展示请走 loadPersistedRuns + listRuns。
|
||||
* Async lookup by runId: return on memory hit; on miss read state.json from disk (not injected into memory).
|
||||
* Used by the "get historical return by runId" scenario; for panel display use loadPersistedRuns + listRuns.
|
||||
*/
|
||||
getRunAsync(runId: string): Promise<RunProgress | undefined>
|
||||
/**
|
||||
* 扫盘把所有历史 run 的 state.json hydrate 进 store(已存在 runId 跳过)。
|
||||
* 进程单例内仅实际扫盘一次(persistedLoaded flag);重复调用立即返回。
|
||||
* Scans the disk and hydrates state.json of all historical runs into the store (skips existing runIds).
|
||||
* The process singleton only scans the disk once (persistedLoaded flag); repeated calls return immediately.
|
||||
*/
|
||||
loadPersistedRuns(): Promise<void>
|
||||
subscribe(listener: () => void): () => void
|
||||
@@ -87,30 +87,30 @@ export type WorkflowService = {
|
||||
|
||||
let cached: WorkflowService | null = null
|
||||
|
||||
/** 进程单例。工具与面板共享同一 ports/registry/store。 */
|
||||
/** Process singleton. Tool and panel share the same ports/registry/store. */
|
||||
export function getWorkflowService(): WorkflowService {
|
||||
if (cached) return cached
|
||||
const bus = createProgressBus()
|
||||
const store = createProgressStoreFromBus(bus)
|
||||
const ports = createWorkflowPorts({ bus, store })
|
||||
const service = makeService(ports, store)
|
||||
// 订阅 run_done 写终态快照到磁盘(completed/failed/killed 三态共用入口,shutdown-kill 也走此路径)。
|
||||
// store 先于本订阅注册到 bus,故 listener 执行时 store.get(runId) 已是终态。
|
||||
// Subscribe to run_done to write the terminal snapshot to disk (shared entry for completed/failed/killed; shutdown-kill also routes here).
|
||||
// The store registers to the bus before this subscription, so when the listener runs store.get(runId) is already terminal.
|
||||
attachRunStatePersistence(bus, store)
|
||||
// 安装状态变更通知桥接(commit 0768d4dc 承诺但旧实现落空的"完成时自动通知")
|
||||
// Install the state-change notification bridge (commit 0768d4dc promised "auto-notify on completion" but the old implementation left it unfulfilled)
|
||||
installWorkflowNotifications(service)
|
||||
cached = service
|
||||
return cached
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 service(注入 ports + store)。
|
||||
* Construct the service (inject ports + store).
|
||||
*
|
||||
* 生产路径用 {@link getWorkflowService};测试用本函数直接注入 fake ports,
|
||||
* 避免触碰真实的 getProjectRoot/getCwd/analytics 等模块级副作用。
|
||||
* Production path uses {@link getWorkflowService}; tests use this function to inject fake ports directly,
|
||||
* avoiding touching real getProjectRoot/getCwd/analytics and other module-level side effects.
|
||||
*
|
||||
* @param cwdOverride 仅供测试注入临时目录(避免 inline 持久化写真实项目目录)。
|
||||
* @param runsDirProvider 仅供测试注入 tmpdir(Bun ESM 模块命名空间只读,无法 monkey-patch getRunsDir)。
|
||||
* @param cwdOverride For tests only: inject a temp directory (avoids inline persistence writing to the real project directory).
|
||||
* @param runsDirProvider For tests only: inject a tmpdir (Bun ESM module namespace is read-only, cannot monkey-patch getRunsDir).
|
||||
*/
|
||||
export function makeService(
|
||||
ports: WorkflowPorts,
|
||||
@@ -123,11 +123,11 @@ export function makeService(
|
||||
canUseTool: CanUseToolFn,
|
||||
): WorkflowHostContext => ({
|
||||
handle: makeHostHandle(buildHostBundle(toolUseContext, canUseTool)),
|
||||
// 用 projectRoot 与 ports.ts hostFactory / journalStore 保持同根;
|
||||
// 进入 worktree/子目录时不会让命名 workflow 解析与 journal 落盘不同步。
|
||||
// cwdOverride 仅供测试注入临时目录(避免 inline 持久化写真实项目目录)。
|
||||
// Use projectRoot to stay in sync with ports.ts hostFactory / journalStore;
|
||||
// entering a worktree/subdirectory will not desync named workflow resolution from journal persistence.
|
||||
// cwdOverride is for tests only: inject a temp directory (avoids inline persistence writing to the real project directory).
|
||||
cwd: cwdOverride ?? getProjectRoot(),
|
||||
budgetTotal: null, // turn 级预算注入点(未来从 settings 读)
|
||||
budgetTotal: null, // turn-level budget injection point (in future read from settings)
|
||||
toolUseId: toolUseContext.toolUseId,
|
||||
})
|
||||
|
||||
@@ -155,7 +155,7 @@ export function makeService(
|
||||
const found = await resolveNamedWorkflow(dir, input.name)
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`命名 workflow "${input.name}" 未找到(查找 ${WORKFLOW_DIR_NAME}/)`,
|
||||
`Named workflow "${input.name}" not found (looked in ${WORKFLOW_DIR_NAME}/)`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
@@ -164,11 +164,11 @@ export function makeService(
|
||||
workflowName: input.name,
|
||||
}
|
||||
}
|
||||
throw new Error('必须提供 script、name 或 scriptPath 之一')
|
||||
throw new Error('One of script, name, or scriptPath must be provided')
|
||||
}
|
||||
|
||||
// loadPersistedRuns 的进程单例 flag:首次调用后置 true,后续重复调用立即返回。
|
||||
// 扫盘失败时复位允许下次重试。每个 makeService 调用独立闭包变量(测试构造新 service 时重置)。
|
||||
// Process-singleton flag for loadPersistedRuns: set to true on first call, subsequent calls return immediately.
|
||||
// Reset on scan failure to allow next retry. Each makeService call has its own closure variable (reset when tests build a new service).
|
||||
let persistedLoaded = false
|
||||
|
||||
return {
|
||||
@@ -179,7 +179,7 @@ export function makeService(
|
||||
try {
|
||||
parseScript(script)
|
||||
} catch (e) {
|
||||
throw new Error(`脚本校验失败:${(e as Error).message}`)
|
||||
throw new Error(`Script validation failed: ${(e as Error).message}`)
|
||||
}
|
||||
|
||||
const host = buildHost(toolUseContext, canUseTool)
|
||||
@@ -194,8 +194,8 @@ export function makeService(
|
||||
host.handle,
|
||||
)
|
||||
|
||||
// inline 入口持久化脚本到 run 目录(与 WorkflowTool 对称),返回可复用路径。
|
||||
// 写盘失败降级(log),不阻断 run(script 已在内存)。
|
||||
// Inline entry: persist script to the run directory (symmetric with WorkflowTool), return a reusable path.
|
||||
// Degrade on write failure (log), do not block the run (script is already in memory).
|
||||
let persistedScriptPath: string | undefined
|
||||
if (!workflowFile && input.script) {
|
||||
try {
|
||||
@@ -211,7 +211,7 @@ export function makeService(
|
||||
}
|
||||
}
|
||||
|
||||
// detached:不 await,让调用方立即拿到 runId;结束路由到 registrar。
|
||||
// detached: do not await, let the caller get runId immediately; on completion route to the registrar.
|
||||
void runWorkflow({
|
||||
script,
|
||||
...(input.args !== undefined ? { args: input.args } : {}),
|
||||
@@ -253,10 +253,10 @@ export function makeService(
|
||||
},
|
||||
|
||||
shutdown() {
|
||||
// 仅杀 running:已完成/失败的 run taskRegistrar 已回收 binding,kill 是 no-op。
|
||||
// taskRegistrar.kill 对未知 runId 安全 no-op,因此幂等——多次 shutdown 不重复抛错。
|
||||
// 每个 kill 单独 try/catch:kill 内部走 setAppState,进程 exit 阶段触发 React 重渲染
|
||||
// 可能抛错(render 已卸载等);单个失败不应阻断其他 run 的清理。
|
||||
// Only kill running: for completed/failed runs the taskRegistrar has already reclaimed the binding, kill is a no-op.
|
||||
// taskRegistrar.kill is a safe no-op for unknown runIds, hence idempotent — multiple shutdowns do not throw repeatedly.
|
||||
// Each kill is wrapped in its own try/catch: kill internally routes through setAppState, and process-exit phase triggers a React re-render
|
||||
// which may throw (render already unmounted, etc.); a single failure should not block cleanup of other runs.
|
||||
for (const run of store.list()) {
|
||||
if (run.status !== 'running') continue
|
||||
try {
|
||||
@@ -283,7 +283,7 @@ export function makeService(
|
||||
const runs = await listPersistedRuns(runsDirProvider())
|
||||
for (const run of runs) store.hydrate(run)
|
||||
} catch (e) {
|
||||
// 扫盘失败不阻断面板:log + 复位 flag 允许下次重试
|
||||
// Scan failure does not block the panel: log + reset flag to allow next retry
|
||||
logForDebugging(
|
||||
`[workflow warn] loadPersistedRuns failed: ${(e as Error).message}`,
|
||||
)
|
||||
@@ -300,14 +300,14 @@ export function makeService(
|
||||
}
|
||||
}
|
||||
|
||||
/** 测试用:重置单例(避免跨用例污染)。 */
|
||||
/** For tests: reset the singleton (avoid cross-case contamination). */
|
||||
export function __resetWorkflowServiceForTests(): void {
|
||||
cached = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回已实例化的 service(不创建)。进程退出 / 配置卸载时用本函数 peek,
|
||||
* 没用过 workflow 则 cached 仍为 null——避免在 exit hook 里副作用地创建 bus/ports。
|
||||
* Returns the already-instantiated service (does not create one). Used on process exit / config unload to peek;
|
||||
* if workflow was never used, cached is still null — avoids side-effecting bus/ports creation in the exit hook.
|
||||
*/
|
||||
export function peekWorkflowService(): WorkflowService | null {
|
||||
return cached
|
||||
|
||||
@@ -8,14 +8,15 @@ import { buildTool, type Tool } from '../Tool.js'
|
||||
import { getWorkflowService } from './service.js'
|
||||
|
||||
/**
|
||||
* 把引擎自包含描述符适配为 buildTool 兼容的 Tool。
|
||||
* 描述符统一走 service 单例(共享 ports/registry/store)。
|
||||
* Adapts the engine's self-contained descriptor into a buildTool-compatible Tool.
|
||||
* The descriptor routes through the service singleton (sharing ports/registry/store).
|
||||
*
|
||||
* ports 解析延迟到首次实际方法调用(lazy):tools.ts 在模块加载阶段(feature-gated)
|
||||
* 调用 createWorkflowToolCore(),若此时立即解析 ports 会触发 service 实例化,
|
||||
* 进而调用 getProjectRoot 等模块级副作用——这在 bootstrap 完成前可能拿到错误路径。
|
||||
* Tool 对象本身的单例由 createWorkflowToolCore 的 cached 保证(PermissionRequest
|
||||
* 按引用匹配),ports 单例由 getWorkflowService 保证。
|
||||
* ports resolution is deferred to the first real method call (lazy): tools.ts calls
|
||||
* createWorkflowToolCore() during module-load (feature-gated), and resolving ports
|
||||
* immediately would trigger service instantiation, which in turn calls module-level
|
||||
* side effects like getProjectRoot — yielding wrong paths before bootstrap completes.
|
||||
* The Tool object itself is a singleton via createWorkflowToolCore's cached (PermissionRequest
|
||||
* matches by reference), and the ports singleton is guaranteed by getWorkflowService.
|
||||
*/
|
||||
function buildWorkflowTool(): Tool {
|
||||
let cachedDescriptor: WorkflowToolDescriptor | null = null
|
||||
@@ -55,7 +56,7 @@ function buildWorkflowTool(): Tool {
|
||||
})
|
||||
}
|
||||
|
||||
// 单例:tools.ts 注册与 PermissionRequest 引用需为同一实例(switch 按引用匹配)。
|
||||
// Singleton: tools.ts registration and PermissionRequest must reference the same instance (switch matches by reference).
|
||||
let cached: Tool | null = null
|
||||
|
||||
export function createWorkflowToolCore(): Tool {
|
||||
|
||||
Reference in New Issue
Block a user