feat(workflow): add workflow engine, /workflows panel, /ultracode skill

将 feat/sdk-backend 分支中 workflow 相关的 20 个 commit 压缩为单 commit:

- 工作流引擎核心:phase / agent / parallel / pipeline 编排原语(packages/workflow-engine/)
- /workflows 面板:三区焦点布局(顶部 run tabs + 左侧 phase 侧栏 + 右侧 agent 列表)
- /ultracode skill:多 agent workflow 编排入口
- 进度存储 / journal / notification 系统
- WorkflowService 生命周期管理 + SentryErrorBoundary
- 脚本沙箱:禁用 dynamic import()、JSON args 防御性归一化
- journal 与 named-workflow 路径统一在 projectRoot
- 错误处理:parallel/pipeline hooks 错误日志、failure routing、semaphore abort
- workflow 工具升级为 core 工具 + PascalCase 命名

Co-Authored-By: glm-5.1 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-13 20:07:18 +08:00
parent 91cffe16e2
commit d236880bc3
106 changed files with 16127 additions and 834 deletions

View File

@@ -0,0 +1,145 @@
import React, { useCallback, useMemo } from 'react';
import { Box, Text, useTheme } from '@anthropic/ink';
import { getTheme, type Theme } from 'src/utils/theme.js';
import { env } from 'src/utils/env.js';
import { shouldShowAlwaysAllowOptions } from 'src/utils/permissions/permissionsLoader.js';
import { logUnaryEvent } from 'src/utils/unaryLogging.js';
import { PermissionDialog } from 'src/components/permissions/PermissionDialog.js';
import { PermissionPrompt, type PermissionPromptOption } from 'src/components/permissions/PermissionPrompt.js';
import type { PermissionRequestProps } from 'src/components/permissions/PermissionRequest.js';
import { PermissionRuleExplanation } from 'src/components/permissions/PermissionRuleExplanation.js';
type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no';
/**
* Permission request UI for the WorkflowTool. Asks the user to confirm
* executing a workflow script.
* Follows the MonitorPermissionRequest / FallbackPermissionRequest pattern.
*/
export function WorkflowPermissionRequest({
toolUseConfirm,
onDone,
onReject,
workerBadge,
}: PermissionRequestProps): React.ReactNode {
const [themeName] = useTheme();
const theme = getTheme(themeName);
const input = toolUseConfirm.input as {
workflow: string;
args?: string;
};
const showAlwaysAllowOptions = useMemo(() => shouldShowAlwaysAllowOptions(), []);
const options: PermissionPromptOption<OptionValue>[] = useMemo(() => {
const opts: PermissionPromptOption<OptionValue>[] = [
{
label: 'Yes',
value: 'yes',
feedbackConfig: { type: 'accept' as const },
},
];
if (showAlwaysAllowOptions) {
opts.push({
label: (
<Text>
Yes, and don{'\u2019'}t ask again for <Text bold>{toolUseConfirm.tool.name}</Text> commands
</Text>
),
value: 'yes-dont-ask-again',
});
}
opts.push({
label: 'No',
value: 'no',
feedbackConfig: { type: 'reject' as const },
});
return opts;
}, [showAlwaysAllowOptions, toolUseConfirm.tool.name]);
const handleSelect = useCallback(
(value: OptionValue, feedback?: string) => {
switch (value) {
case 'yes':
logUnaryEvent({
completion_type: 'tool_use_single',
event: 'accept',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
platform: env.platform,
},
});
toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback);
onDone();
break;
case 'yes-dont-ask-again':
logUnaryEvent({
completion_type: 'tool_use_single',
event: 'accept',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
platform: env.platform,
},
});
toolUseConfirm.onAllow(toolUseConfirm.input, [
{
type: 'addRules',
rules: [{ toolName: toolUseConfirm.tool.name }],
behavior: 'allow',
destination: 'localSettings',
},
]);
onDone();
break;
case 'no':
logUnaryEvent({
completion_type: 'tool_use_single',
event: 'reject',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
platform: env.platform,
},
});
toolUseConfirm.onReject(feedback);
onReject();
onDone();
break;
}
},
[toolUseConfirm, onDone, onReject],
);
const handleCancel = useCallback(() => {
logUnaryEvent({
completion_type: 'tool_use_single',
event: 'reject',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
platform: env.platform,
},
});
toolUseConfirm.onReject();
onReject();
onDone();
}, [toolUseConfirm, onDone, onReject]);
return (
<PermissionDialog title="Workflow" workerBadge={workerBadge}>
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Text bold color={theme.permission as keyof Theme}>
Execute workflow: {input.workflow}
</Text>
{input.args && <Text dimColor>Arguments: {input.args}</Text>}
</Box>
<PermissionRuleExplanation permissionResult={toolUseConfirm.permissionResult} toolType="command" />
<PermissionPrompt<OptionValue> options={options} onSelect={handleSelect} onCancel={handleCancel} />
</Box>
</PermissionDialog>
);
}

View File

@@ -0,0 +1,100 @@
import { expect, test } from 'bun:test';
import React from 'react';
import { SentryErrorBoundary } from '../../components/SentryErrorBoundary.js';
import type { RunProgress } from '../progress/store.js';
import { call as panelCall } from '../panel/panelCall.js';
import { clampSelected, WorkflowsPanel } from '../panel/WorkflowsPanel.js';
import { STATUS_DOT } from '../panel/status.js';
// 纯函数:选中夹紧到有效区间(与面板内 clampSelected 同源)。
test('clampSelected空列表→0越界→末位负/NaN→0正常→原值', () => {
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
expect(clampSelected(Number.NaN, 3)).toBe(0);
});
// STATUS_DOT 覆盖四种状态,且均为可见圆点字符。
test('STATUS_DOT 覆盖 running/completed/failed/killed 且为非空字符', () => {
const statuses = ['running', 'completed', 'failed', 'killed'] as const;
for (const s of statuses) {
expect(STATUS_DOT[s]).toBeTruthy();
expect(STATUS_DOT[s].length).toBeGreaterThan(0);
}
});
// 进度数据形态契约:面板读取的字段在典型 RunProgress 上存在/可读,
// 防止 store.ts 结构漂移悄悄破坏面板渲染。
test('RunProgress 字段契约:面板读取的 key 均存在', () => {
const run: RunProgress = {
runId: 'r1',
workflowName: 'review',
status: 'running',
phases: [{ title: 'Find', status: 'done' }],
currentPhase: 'Review',
agents: [{ id: 1, label: 'review:api', phase: 'Review', status: 'running' }],
agentCount: 1,
updatedAt: 1,
};
// 面板 WorkflowList/Detail 读取的路径
expect(run.status).toBe('running');
expect(STATUS_DOT[run.status]).toBe('●');
expect(run.currentPhase).toBe('Review');
expect(run.agents.length).toBe(run.agentCount);
expect(run.phases[0]?.title).toBe('Find');
expect(run.phases[0]?.status).toBe('done');
expect(run.agents[0]?.label).toBe('review:api');
});
// 完成/失败形态returnValue / error 在非 running 时才显示。
test('RunProgress 完成/失败形态returnValue/error 可选', () => {
const completed: RunProgress = {
runId: 'r2',
workflowName: 'w',
status: 'completed',
phases: [],
currentPhase: null,
agents: [],
agentCount: 0,
returnValue: 'ok',
updatedAt: 2,
};
const failed: RunProgress = {
runId: 'r3',
workflowName: 'w',
status: 'failed',
phases: [],
currentPhase: null,
agents: [],
agentCount: 0,
error: 'boom',
updatedAt: 3,
};
expect(completed.returnValue).toBe('ok');
expect(completed.error).toBeUndefined();
expect(failed.error).toBe('boom');
expect(failed.returnValue).toBeUndefined();
expect(STATUS_DOT['completed']).toBe('✓');
expect(STATUS_DOT['failed']).toBe('✗');
});
// 修复 MuseSyncExternalStore / listNamed / 子组件抛错时不应击穿 REPL。
// panelCall 必须把 WorkflowsPanel 包在 SentryErrorBoundary 里。
test('panelCall 用 SentryErrorBoundary 包裹 WorkflowsPanel修复 M 回归)', async () => {
const element = (await (panelCall as unknown as (a: unknown, b: unknown, c: unknown) => Promise<React.ReactNode>)(
() => {},
{ canUseTool: undefined },
'',
)) as React.ReactElement<{ name?: string; children: React.ReactNode }>;
expect(element.type).toBe(SentryErrorBoundary);
expect(element.props.name).toBe('WorkflowsPanel');
const child = element.props.children as React.ReactElement<{
onDone: () => void;
}>;
expect(child.type).toBe(WorkflowsPanel);
expect(React.isValidElement(child)).toBe(true);
expect(typeof child.props.onDone).toBe('function');
});

View File

@@ -0,0 +1,142 @@
import { expect, test, mock } from 'bun:test'
// 注意mock specifier 必须解析到 impl 实际 import 的同一模块bun mock.module
// 按解析后模块匹配。impl 用 '@claude-code-best/builtin-tools/...' 与 'src/*' 别名
// 路径导入,此处用相同 specifier。
mock.module(
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
() => ({
runAgent: async function* () {
yield {
type: 'assistant',
message: { content: [{ type: 'text', text: 'agent-text' }] },
}
},
}),
)
mock.module(
'@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js',
() => ({
finalizeAgentTool: () => ({
content: [{ type: 'text', text: 'agent-text' }],
usage: { output_tokens: 42 },
totalTokens: 42,
}),
}),
)
mock.module(
'@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js',
() => ({
isBuiltInAgent: () => true,
}),
)
mock.module('src/tools.js', () => ({ assembleToolPool: () => ({ tools: [] }) }))
mock.module('src/utils/messages.js', () => ({
createUserMessage: (o: { content: string }) => ({
role: 'user',
content: o.content,
}),
extractTextContent: () => 'agent-text',
}))
mock.module('src/utils/uuid.js', () => ({ createAgentId: () => 'agent-1' }))
mock.module('src/services/analytics/index.js', () => ({ logEvent: () => {} }))
mock.module('src/utils/debug.js', () => ({ logForDebugging: () => {} }))
import {
claudeCodeBackend,
resolveAgentDefinition,
mapWorkflowModel,
extractStructuredOutput,
WORKFLOW_AGENT,
} from '../backends/claudeCodeBackend.js'
import { makeHostHandle } from '../hostHandle.js'
function ctx() {
return {
host: makeHostHandle({
toolUseContext: {
options: {
agentDefinitions: { activeAgents: [] },
querySource: 'workflow',
mainLoopModel: 'm',
},
getAppState: () => ({
toolPermissionContext: {
mode: 'acceptEdits',
alwaysAllowRules: {},
},
mcp: { tools: [] },
}),
} as never,
canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never,
// run() 不读 parentMessage用空对象占位满足 WorkflowHostBundle 类型。
parentMessage: {} as never,
}),
signal: new AbortController().signal,
runId: 'r1',
}
}
test('文本 agent → ok + token 计量', 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)
}
})
test('runAgent 抛错 → dead', async () => {
// 覆盖 mock 让 runAgent 抛last-write-wins
mock.module(
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
() => ({
// biome-ignore lint/correctness/useYield: 故意抛错以测试 dead 分支(不 yield
runAgent: async function* () {
throw new Error('boom')
},
}),
)
const res = await claudeCodeBackend.run({ prompt: 'fail' }, ctx())
expect(res.kind).toBe('dead')
})
test('id 与 capabilities 形状', () => {
expect(claudeCodeBackend.id).toBe('claude-code')
expect(claudeCodeBackend.capabilities.structuredOutput).toBe(true)
expect(claudeCodeBackend.capabilities.tools).toBe(true)
})
test('resolveAgentDefinition无 agentType → WORKFLOW_AGENT 兜底', () => {
const tuc = {
options: { agentDefinitions: { activeAgents: [] } },
} as never
expect(resolveAgentDefinition(undefined, tuc)).toBe(WORKFLOW_AGENT)
})
test('resolveAgentDefinition命中 activeAgents', () => {
const fake = { agentType: 'Explore', permissionMode: 'plan' } as never
const tuc = {
options: { agentDefinitions: { activeAgents: [fake] } },
} as never
expect(resolveAgentDefinition('Explore', tuc)).toBe(fake)
// 未命中仍兜底
expect(resolveAgentDefinition('Nope', tuc)).toBe(WORKFLOW_AGENT)
})
test('mapWorkflowModel 直传', () => {
expect(mapWorkflowModel(undefined)).toBeUndefined()
expect(mapWorkflowModel('claude-haiku-*')).toBe('claude-haiku-*')
})
test('extractStructuredOutput合法 JSON 提取;非法返回 null', () => {
expect(
extractStructuredOutput([
{ type: 'text', text: 'prefix {"a":1,"b":2} suffix' },
]),
).toEqual({ a: 1, b: 2 })
expect(
extractStructuredOutput([{ type: 'text', text: 'no json here' }]),
).toBeNull()
expect(extractStructuredOutput([])).toBeNull()
})

View File

@@ -0,0 +1,175 @@
import { describe, expect, test } from 'bun:test'
import type { RunProgress } from '../progress/store.js'
import type { WorkflowService } from '../service.js'
function makeMockService(runs: RunProgress[]): {
service: WorkflowService
emit: () => void
setRuns: (runs: RunProgress[]) => void
} {
let current = runs
const listeners = new Set<() => void>()
return {
service: {
ports: {},
launch: async () => ({ runId: 'x' }),
kill: () => {},
listRuns: () => current,
getRun: () => undefined,
subscribe: (fn: () => void) => {
listeners.add(fn)
return () => {
listeners.delete(fn)
}
},
listNamed: async () => [],
} as unknown as WorkflowService,
emit: () => {
for (const fn of listeners) fn()
},
setRuns: r => {
current = r
},
}
}
function makeRun(
runId: string,
status: RunProgress['status'],
overrides: Partial<RunProgress> = {},
): RunProgress {
return {
runId,
workflowName: 'wf',
status,
phases: [],
declaredPhases: [],
currentPhase: null,
agents: [],
agentCount: 0,
updatedAt: Date.now(),
...overrides,
}
}
describe('installWorkflowNotifications', () => {
test('running → completed 触发通知(含 workflow 名)', async () => {
const { installWorkflowNotifications } = await import('../notifications.js')
const { service, emit, setRuns } = makeMockService([
makeRun('r1', 'running'),
])
const calls: string[] = []
const unsubscribe = installWorkflowNotifications(service, msg =>
calls.push(msg),
)
// 第一次 emitlistener 记录初始 running 状态,不发通知
emit()
expect(calls.length).toBe(0)
setRuns([makeRun('r1', 'completed')])
emit()
expect(calls.length).toBe(1)
expect(calls[0]).toMatch(/task-notification/)
expect(calls[0]).toMatch(/completed successfully/)
expect(calls[0]).toMatch(/"wf"/)
unsubscribe()
})
test('running → failed 触发通知,含 error 文字', async () => {
const { installWorkflowNotifications } = await import('../notifications.js')
const { service, emit, setRuns } = makeMockService([
makeRun('r1', 'running'),
])
const calls: string[] = []
installWorkflowNotifications(service, msg => calls.push(msg))
emit() // 记录初始 running
setRuns([makeRun('r1', 'failed', { error: 'agent X boom' })])
emit()
expect(calls.length).toBe(1)
expect(calls[0]).toMatch(/failed/)
expect(calls[0]).toMatch(/agent X boom/)
})
test('running → killed 触发通知', async () => {
const { installWorkflowNotifications } = await import('../notifications.js')
const { service, emit, setRuns } = makeMockService([
makeRun('r1', 'running'),
])
const calls: string[] = []
installWorkflowNotifications(service, msg => calls.push(msg))
emit() // 记录初始 running
setRuns([makeRun('r1', 'killed')])
emit()
expect(calls.length).toBe(1)
expect(calls[0]).toMatch(/was stopped/)
})
test('初次见到 run无 prev不发通知避免启动时通知历史 run', 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 转换来)
setRuns([makeRun('r1', 'completed')])
emit()
expect(calls.length).toBe(0)
})
test('running → running 不发通知', async () => {
const { installWorkflowNotifications } = await import('../notifications.js')
const { service, emit, setRuns } = makeMockService([
makeRun('r1', 'running'),
])
const calls: string[] = []
installWorkflowNotifications(service, msg => calls.push(msg))
emit() // 记录初始 running
setRuns([makeRun('r1', 'running', { agentCount: 1 })])
emit()
expect(calls.length).toBe(0)
})
test('已 completed 的 run 再次 emit 不重复通知', async () => {
const { installWorkflowNotifications } = await import('../notifications.js')
const { service, emit, setRuns } = makeMockService([
makeRun('r1', 'running'),
])
const calls: string[] = []
installWorkflowNotifications(service, msg => calls.push(msg))
emit() // 记录初始 running
setRuns([makeRun('r1', 'completed')])
emit()
expect(calls.length).toBe(1)
emit()
expect(calls.length).toBe(1)
})
test('unsubscribe 后不再发通知', async () => {
const { installWorkflowNotifications } = await import('../notifications.js')
const { service, emit, setRuns } = makeMockService([
makeRun('r1', 'running'),
])
const calls: string[] = []
const unsubscribe = installWorkflowNotifications(service, msg =>
calls.push(msg),
)
emit() // 记录初始 running
unsubscribe()
setRuns([makeRun('r1', 'completed')])
emit()
expect(calls.length).toBe(0)
})
})

View File

@@ -0,0 +1,109 @@
import { expect, test } from 'bun:test'
// 注意:本测试不 mock bootstrap/state、utils/cwd、analytics、debug。
// 原因mock.module 是进程全局的last-write-winsmock 这些公共模块会污染
// 同进程其他测试(如 src/commands/__tests__/autonomy.test.ts 经其依赖链 import
// 真实 bootstrap/state。ports 在测试环境下能正常解析 getProjectRoot/getCwd
// logEvent/logForDebugging 在 sink 未 attach 时为静默 no-op无需 mock。
import { buildRegistry } from '../registry.js'
import { createWorkflowPorts } from '../ports.js'
import { createProgressBus } from '../progress/bus.js'
import { createProgressStoreFromBus } from '../progress/store.js'
import { getProjectRoot } from '../../bootstrap/state.js'
import type { SetAppState } from '../../Task.js'
import type { AppState } from '../../state/AppState.tsx'
test('buildRegistry 注册 claude-code 为默认且 resolve 命中', () => {
const reg = buildRegistry()
expect(reg.has('claude-code')).toBe(true)
expect(reg.resolve({ prompt: 'x' }).id).toBe('claude-code')
expect(reg.resolve({ prompt: 'x', agentType: 'whatever' }).id).toBe(
'claude-code',
)
})
test('createWorkflowPorts 组装完整端口(含 agentAdapterRegistry 与 progressEmitter→bus', () => {
const bus = createProgressBus()
const store = createProgressStoreFromBus(bus)
const ports = createWorkflowPorts({ bus, store })
expect(ports.agentAdapterRegistry).toBeDefined()
expect(ports.agentAdapterRegistry!.resolve({ prompt: 'x' }).id).toBe(
'claude-code',
)
expect(typeof ports.taskRegistrar.register).toBe('function')
expect(typeof ports.taskRegistrar.kill).toBe('function')
expect(typeof ports.hostFactory).toBe('function')
// agentRunner 兜底字段仍存在WorkflowPorts 必填)
expect(ports.agentRunner).toBeDefined()
expect(typeof ports.agentRunner.runAgentToResult).toBe('function')
// progressEmitter 经 bus → store发一个 run_startedstore 能看到
ports.progressEmitter.emit({
type: 'run_started',
runId: 't',
workflowName: 'w',
meta: null,
})
expect(store.get('t')?.workflowName).toBe('w')
})
test('taskRegistrar.register/complete/kill 经 RunBinding 路由(真 setAppState无 mock', () => {
const bus = createProgressBus()
const store = createProgressStoreFromBus(bus)
const ports = createWorkflowPorts({ bus, store })
// 真 setAppState用一个本地 AppState 对象承载 tasksregisterTask 走真实代码路径。
const state = { tasks: {} } as unknown as AppState
const setAppState: SetAppState = f => {
Object.assign(state, f(state))
}
const hostCtx = ports.hostFactory({
context: {
agentId: 'a-1',
toolUseId: 'tu-1',
setAppState,
},
canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never,
parentMessage: {} as never,
})
const { runId, signal } = ports.taskRegistrar.register(
{
workflowName: 'wf',
summary: 'summary',
workflowFile: 'wf.ts',
toolUseId: 'tu-1',
},
hostCtx.handle,
)
expect(typeof runId).toBe('string')
expect(signal).toBeInstanceOf(AbortSignal)
// complete/fail/kill 不抛RunBinding 命中)
expect(() => ports.taskRegistrar.complete(runId, 'done')).not.toThrow()
expect(() => ports.taskRegistrar.kill(runId)).not.toThrow()
// 未知 runId 安全 no-op
expect(() => ports.taskRegistrar.complete('nope')).not.toThrow()
expect(ports.taskRegistrar.pendingAction('nope')).toBeNull()
// 终态后 binding 回收:再次 complete 同 runId 应安全 no-op不抛错、不重复调用 workflow task fn
ports.taskRegistrar.complete(runId)
ports.taskRegistrar.kill(runId)
})
test('hostFactory.cwd 与 journalStore 同根getProjectRoot—— 修复 K 回归', () => {
// 历史 bughostFactory.cwd 用 getCwd()、journalStore 用 getProjectRoot()
// 用户进入 worktree/子目录时两者不同 → 命名 workflow 解析与 journal 落盘不同步。
// 修复后两者都用 projectRoot本测试 lock-in 该选择,防止回归。
const bus = createProgressBus()
const store = createProgressStoreFromBus(bus)
const ports = createWorkflowPorts({ bus, store })
const hostCtx = ports.hostFactory({
context: { agentId: 'a', toolUseId: 'tu' },
canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never,
parentMessage: {} as never,
})
expect(hostCtx.cwd).toBe(getProjectRoot())
})

View File

@@ -0,0 +1,23 @@
import { expect, test, mock } from 'bun:test'
import { createProgressBus } from '../progress/bus.js'
test('emit 广播给所有订阅者', () => {
const bus = createProgressBus()
const a = mock(() => {})
const b = mock(() => {})
bus.subscribe(a)
bus.subscribe(b)
const ev = { type: 'log' as const, runId: 'r', message: 'hi' }
bus.emit(ev)
expect(a).toHaveBeenCalledTimes(1)
expect(b).toHaveBeenCalledWith(ev)
})
test('subscribe 返回取消订阅', () => {
const bus = createProgressBus()
const fn = mock(() => {})
const unsub = bus.subscribe(fn)
unsub()
bus.emit({ type: 'log', runId: 'r', message: 'x' })
expect(fn).not.toHaveBeenCalled()
})

View File

@@ -0,0 +1,175 @@
import { expect, test } from 'bun:test'
import { createProgressBus, type ProgressBus } from '../progress/bus.js'
import { createProgressStoreFromBus } from '../progress/store.js'
import type { AgentRunResult } from '@claude-code-best/workflow-engine'
const ok = (o: string): AgentRunResult => ({
kind: 'ok',
output: o,
usage: { outputTokens: 1 },
})
function newStore() {
const bus: ProgressBus = createProgressBus()
return { bus, store: createProgressStoreFromBus(bus) }
}
test('run_started 建条目phase_started/done 更新 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' })
bus.emit({ type: 'phase_started', runId: 'r1', phase: 'B' })
bus.emit({ type: 'phase_done', runId: 'r1', phase: 'A' })
const r = store.get('r1')!
expect(r.phases.map(p => [p.title, p.status])).toEqual([
['A', 'done'],
['B', 'running'],
])
expect(r.currentPhase).toBe('B')
})
test('并发 agent_done 按 agentId 精确关联(回归旧 LIFO 竞态)', () => {
const { bus, store } = newStore()
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
bus.emit({
type: 'agent_started',
runId: 'r1',
agentId: 0,
label: 'a',
phase: 'A',
})
bus.emit({
type: 'agent_started',
runId: 'r1',
agentId: 1,
label: 'b',
phase: 'A',
})
bus.emit({
type: 'agent_done',
runId: 'r1',
agentId: 1,
label: 'b',
phase: 'A',
result: ok('b-out'),
})
bus.emit({
type: 'agent_done',
runId: 'r1',
agentId: 0,
label: 'a',
phase: 'A',
result: ok('a-out'),
})
const agents = store.get('r1')!.agents
expect(agents.find(x => x.id === 0)?.status).toBe('done')
expect(agents.find(x => x.id === 1)?.status).toBe('done')
expect(agents.find(x => x.id === 0)?.label).toBe('a')
expect(agents.find(x => x.id === 1)?.label).toBe('b')
})
test('journal 命中(仅 agent_done 无 started按 id 补建 done 条目', () => {
const { bus, store } = newStore()
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
bus.emit({
type: 'agent_done',
runId: 'r1',
agentId: 7,
label: 'c',
phase: 'A',
result: ok('c'),
})
const a = store.get('r1')!.agents.find(x => x.id === 7)!
expect(a.status).toBe('done')
})
test('run_done 终态 + list 排序 + subscribe 通知', () => {
const { bus, store } = newStore()
let calls = 0
store.subscribe(() => calls++)
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
bus.emit({
type: 'run_done',
runId: 'r1',
status: 'completed',
returnValue: 42,
})
const r = store.get('r1')!
expect(r.status).toBe('completed')
expect(r.returnValue).toBe(42)
expect(store.list().map(x => x.runId)).toEqual(['r1'])
expect(calls).toBe(2)
})
test('run_done failed 终态记录 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' })
const r = store.get('r2')!
expect(r.status).toBe('failed')
expect(r.error).toBe('boom')
})
test('log 事件不触发 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
})
test('run_started 落地 declaredPhases来自 meta.phases顺序保留', () => {
const { bus, store } = newStore()
bus.emit({
type: 'run_started',
runId: 'r1',
workflowName: 'w',
meta: {
name: 'w',
description: 'd',
phases: [{ title: 'Find' }, { title: 'Review' }, { title: 'Verify' }],
},
})
expect(store.get('r1')!.declaredPhases).toEqual(['Find', 'Review', 'Verify'])
})
test('run_started meta 为 null → declaredPhases = []', () => {
const { bus, store } = newStore()
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
expect(store.get('r1')!.declaredPhases).toEqual([])
})
test('agent_done 落地 outputShapeok·object / ok·text / dead 无)', () => {
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' })
bus.emit({ type: 'agent_started', runId: 'r1', agentId: 1, phase: 'A' })
bus.emit({ type: 'agent_started', runId: 'r1', agentId: 2, phase: 'A' })
bus.emit({
type: 'agent_done',
runId: 'r1',
agentId: 0,
phase: 'A',
result: { kind: 'ok', output: { x: 1 }, usage: { outputTokens: 1 } },
})
bus.emit({
type: 'agent_done',
runId: 'r1',
agentId: 1,
phase: 'A',
result: { kind: 'ok', output: 'hi', usage: { outputTokens: 1 } },
})
bus.emit({
type: 'agent_done',
runId: 'r1',
agentId: 2,
phase: 'A',
result: { kind: 'dead' },
})
const agents = store.get('r1')!.agents
expect(agents.find(a => a.id === 0)?.outputShape).toBe('object')
expect(agents.find(a => a.id === 1)?.outputShape).toBe('text')
expect(agents.find(a => a.id === 2)?.outputShape).toBeUndefined()
})

View File

@@ -0,0 +1,81 @@
import { expect, test } from 'bun:test'
import type { AgentProgress, RunProgress } from '../progress/store.js'
import {
ALL_PHASE,
mergePhases,
filterAgentsByPhase,
tabLabel,
} from '../panel/selectors.js'
function run(partial: Partial<RunProgress>): RunProgress {
return {
runId: 'r1',
workflowName: 'w',
status: 'running',
phases: [],
declaredPhases: [],
currentPhase: null,
agents: [],
agentCount: 0,
updatedAt: 1,
...partial,
}
}
test('mergePhases声明顺序优先实际 phase 追加未声明的,计数 done/total', () => {
const r = run({
declaredPhases: ['Find', 'Review', 'Verify'],
phases: [
{ title: 'Find', status: 'done' },
{ title: 'Review', status: 'running' },
],
agents: [
{
id: 1,
phase: 'Find',
status: 'done',
resultKind: 'ok',
outputShape: 'text',
},
{ id: 2, phase: 'Find', status: 'done', resultKind: 'dead' },
{ id: 3, phase: 'Review', status: 'running' },
],
})
expect(mergePhases(r)).toEqual([
{ title: 'Find', status: 'done', done: 2, total: 2 },
{ title: 'Review', status: 'running', done: 0, total: 1 },
{ title: 'Verify', status: 'pending', done: 0, total: 0 },
])
})
test('mergePhases实际出现但未声明的 phase 追加到末尾', () => {
const r = run({
declaredPhases: ['Find'],
phases: [
{ title: 'Find', status: 'done' },
{ title: 'Adhoc', status: 'running' },
],
agents: [],
})
expect(mergePhases(r).map(p => p.title)).toEqual(['Find', 'Adhoc'])
})
test('filterAgentsByPhaseAll / undefined → 全部;指定 → 仅该 phase', () => {
const agents: AgentProgress[] = [
{ id: 1, phase: 'A', status: 'running' },
{
id: 2,
phase: 'B',
status: 'done',
resultKind: 'ok',
outputShape: 'text',
},
]
expect(filterAgentsByPhase(agents, undefined)).toHaveLength(2)
expect(filterAgentsByPhase(agents, ALL_PHASE)).toHaveLength(2)
expect(filterAgentsByPhase(agents, 'A')).toEqual([agents[0]])
})
test('tabLabelworkflow 名 + runId 后 4 位短码', () => {
expect(tabLabel('review-changes', 'wf_abc123def')).toBe('review-changes#3def')
})

View File

@@ -0,0 +1,335 @@
import { expect, test } from 'bun:test'
// DI 模式:不使用 mock.module进程全局、last-write-wins会污染同进程其他测试如
// autonomy.test.ts。改为手工构造 FAKE WorkflowPortsregistry.run 返回固定 ok
// 结果taskRegistrar 维护 abort 绑定journalStore 内存空实现。真实 runWorkflow
// 因此跑完且无需 LLM 或 mock。
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { makeService, __resetWorkflowServiceForTests } from '../service.js'
import { createProgressBus } from '../progress/bus.js'
import { createProgressStoreFromBus } from '../progress/store.js'
import type {
AgentRunResult,
ProgressEvent,
WorkflowPorts,
} from '@claude-code-best/workflow-engine'
// 构造 FAKE portsregistry.run 返回固定 AgentRunResulttaskRegistrar 带 binding
// journalStore 内存空实现。progressEmitter.emit → bus.emitstore 已在构造时订阅 bus
// 注意runWorkflow 自身会发 run_started/run_donetaskRegistrar 只管 abort 绑定,
// 不重复发事件(避免 store reducer 收到重复 run_done
type RegistrarCall =
| { kind: 'complete'; runId: string; summary?: string }
| { kind: 'fail'; runId: string; error?: string }
| { kind: 'kill'; runId: string }
function fakePorts(
opts: {
/** adapter.run 抛错(模拟 agent 后端崩溃)。 */
adapterThrow?: string
/** adapter.run 返回值(默认 ok。 */
adapterResult?: AgentRunResult
/** agentRunner.runAgentToResult 返回值fallback 路径,默认 throw。 */
runnerResult?: AgentRunResult
} = {},
): {
ports: WorkflowPorts
store: ReturnType<typeof createProgressStoreFromBus>
killed: string[]
/** taskRegistrar 调用记录complete/fail/kill。 */
calls: RegistrarCall[]
} {
const bus = createProgressBus()
const store = createProgressStoreFromBus(bus)
const killed: string[] = []
const calls: RegistrarCall[] = []
const bindings = new Map<string, { abort: AbortController }>()
let seq = 0
const ports = {
// hostFactory 实际不被 service.launch 路径调用service 自建 host handle
// 但 WorkflowPorts 类型要求存在;保留一个最小实现。
hostFactory: () => ({
handle: {} as never,
cwd: '/tmp',
budgetTotal: null,
toolUseId: 'tu',
}),
agentAdapterRegistry: {
resolve: () => ({
id: 'claude-code',
capabilities: { structuredOutput: true },
run:
opts.adapterThrow !== undefined
? async (): Promise<AgentRunResult> => {
throw new Error(opts.adapterThrow)
}
: async (): Promise<AgentRunResult> =>
opts.adapterResult ?? {
kind: 'ok',
output: 'mock-out',
usage: { outputTokens: 1 },
},
}),
},
agentRunner: {
runAgentToResult:
opts.runnerResult !== undefined
? async () => opts.runnerResult
: async () => {
throw new Error('should not reach')
},
},
progressEmitter: {
emit: (e: ProgressEvent) => bus.emit(e),
},
taskRegistrar: {
register: ({ workflowName }: { workflowName: string }) => {
const abort = new AbortController()
seq += 1
const runId = `run-${seq}`
bindings.set(runId, { abort })
return { runId, signal: abort.signal }
},
complete: (runId: string, summary?: string) => {
calls.push({ kind: 'complete', runId, summary })
},
fail: (runId: string, error?: string) => {
calls.push({ kind: 'fail', runId, error })
},
kill: (runId: string) => {
killed.push(runId)
calls.push({ kind: 'kill', runId })
bindings.get(runId)?.abort.abort()
},
pendingAction: () => null,
},
journalStore: {
read: async () => [],
append: async () => {},
truncate: async () => {},
},
permissionGate: { isAborted: () => false },
logger: {
debug: () => {},
event: () => {},
warn: () => {},
},
} as unknown as WorkflowPorts
return { ports, store, killed, calls }
}
const stubTUC = { agentId: 'a1', toolUseId: 'tu' } as never
const stubCanUseTool = (() => Promise.resolve({ behavior: 'allow' })) as never
/** 等待 detached runWorkflow 完成detached 调用,需让微任务/宏任务排空)。 */
async function settle(): Promise<void> {
await new Promise(r => setTimeout(r, 60))
}
test('launch → completedstore 出现该 run', async () => {
__resetWorkflowServiceForTests()
const { ports, store } = fakePorts()
const svc = makeService(ports, store)
const { runId } = await svc.launch(
{ script: `return agent('compute')` },
stubTUC,
stubCanUseTool,
)
await settle()
const r = svc.getRun(runId)
expect(r).toBeDefined()
// detached 执行可能在 settle 窗口内仍 running或已 completed——两者皆可接受。
expect(['completed', 'running']).toContain(r!.status)
expect(r!.workflowName).toBe('workflow')
})
test('kill 走 taskRegistrar.kill', async () => {
__resetWorkflowServiceForTests()
const { ports, store, killed } = fakePorts()
const svc = makeService(ports, store)
const { runId } = await svc.launch(
{ script: `return agent('x')` },
stubTUC,
stubCanUseTool,
)
svc.kill(runId)
expect(killed).toContain(runId)
})
test('listRuns/subscribe 来自 store', () => {
__resetWorkflowServiceForTests()
const { ports, store } = fakePorts()
const svc = makeService(ports, store)
expect(svc.listRuns()).toEqual([])
let n = 0
const unsub = svc.subscribe(() => {
n++
})
expect(typeof unsub).toBe('function')
unsub()
expect(n).toBe(0)
})
test('listNamed 委托 namedWorkflows空目录→[];有文件→列出)', async () => {
__resetWorkflowServiceForTests()
const { ports, store } = fakePorts()
const svc = makeService(ports, store)
// 不存在的目录 → []
const empty = await svc.listNamed(
join(tmpdir(), `wf-nope-${Math.random().toString(36).slice(2)}`),
)
expect(empty).toEqual([])
// 有命名文件的目录 → 列出 name去扩展名排序
const dir = await mkdtemp(join(tmpdir(), 'wf-named-'))
try {
await writeFile(
join(dir, 'a.ts'),
'export const meta = { name: "a", description: "d" }\nreturn 1',
)
await writeFile(join(dir, 'b.js'), 'return 2')
const names = await svc.listNamed(dir)
expect(names).toEqual(['a', 'b'])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('缺 script/name/scriptPath → 抛错', async () => {
__resetWorkflowServiceForTests()
const { ports, store } = fakePorts()
const svc = makeService(ports, store)
await expect(svc.launch({}, stubTUC, stubCanUseTool)).rejects.toThrow(
/script|name|scriptPath/,
)
})
test('scriptPath 读取文件内容并校验', async () => {
__resetWorkflowServiceForTests()
const { ports, store } = fakePorts()
const svc = makeService(ports, store)
const dir = await mkdtemp(join(tmpdir(), 'wf-path-'))
const file = join(dir, 's.ts')
try {
await writeFile(file, `return agent('from-file')`)
const { runId } = await svc.launch(
{ scriptPath: file },
stubTUC,
stubCanUseTool,
)
await settle()
const r = svc.getRun(runId)
expect(r).toBeDefined()
expect(['completed', 'running']).toContain(r!.status)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('parseScript 校验失败 → launch 抛错', async () => {
__resetWorkflowServiceForTests()
const { ports, store } = fakePorts()
const svc = makeService(ports, store)
// 触发 ScriptErrormeta 字面量缺 descriptionvalidateMeta 要求 name+description 均为字符串)
await expect(
svc.launch(
{ script: `export const meta = { name: "x" }\nreturn 1` },
stubTUC,
stubCanUseTool,
),
).rejects.toThrow(/校验失败/)
})
// ---- 服务层失败路由覆盖(审查 gap.then/.catch → taskRegistrar 路径)----
test('脚本运行抛错 → service 路由到 taskRegistrar.fail带 error 文本', async () => {
__resetWorkflowServiceForTests()
const { ports, store, calls } = fakePorts()
const svc = makeService(ports, store)
await svc.launch(
{ script: `throw new Error('script boom')` },
stubTUC,
stubCanUseTool,
)
await settle()
const fail = calls.find(c => c.kind === 'fail')
expect(fail).toBeDefined()
expect(fail?.kind === 'fail' && fail.error).toMatch(/script boom/)
})
test('adapter 抛错 → service 通过 .catch 路径路由到 taskRegistrar.fail', async () => {
__resetWorkflowServiceForTests()
const { ports, store, calls } = fakePorts({ adapterThrow: 'adapter boom' })
const svc = makeService(ports, store)
await svc.launch({ script: `return agent('x')` }, stubTUC, stubCanUseTool)
await settle()
const fail = calls.find(c => c.kind === 'fail')
expect(fail).toBeDefined()
// adapter throw → runWorkflow 的内部 try/catch 转 failed statuserror 透传;
// 或透传到 detached promise 的 .catch。两者最终都进 taskRegistrar.fail。
expect(fail?.kind === 'fail' && fail.error).toMatch(/adapter boom/)
})
test('脚本正常完成 → service 路由到 taskRegistrar.complete', async () => {
__resetWorkflowServiceForTests()
const { ports, store, calls } = fakePorts()
const svc = makeService(ports, store)
await svc.launch({ script: `return agent('x')` }, stubTUC, stubCanUseTool)
await settle()
expect(calls.some(c => c.kind === 'complete')).toBe(true)
})
// ---- 修复 Nshutdown 清理 ----
test('shutdown 杀掉所有 running runtaskRegistrar.kill 调用每个)', async () => {
__resetWorkflowServiceForTests()
const { ports, store, killed } = fakePorts()
// 让 adapter 慢一点settle 期间 run 仍在 running
const slowPorts = {
...ports,
agentAdapterRegistry: {
resolve: () => ({
id: 'claude-code',
capabilities: { structuredOutput: true },
run: async (): Promise<AgentRunResult> => {
await new Promise(r => setTimeout(r, 200))
return { kind: 'ok', output: 'slow', usage: { outputTokens: 1 } }
},
}),
},
} as unknown as typeof ports
const slowSvc = makeService(slowPorts, store)
const { runId: a } = await slowSvc.launch(
{ script: `return agent('a')` },
stubTUC,
stubCanUseTool,
)
const { runId: b } = await slowSvc.launch(
{ script: `return agent('b')` },
stubTUC,
stubCanUseTool,
)
killed.length = 0
slowSvc.shutdown()
expect(killed).toContain(a)
expect(killed).toContain(b)
})
test('shutdown 不重复杀已完成 run幂等多次调用安全', async () => {
__resetWorkflowServiceForTests()
const { ports, store, killed } = fakePorts()
const svc = makeService(ports, store)
const { runId } = await svc.launch(
{ script: `return agent('x')` },
stubTUC,
stubCanUseTool,
)
await settle() // 完成
killed.length = 0
svc.shutdown()
// 已完成的不应再被 kill
expect(killed).not.toContain(runId)
// 幂等
expect(() => svc.shutdown()).not.toThrow()
})

View File

@@ -0,0 +1,75 @@
import { expect, test } from 'bun:test'
import type { AgentProgress, RunProgress } from '../progress/store.js'
import {
STATUS_DOT,
RUN_STATUS_COLOR,
PHASE_MARK,
PHASE_COLOR,
agentVisual,
} from '../panel/status.js'
test('STATUS_DOT / RUN_STATUS_COLOR 覆盖四种 run 状态且为非空字符', () => {
const statuses: RunProgress['status'][] = [
'running',
'completed',
'failed',
'killed',
]
for (const s of statuses) {
expect(STATUS_DOT[s].length).toBeGreaterThan(0)
expect(RUN_STATUS_COLOR[s]).toBeTruthy()
}
expect(STATUS_DOT.running).toBe('●')
expect(STATUS_DOT.completed).toBe('✓')
expect(STATUS_DOT.failed).toBe('✗')
expect(STATUS_DOT.killed).toBe('■')
})
test('PHASE_MARK / PHASE_COLOR 覆盖 running/done/pending', () => {
expect(PHASE_MARK.running).toBe('●')
expect(PHASE_MARK.done).toBe('✓')
expect(PHASE_MARK.pending).toBe('○')
expect(PHASE_COLOR.pending).toBe('subtle')
})
test('agentVisualrunning → ● warning running', () => {
const a: AgentProgress = { id: 1, status: 'running' }
expect(agentVisual(a)).toEqual({
mark: '●',
color: 'warning',
suffix: 'running',
})
})
test('agentVisualdone·object → ✓ success object', () => {
const a: AgentProgress = {
id: 1,
status: 'done',
resultKind: 'ok',
outputShape: 'object',
}
expect(agentVisual(a)).toEqual({
mark: '✓',
color: 'success',
suffix: 'object',
})
})
test('agentVisualdone·text → ✓ success text', () => {
const a: AgentProgress = {
id: 1,
status: 'done',
resultKind: 'ok',
outputShape: 'text',
}
expect(agentVisual(a)).toEqual({
mark: '✓',
color: 'success',
suffix: 'text',
})
})
test('agentVisualdead → ✗ error dead', () => {
const a: AgentProgress = { id: 1, status: 'done', resultKind: 'dead' }
expect(agentVisual(a)).toEqual({ mark: '✗', color: 'error', suffix: 'dead' })
})

View File

@@ -0,0 +1,30 @@
import { expect, test } from 'bun:test'
import { routeWorkflowKey } from '../panel/useWorkflowKeyboard.js'
test('Tab → nextTabShift+Tab → prevTab', () => {
expect(routeWorkflowKey('', { tab: true })).toBe('nextTab')
expect(routeWorkflowKey('', { tab: true, shift: true })).toBe('prevTab')
})
test('q / Esc → quit', () => {
expect(routeWorkflowKey('q', {})).toBe('quit')
expect(routeWorkflowKey('', { escape: true })).toBe('quit')
})
test('x → killr → resumen → newRun', () => {
expect(routeWorkflowKey('x', {})).toBe('kill')
expect(routeWorkflowKey('r', {})).toBe('resume')
expect(routeWorkflowKey('n', {})).toBe('newRun')
})
test('←/→ 切焦点列;↑/↓ 列内移动', () => {
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', () => {
expect(routeWorkflowKey('z', {})).toBeNull()
expect(routeWorkflowKey('', {})).toBeNull()
})

View File

@@ -0,0 +1,158 @@
// 深度集成后端:从活会话解析 agent/model/tools委托核心 runAgent。
// 实现 AgentAdapter 接口,由 registryU5注册并路由。
import {
type AgentAdapter,
type AgentAdapterContext,
type AgentRunParams,
type AgentRunResult,
} from '@claude-code-best/workflow-engine'
import { assembleToolPool } from '../../tools.js'
import { finalizeAgentTool } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js'
import { runAgent } from '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js'
import {
isBuiltInAgent,
type AgentDefinition,
type BuiltInAgentDefinition,
} from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
import { createUserMessage, extractTextContent } from '../../utils/messages.js'
import { createAgentId } from '../../utils/uuid.js'
import { logForDebugging } from '../../utils/debug.js'
import { logEvent } from '../../services/analytics/index.js'
import type { ModelAlias } from '../../utils/model/aliases.js'
import type { Message } from '../../types/message.js'
import type { ToolUseContext } from '../../Tool.js'
import { readHostBundle } from '../hostHandle.js'
/** workflow 子 agent 的兜底定义agentType 未命中真实注册表时用)。 */
export const WORKFLOW_AGENT: BuiltInAgentDefinition = {
agentType: 'workflow-worker',
whenToUse: 'workflow 脚本内 agent() 钩子派发的子任务',
tools: ['*'],
source: 'built-in',
baseDir: 'built-in',
getSystemPrompt: () =>
'You are a workflow sub-agent. Complete the task concisely; your final text is the return value relayed to the workflow.',
}
/** agentType → 真实 agent 注册表activeAgents 命中即用,否则兜底)。已导出便于单测。 */
export function resolveAgentDefinition(
agentType: string | undefined,
toolUseContext: ToolUseContext,
): AgentDefinition {
if (!agentType) return WORKFLOW_AGENT
const found = toolUseContext.options.agentDefinitions.activeAgents.find(
a => a.agentType === agentType,
)
return found ?? WORKFLOW_AGENT
}
/** model 别名 → 当前 provider 实际 model id。v1 直传(保留映射扩展点)。已导出便于单测。 */
export function mapWorkflowModel(
model: string | undefined,
): string | undefined {
return model
}
/** 从 agent 最终消息中提取 StructuredOutput 产出的 JSON 对象;失败返回 null。已导出便于单测。 */
export function extractStructuredOutput(
content: Array<{ type: string; text?: string }>,
): unknown | null {
for (const block of content) {
if (block.type === 'text' && block.text) {
const trimmed = block.text.trim()
const start = trimmed.indexOf('{')
const end = trimmed.lastIndexOf('}')
if (start >= 0 && end > start) {
try {
return JSON.parse(trimmed.slice(start, end + 1))
} catch {
// 继续尝试下一个文本块
}
}
}
}
return null
}
/** 深度集成后端:从活会话解析 agent/model/tools委托核心 runAgent。 */
export const claudeCodeBackend: AgentAdapter = {
id: 'claude-code',
capabilities: { structuredOutput: true, tools: true },
async run(
params: AgentRunParams,
ctx: AgentAdapterContext,
): Promise<AgentRunResult> {
const { toolUseContext, canUseTool } = readHostBundle(ctx.host)
const appState = toolUseContext.getAppState()
const agentDef = resolveAgentDefinition(params.agentType, toolUseContext)
const model = mapWorkflowModel(params.model)
const agentId = createAgentId()
const workerPermissionContext = {
...appState.toolPermissionContext,
mode: agentDef.permissionMode ?? 'acceptEdits',
}
const workerTools = assembleToolPool(
workerPermissionContext,
appState.mcp.tools,
)
// schema → 通过 prompt 追加 JSON Schema 指令(非交互模式 StructuredOutput 已启用)
const promptText = params.schema
? `${params.prompt}\n\nYou MUST return your final answer by calling the StructuredOutput tool with a value matching this JSON Schema:\n${JSON.stringify(params.schema)}`
: params.prompt
const promptMessages = [createUserMessage({ content: promptText })]
const messages: Message[] = []
const startTime = Date.now()
try {
for await (const msg of runAgent({
agentDefinition: agentDef,
promptMessages,
toolUseContext,
canUseTool,
isAsync: true,
querySource: toolUseContext.options.querySource ?? 'workflow',
availableTools: workerTools,
override: { agentId },
// runAgent 的 model 是顶层 ModelAliasworkflow 的 model 是任意别名串,
// 类型上不兼容,运行时由 provider 层解析。双重断言透传(优于 as any/never
...(model ? { model: model as unknown as ModelAlias } : {}),
})) {
messages.push(msg as Message)
}
} catch (e) {
logForDebugging(
`workflow sub-agent error (${agentDef.agentType}): ${(e as Error).message}`,
)
logEvent('tengu_workflow_agent', { ok: 0 })
return { kind: 'dead' }
}
const finalized = finalizeAgentTool(messages, agentId, {
prompt: params.prompt,
resolvedAgentModel: toolUseContext.options.mainLoopModel,
isBuiltInAgent: isBuiltInAgent(agentDef),
startTime,
agentType: agentDef.agentType,
isAsync: true,
})
const outputTokens =
finalized.usage?.output_tokens ?? finalized.totalTokens ?? 0
logEvent('tengu_workflow_agent', { ok: 1, outputTokens })
if (params.schema) {
const structured = extractStructuredOutput(finalized.content)
if (structured === null) return { kind: 'dead' }
return {
kind: 'ok',
output: structured as object,
usage: { outputTokens },
}
}
const text = extractTextContent(finalized.content, '\n')
return { kind: 'ok', output: text, usage: { outputTokens } }
},
}

View File

@@ -0,0 +1,42 @@
import {
createHostHandle,
unwrapHostHandle,
type HostHandle,
} from '@claude-code-best/workflow-engine'
import type { CanUseToolFn } from '../hooks/useCanUseTool.js'
import type { AssistantMessage } from '../types/message.js'
import type { AgentId } from '../types/ids.js'
import type { ToolUseContext } from '../Tool.js'
/** HostHandle 内含的不透明 bundle核心侧解包后使用。 */
export type WorkflowHostBundle = {
toolUseContext: ToolUseContext
canUseTool: CanUseToolFn
parentMessage?: AssistantMessage
agentId?: AgentId
}
/**
* 共享:从 toolUseContext/canUseTool 构造 host bundle。
* parentMessage 可选面板启动路径无——claudeCodeBackend 从不读它)。
*/
export function buildHostBundle(
toolUseContext: WorkflowHostBundle['toolUseContext'],
canUseTool: WorkflowHostBundle['canUseTool'],
parentMessage?: AssistantMessage,
): WorkflowHostBundle {
return {
toolUseContext,
canUseTool,
...(parentMessage !== undefined ? { parentMessage } : {}),
agentId: toolUseContext.agentId,
}
}
export function makeHostHandle(bundle: WorkflowHostBundle): HostHandle {
return createHostHandle(bundle)
}
export function readHostBundle(handle: HostHandle): WorkflowHostBundle {
return unwrapHostHandle(handle) as WorkflowHostBundle
}

View File

@@ -0,0 +1,34 @@
import { join } from 'node:path'
import {
listNamedWorkflows,
WORKFLOW_DIR_NAME,
} from '@claude-code-best/workflow-engine'
import type { Command } from '../types/command.js'
import { getProjectRoot } from '../bootstrap/state.js'
/** 扫描 .claude/workflows/ 下 *.ts|*.js|*.mjs每个生成一个 /<name> 命令。 */
export async function getWorkflowCommands(
cwd: string = getProjectRoot(),
): Promise<Command[]> {
const dir = join(cwd, WORKFLOW_DIR_NAME)
const names = await listNamedWorkflows(dir)
return names.map(name => ({
type: 'prompt',
name,
description: `Run workflow: ${name}`,
kind: 'workflow',
source: 'builtin',
progressMessage: `Running workflow ${name}...`,
contentLength: 0,
async getPromptForCommand(args, _context) {
const argText =
typeof args === 'string' && args ? `\n\nArguments: ${args}` : ''
return [
{
type: 'text',
text: `Run the "${name}" workflow now by calling the Workflow tool with name="${name}".${argText}`,
},
]
},
}))
}

View File

@@ -0,0 +1,87 @@
/**
* Workflow 状态变更通知桥接。
*
* 引擎通过 progressEmitter.emit({ type: 'run_done', ... }) 发事件,
* progress/store reducer 把状态记到 RunProgress。但旧实现没有任何代码
* 把状态转换桥接到 host 通知机制——WorkflowTool 返回文本承诺的"完成时
* 会自动通知"实际落空。
*
* 本模块订阅 WorkflowService.subscribe监听 status 从 running →
* completed/failed/killed 的转换,通过注入的 notifier 回调发 host
* notification默认走 enqueuePendingNotification task-notification mode
*/
import {
STATUS_TAG,
SUMMARY_TAG,
TASK_ID_TAG,
TASK_NOTIFICATION_TAG,
TASK_TYPE_TAG,
} from '../constants/xml.js'
import { enqueuePendingNotification } from '../utils/messageQueueManager.js'
import type { RunProgress } from './progress/store.js'
import type { WorkflowService } from './service.js'
const WORKFLOW_TASK_TYPE = 'local_workflow'
/** 通知发送器抽象(便于测试注入 spy。 */
export type WorkflowNotifier = (message: string) => void
const TERMINAL_STATUSES: ReadonlySet<RunProgress['status']> = new Set([
'completed',
'failed',
'killed',
])
/** 默认通知器:走 host message queue 的 task-notification 模式。 */
const defaultNotifier: WorkflowNotifier = message => {
enqueuePendingNotification({ value: message, mode: 'task-notification' })
}
export function installWorkflowNotifications(
service: WorkflowService,
notify: WorkflowNotifier = defaultNotifier,
): () => void {
const prevStatus = new Map<string, RunProgress['status'] | undefined>()
const unsubscribe = service.subscribe(() => {
const runs = service.listRuns()
for (const run of runs) {
const prev = prevStatus.get(run.runId)
// 初次见到这个 run仅记录当前状态不发通知
// (避免安装时把已有历史 run 当作新通知触发)
if (prev === undefined) {
prevStatus.set(run.runId, run.status)
continue
}
// 状态变化 + 进入终态 → 发通知
if (prev !== run.status && TERMINAL_STATUSES.has(run.status)) {
notify(buildMessage(run))
}
prevStatus.set(run.runId, run.status)
}
})
return () => {
unsubscribe()
prevStatus.clear()
}
}
function buildMessage(run: RunProgress): string {
const statusText =
run.status === 'completed'
? 'completed successfully'
: run.status === 'failed'
? 'failed'
: 'was stopped'
const errorSuffix =
run.status === 'failed' && run.error ? `: ${run.error}` : ''
const summary = `Workflow "${run.workflowName}" ${statusText}${errorSuffix}`
return `<${TASK_NOTIFICATION_TAG}>
<${TASK_ID_TAG}>${run.runId}</${TASK_ID_TAG}>
<${TASK_TYPE_TAG}>${WORKFLOW_TASK_TYPE}</${TASK_TYPE_TAG}>
<${STATUS_TAG}>${run.status}</${STATUS_TAG}>
<${SUMMARY_TAG}>${summary}</${SUMMARY_TAG}>
</${TASK_NOTIFICATION_TAG}>`
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { Theme } from '@anthropic/ink';
import type { AgentProgress } from '../progress/store.js';
import { agentVisual } from './status.js';
const LABEL_WIDTH = 18;
/**
* 右 agent 列表(已按选中 phase 过滤)。
* 光标行铺橙底;每行:标记 + label + 行尾状态文字running/object/text/dead
*/
export function AgentList({
agents,
selectedIndex,
}: {
agents: AgentProgress[];
selectedIndex: number;
}): React.ReactNode {
if (agents.length === 0) {
return <Text color="subtle">(no agents in this phase)</Text>;
}
return (
<Box flexDirection="column">
{agents.map((a, i) => {
const v = agentVisual(a);
const selected = i === selectedIndex;
const label = (a.label ?? `agent-${a.id}`).slice(0, LABEL_WIDTH).padEnd(LABEL_WIDTH);
return (
<Box key={a.id}>
<Text backgroundColor={selected ? 'claude' : undefined}>
<Text color={v.color as keyof Theme}>{v.mark}</Text> {label} <Text color="subtle">{v.suffix}</Text>
</Text>
</Box>
);
})}
</Box>
);
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { Theme } from '@anthropic/ink';
import type { AgentProgress } from '../progress/store.js';
import { PHASE_COLOR, PHASE_MARK, type PhaseStatus } from './status.js';
import { ALL_PHASE, type MergedPhase } from './selectors.js';
type PhaseRow = {
title: string;
status?: PhaseStatus;
done: number;
total: number;
};
/**
* 左 phase 侧栏:第一行 All汇总 done/total其后 merged phases含 pending ○)。
* 选中行铺橙底文字色不变selectedIndex=0 表示 All。
*/
export function PhaseSidebar({
phases,
agents,
selectedIndex,
}: {
phases: MergedPhase[];
agents: AgentProgress[];
selectedIndex: number;
}): React.ReactNode {
const totalAgents = agents.length;
const doneAgents = agents.filter(a => a.status === 'done').length;
const rows: PhaseRow[] = [{ title: ALL_PHASE, done: doneAgents, total: totalAgents }, ...phases];
return (
<Box flexDirection="column">
{rows.map((row, i) => {
const selected = i === selectedIndex;
const mark = row.status ? PHASE_MARK[row.status] : ' ';
const color = row.status ? (PHASE_COLOR[row.status] as keyof Theme) : undefined;
return (
<Box key={row.title}>
<Text backgroundColor={selected ? 'claude' : undefined} color={color}>
{selected ? '▶' : ' '}
{mark} {row.title.padEnd(10)} {row.done}/{row.total}
</Text>
</Box>
);
})}
</Box>
);
}

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { Theme } from '@anthropic/ink';
import type { RunProgress } from '../progress/store.js';
import { RUN_STATUS_COLOR, STATUS_DOT } from './status.js';
import { tabLabel } from './selectors.js';
/**
* 顶部 run tab 行:每个 run 一个 tab状态点 + 名 + #短码)。
* 当前 tab 用橙色 ═ 下划线高亮。
*/
export function TabsBar({ runs, activeRunId }: { runs: RunProgress[]; activeRunId: string | null }): React.ReactNode {
if (runs.length === 0) {
return <Text color="subtle">(no runs)</Text>;
}
return (
<Box>
{runs.map(r => {
const active = r.runId === activeRunId;
const label = tabLabel(r.workflowName, r.runId);
const underline = '═'.repeat(label.length + 2);
return (
<Box key={r.runId} flexDirection="column" marginRight={2}>
<Box>
<Text color={RUN_STATUS_COLOR[r.status] as keyof Theme}>{STATUS_DOT[r.status]}</Text>
<Text> </Text>
<Text color={active ? 'claude' : undefined} bold={active}>
{label}
</Text>
</Box>
<Text color={active ? 'claude' : undefined}>{active ? underline : ''}</Text>
</Box>
);
})}
</Box>
);
}

View File

@@ -0,0 +1,162 @@
import React, { useEffect, useState, useSyncExternalStore } from 'react';
import { Box, Text } from '@anthropic/ink';
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
import { getWorkflowService } from '../service.js';
import type { RunProgress } from '../progress/store.js';
import { AgentList } from './AgentList.js';
import { PhaseSidebar } from './PhaseSidebar.js';
import { TabsBar } from './TabsBar.js';
import { type FocusColumn, type WorkflowKeyboardHandlers, useWorkflowKeyboard } from './useWorkflowKeyboard.js';
import { ALL_PHASE, filterAgentsByPhase, mergePhases } from './selectors.js';
/**
* 夹紧选中索引到有效区间空列表→0越界→末位负/NaN→0
* 抽成模块级纯函数:面板内调用 + 单测覆盖同一逻辑,避免行为漂移。
*/
export function clampSelected(selected: number, len: number): number {
if (len === 0) return 0;
const n = Math.trunc(selected);
if (Number.isNaN(n) || n < 0) return 0;
return Math.min(n, len - 1);
}
/**
* /workflows 主面板:三区焦点模型(顶 tab + 左 phase 侧栏 + 右 agent 列表)。
*
* - useSyncExternalStore 订阅 WorkflowServicestore 返回稳定快照,无变更不重渲染)。
* - 焦点状态activeRunId / focusColumn('phases'|'agents') / selectedPhaseIndex(0=All) / selectedAgentIndex。
* - 键位Tab 切 run · ←/→ 切焦点列 · ↑/↓ 列内移动 · x kill · r resume · q/Esc 退出。
*/
export function WorkflowsPanel({
onDone,
context,
}: {
onDone: LocalJSXCommandOnDone;
context: LocalJSXCommandContext;
}): React.ReactNode {
const svc = getWorkflowService();
const runs = useSyncExternalStore(
svc.subscribe,
() => svc.listRuns(),
() => [],
);
const [activeRunId, setActiveRunId] = useState<string | null>(null);
const [focusColumn, setFocusColumn] = useState<FocusColumn>('phases');
const [selectedPhaseIndex, setSelectedPhaseIndex] = useState(0);
const [selectedAgentIndex, setSelectedAgentIndex] = useState(0);
// runs 变化时activeRunId 失效(被 kill / 首次)→ 夹紧到首个
useEffect(() => {
if (runs.length === 0) {
if (activeRunId !== null) setActiveRunId(null);
return;
}
if (!runs.some(r => r.runId === activeRunId)) {
setActiveRunId(runs[0]!.runId);
}
}, [runs, activeRunId]);
const focused: RunProgress | undefined = runs.find(r => r.runId === activeRunId);
const phases = focused ? mergePhases(focused) : [];
// 侧栏含 All 行phases 数组前补一项 → 总行数 = phases.length + 1
const phaseRowCount = phases.length + 1;
const clampedPhase = clampSelected(selectedPhaseIndex, phaseRowCount);
// 选中 phase title0 = All = undefined
const selectedPhaseTitle = clampedPhase === 0 ? undefined : phases[clampedPhase - 1]?.title;
const visibleAgents = focused ? filterAgentsByPhase(focused.agents, selectedPhaseTitle) : [];
const clampedAgent = clampSelected(selectedAgentIndex, visibleAgents.length);
const switchTab = (runId: string): void => {
setActiveRunId(runId);
setFocusColumn('phases');
setSelectedPhaseIndex(0);
setSelectedAgentIndex(0);
};
const nextTab = (): void => {
if (runs.length === 0) return;
const idx = runs.findIndex(r => r.runId === activeRunId);
const next = runs[(idx + 1) % runs.length]!;
switchTab(next.runId);
};
const prevTab = (): void => {
if (runs.length === 0) return;
const idx = runs.findIndex(r => r.runId === activeRunId);
const next = runs[(idx - 1 + runs.length) % runs.length]!;
switchTab(next.runId);
};
const handlers: WorkflowKeyboardHandlers = {
nextTab,
prevTab,
focusLeft: () => setFocusColumn('phases'),
focusRight: () => setFocusColumn('agents'),
moveUp: () => {
if (focusColumn === 'phases') setSelectedPhaseIndex(s => clampSelected(s - 1, phaseRowCount));
else setSelectedAgentIndex(s => clampSelected(s - 1, visibleAgents.length));
},
moveDown: () => {
if (focusColumn === 'phases') setSelectedPhaseIndex(s => clampSelected(s + 1, phaseRowCount));
else setSelectedAgentIndex(s => clampSelected(s + 1, visibleAgents.length));
},
killFocused: () => {
if (focused) svc.kill(focused.runId);
},
resumeFocused: () => {
if (!focused) return;
const canUseTool = context.canUseTool;
if (!canUseTool) {
onDone('resume 需要 canUseTool 上下文,请在主会话中用 /<name> resume 重试。');
return;
}
void svc
.launch({ resumeFromRunId: focused.runId, name: focused.workflowName }, context, canUseTool)
.catch(e => onDone(`resume 失败:${(e as Error).message}`));
},
newRun: () => onDone('Tip: 用 /<name> 启动命名 workflow或通过 Workflow 工具带 name 参数。'),
quit: () => onDone(),
};
useWorkflowKeyboard(handlers);
const running = runs.filter(r => r.status === 'running').length;
const done = runs.length - running;
const phaseHeader = selectedPhaseTitle ?? ALL_PHASE;
return (
<Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
<Box justifyContent="space-between">
<Text bold>Workflows</Text>
<Text color="subtle">
{running} running · {done} done
</Text>
</Box>
<Box marginTop={1}>
<TabsBar runs={runs} activeRunId={activeRunId} />
</Box>
<Box flexDirection="row" marginTop={1}>
<Box width="25%" flexDirection="column">
<Text color={focusColumn === 'phases' ? 'claude' : 'subtle'} bold>
PHASES
</Text>
<PhaseSidebar phases={phases} agents={focused?.agents ?? []} selectedIndex={clampedPhase} />
</Box>
<Text color="subtle"></Text>
<Box flexGrow={1} flexDirection="column">
<Text color={focusColumn === 'agents' ? 'claude' : 'subtle'} bold>
AGENTS · {phaseHeader}
</Text>
<AgentList agents={visibleAgents} selectedIndex={clampedAgent} />
</Box>
</Box>
<Box marginTop={1}>
<Text color="subtle">Tab run · / · / · x kill · r resume · q quit</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,16 @@
import type { LocalJSXCommandCall } from '../../types/command.js';
import { SentryErrorBoundary } from '../../components/SentryErrorBoundary.js';
import { WorkflowsPanel } from './WorkflowsPanel.js';
/**
* /workflows 的 local-jsx call构造面板元素返回给 Ink 渲染。
*
* 用 SentryErrorBoundary 包裹useSyncExternalStore / listNamed / 子组件
* 抛错时不让异常击穿到 REPL 顶层导致整个会话崩溃boundary 落到本地错误卡片。
* onDone/context 由命令运行时注入args 未使用(面板无参数化行为)。
*/
export const call: LocalJSXCommandCall = async (onDone, context, _args) => (
<SentryErrorBoundary name="WorkflowsPanel">
<WorkflowsPanel onDone={onDone} context={context} />
</SentryErrorBoundary>
);

View File

@@ -0,0 +1,60 @@
import type { AgentProgress, RunProgress } from '../progress/store.js'
import type { PhaseStatus } from './status.js'
/** 「不筛选」固定项的 title侧栏第一行。 */
export const ALL_PHASE = 'All'
/** 合并后的 phase含 pending带该 phase 下 agent 的 done/total 计数。 */
export type MergedPhase = {
title: string
status: PhaseStatus
done: number
total: number
}
/**
* 合并 declaredPhasesmeta 声明)与 run.phases实际 running/done
* - 声明顺序优先;未在 declared 但实际出现的 phase 追加末尾。
* - 实际无记录 → pending否则取实际 status。
* - done/total = 该 phase 下 done / 全部 agent 数。
*/
export function mergePhases(
run: Pick<RunProgress, 'declaredPhases' | 'phases' | 'agents'>,
): MergedPhase[] {
const actualByTitle = new Map(run.phases.map(p => [p.title, p]))
const seen = new Set<string>()
const out: MergedPhase[] = []
const push = (title: string): void => {
if (seen.has(title)) return
seen.add(title)
const actual = actualByTitle.get(title)
const status: PhaseStatus = !actual ? 'pending' : actual.status
const inPhase = run.agents.filter(a => a.phase === title)
out.push({
title,
status,
done: inPhase.filter(a => a.status === 'done').length,
total: inPhase.length,
})
}
for (const t of run.declaredPhases) push(t)
for (const p of run.phases) push(p.title)
return out
}
/**
* 按选中 phase 筛选 agent。
* selectedPhase 为 undefined 或 ALL_PHASE → 全部。
*/
export function filterAgentsByPhase(
agents: AgentProgress[],
selectedPhase: string | undefined,
): AgentProgress[] {
if (selectedPhase === undefined || selectedPhase === ALL_PHASE) return agents
return agents.filter(a => a.phase === selectedPhase)
}
/** tab 标签workflow 名 + `#` + runId 末 4 位(同名 run 消歧)。 */
export function tabLabel(workflowName: string, runId: string): string {
return `${workflowName}#${runId.slice(-4)}`
}

View File

@@ -0,0 +1,53 @@
import type { AgentProgress, RunProgress } from '../progress/store.js'
/** run 状态 → 圆点字符(顶部 tab 用)。 */
export const STATUS_DOT: Record<RunProgress['status'], string> = {
running: '●',
completed: '✓',
failed: '✗',
killed: '■',
}
/** run 状态 → ink theme 颜色 token沿用现有 WorkflowList 配色)。 */
export const RUN_STATUS_COLOR: Record<RunProgress['status'], string> = {
running: 'warning',
completed: 'success',
failed: 'error',
killed: 'subtle',
}
/** phase 在侧栏的合并状态(含 pendingmeta 声明但未启动)。 */
export type PhaseStatus = 'running' | 'done' | 'pending'
export const PHASE_MARK: Record<PhaseStatus, string> = {
running: '●',
done: '✓',
pending: '○',
}
export const PHASE_COLOR: Record<PhaseStatus, string> = {
running: 'warning',
done: 'success',
pending: 'subtle',
}
/** agent 行的视觉三件套:标记字符 + 颜色 + 行尾文字后缀。 */
export type AgentVisual = { mark: string; color: string; suffix: string }
/**
* agent 状态 → 视觉。
* - running → ● warning
* - done·dead → ✗ error
* - done·okoutputShape='object' → object否则 text
*/
export function agentVisual(a: AgentProgress): AgentVisual {
if (a.status === 'running')
return { mark: '●', color: 'warning', suffix: 'running' }
if (a.resultKind === 'dead')
return { mark: '✗', color: 'error', suffix: 'dead' }
return {
mark: '✓',
color: 'success',
suffix: a.outputShape === 'object' ? 'object' : 'text',
}
}

View File

@@ -0,0 +1,105 @@
import { useInput } from '@anthropic/ink'
/** 焦点所在列。 */
export type FocusColumn = 'phases' | 'agents'
/** useInput 的 key 对象子集(仅声明用到的字段,避免耦合 ink Key 类型)。 */
type KeyEvent = {
tab?: boolean
shift?: boolean
escape?: boolean
leftArrow?: boolean
rightArrow?: boolean
upArrow?: boolean
downArrow?: boolean
}
/** 键 → 动作(纯函数,便于单测;无渲染依赖)。 */
export type WorkflowKeyAction =
| 'nextTab'
| 'prevTab'
| 'focusLeft'
| 'focusRight'
| 'moveUp'
| 'moveDown'
| 'kill'
| 'resume'
| 'newRun'
| 'quit'
export function routeWorkflowKey(
input: string,
key: KeyEvent,
): WorkflowKeyAction | null {
// @anthropic/ink 的 key.tab 对 Tab 键置 true个别环境回落到 '\t'
if (key.tab || input === '\t') return key.shift ? 'prevTab' : 'nextTab'
if (key.escape || input === 'q') return 'quit'
if (input === 'x') return 'kill'
if (input === 'r') return 'resume'
if (input === 'n') return 'newRun'
if (key.leftArrow) return 'focusLeft'
if (key.rightArrow) return 'focusRight'
if (key.upArrow) return 'moveUp'
if (key.downArrow) return 'moveDown'
return null
}
/** 焦点模型回调WorkflowsPanel 注入)。 */
export type WorkflowKeyboardHandlers = {
nextTab: () => void
prevTab: () => void
focusLeft: () => void
focusRight: () => void
moveUp: () => void
moveDown: () => void
killFocused: () => void
resumeFocused: () => void
newRun: () => void
quit: () => void
}
/**
* /workflows 面板键位(焦点轮转模型):
* - Tab / Shift+Tab切顶部 run tab
* - ← / →phases ↔ agents 焦点切换
* - ↑ / ↓:当前焦点列内移动
* - x kill · r resume · n new · q / Esc quit
*/
export function useWorkflowKeyboard(h: WorkflowKeyboardHandlers): void {
useInput((input, key) => {
const action = routeWorkflowKey(input, key as KeyEvent)
if (action === null) return
switch (action) {
case 'nextTab':
h.nextTab()
break
case 'prevTab':
h.prevTab()
break
case 'focusLeft':
h.focusLeft()
break
case 'focusRight':
h.focusRight()
break
case 'moveUp':
h.moveUp()
break
case 'moveDown':
h.moveDown()
break
case 'kill':
h.killFocused()
break
case 'resume':
h.resumeFocused()
break
case 'newRun':
h.newRun()
break
case 'quit':
h.quit()
break
}
})
}

165
src/workflow/ports.ts Normal file
View File

@@ -0,0 +1,165 @@
import {
createFileJournalStore,
type ProgressEvent,
type WorkflowPorts,
} from '@claude-code-best/workflow-engine'
import { logForDebugging } from '../utils/debug.js'
import { getProjectRoot } from '../bootstrap/state.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../services/analytics/index.js'
import {
completeWorkflowTask,
failWorkflowTask,
killWorkflowTask,
registerLocalWorkflowTask,
} from '../tasks/LocalWorkflowTask/LocalWorkflowTask.js'
import {
buildHostBundle,
makeHostHandle,
readHostBundle,
type WorkflowHostBundle,
} from './hostHandle.js'
import { buildRegistry } from './registry.js'
import type { ProgressBus } from './progress/bus.js'
import type { ProgressStore } from './progress/store.js'
import type { SetAppState } from '../Task.js'
import type { AssistantMessage } from '../types/message.js'
type RunBinding = {
runId: string
taskId: string
setAppState: SetAppState
abortController: AbortController
workflowName: string
}
/** 每次工具调用从 toolUseContext 构造 WorkflowHostContext。 */
function makeHostFactory(): WorkflowPorts['hostFactory'] {
return ({ context, canUseTool, parentMessage }) => {
const ctx = context as WorkflowHostBundle['toolUseContext'] & {
agentId?: string
}
return {
handle: makeHostHandle(
buildHostBundle(
ctx,
canUseTool as WorkflowHostBundle['canUseTool'],
parentMessage as AssistantMessage | undefined,
),
),
// 用 projectRoot 而非 getCwd():与 journalStore 的 runsDir 同根,
// 否则用户进入 worktree/子目录时命名 workflow 解析与 journal 落盘不同步。
// 引擎内部 ctx.cwd 仅用于解析scriptPath/name不影响 agent 执行 cwd
// agent 通过 host bundle 内的 toolUseContext 拿到自己的 cwd
cwd: getProjectRoot(),
budgetTotal: null, // turn 级预算注入点(未来从 settings 读)
...(ctx.toolUseId ? { toolUseId: ctx.toolUseId } : {}),
}
}
}
/**
* 组装完整 WorkflowPorts。bus/store 由调用方传入service 单例共享)。
* taskRegistrar 维护 runId → RunBinding 供 kill 路由。
*/
export function createWorkflowPorts(opts: {
bus: ProgressBus
store: ProgressStore
}): WorkflowPorts {
const bindings = new Map<string, RunBinding>()
const runsDir = `${getProjectRoot()}/.claude/workflow-runs`
const registry = buildRegistry()
// 遥测订阅(独立于 store。LogEventMetadata 只接受 boolean/number/undefined
// runId 为字符串——用 analytics 模块自带的 brand cast已验证非代码/路径)放行。
opts.bus.subscribe((e: ProgressEvent) => {
if (e.type === 'run_done') {
logEvent('tengu_workflow_done', {
status: e.status === 'completed' ? 0 : e.status === 'failed' ? 1 : 2,
runId:
e.runId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
}
})
const taskRegistrar: WorkflowPorts['taskRegistrar'] = {
register(regOpts, host) {
const bundle = readHostBundle(host)
const setAppState =
bundle.toolUseContext.setAppStateForTasks ??
bundle.toolUseContext.setAppState
const abortController = new AbortController()
const taskId = registerLocalWorkflowTask(setAppState, {
description: regOpts.summary ?? regOpts.workflowName,
workflowName: regOpts.workflowName,
workflowFile: regOpts.workflowFile ?? '',
summary: regOpts.summary,
...(regOpts.toolUseId ? { toolUseId: regOpts.toolUseId } : {}),
abortController,
})
const runId = regOpts.runId ?? taskId
bindings.set(runId, {
runId,
taskId,
setAppState,
abortController,
workflowName: regOpts.workflowName,
})
logForDebugging(
`workflow task registered: ${runId} (${regOpts.workflowName})`,
)
return { runId, signal: abortController.signal }
},
complete(runId, summary) {
const b = bindings.get(runId)
if (!b) return
completeWorkflowTask(b.taskId, b.setAppState)
logForDebugging(`workflow ${runId} completed: ${summary ?? ''}`)
bindings.delete(runId)
},
fail(runId, error) {
const b = bindings.get(runId)
if (!b) return
failWorkflowTask(b.taskId, b.setAppState, error)
logForDebugging(`workflow ${runId} failed: ${error}`)
bindings.delete(runId)
},
kill(runId) {
const b = bindings.get(runId)
if (!b) return
killWorkflowTask(b.taskId, b.setAppState) // 内部 abort controller
bindings.delete(runId)
},
pendingAction() {
return null // v1skip/retry 不接线seam 保留)
},
}
return {
hostFactory: makeHostFactory(),
agentAdapterRegistry: registry,
agentRunner: {
// 死代码兜底hooks 始终走 agentAdapterRegistryports 必设)。若到此说明 registry 未注册——fail-fast。
async runAgentToResult() {
throw new Error(
'workflow agentRunner fallback reached — agentAdapterRegistry must be set on ports',
)
},
},
progressEmitter: {
emit(event) {
opts.bus.emit(event) // → store reducer + 遥测
},
},
taskRegistrar,
journalStore: createFileJournalStore(runsDir),
permissionGate: { isAborted: () => false }, // 引擎用 ctx.signal 判 abort
logger: {
debug: msg => logForDebugging(msg),
warn: msg => logForDebugging(`[workflow warn] ${msg}`),
event: name => logForDebugging(`workflow event: ${name}`),
},
}
}

View File

@@ -0,0 +1,20 @@
import type { ProgressEvent } from '@claude-code-best/workflow-engine'
/** 类型化进度事件总线。引擎 progressEmitter.emit → 广播给所有订阅者store / 遥测)。 */
export type ProgressBus = {
emit(event: ProgressEvent): void
subscribe(listener: (event: ProgressEvent) => void): () => void
}
export function createProgressBus(): ProgressBus {
const listeners = new Set<(event: ProgressEvent) => void>()
return {
emit(event) {
for (const fn of listeners) fn(event)
},
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}

View File

@@ -0,0 +1,166 @@
import type { ProgressEvent } from '@claude-code-best/workflow-engine'
import type { ProgressBus } from './bus.js'
export type AgentProgress = {
/** 引擎盖戳的唯一 id精确关联 started/done修旧 LIFO 竞态)。 */
id: number
label?: string
phase?: string
status: 'running' | 'done'
resultKind?: string
/** 仅 done·ok 时有意义output 是对象→'object',否则→'text'。dead/skipped 无。 */
outputShape?: 'text' | 'object'
}
export type RunProgress = {
runId: string
workflowName: string
status: 'running' | 'completed' | 'failed' | 'killed'
phases: Array<{ title: string; status: 'running' | 'done' }>
/** 来自 run_started.meta.phases[].title面板据此显示 pending(○) phase。无 meta → []。 */
declaredPhases: string[]
currentPhase: string | null
agents: AgentProgress[]
agentCount: number
returnValue?: unknown
error?: string
updatedAt: number
}
export type ProgressStore = {
apply(event: ProgressEvent): void
list(): RunProgress[]
get(runId: string): RunProgress | undefined
/** 供 useSyncExternalStore返回稳定引用无变更时同一数组。 */
subscribe(listener: () => void): () => void
getSnapshot(): RunProgress[]
}
/** 从 bus 构造 reactive store订阅 bus归约事件通知 React 订阅者。 */
export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore {
const byId = new Map<string, RunProgress>()
let snapshot: RunProgress[] = []
const listeners = new Set<() => void>()
const notify = (): void => {
snapshot = [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt)
for (const fn of listeners) fn()
}
const ensure = (runId: string, workflowName: string): RunProgress => {
let p = byId.get(runId)
if (!p) {
p = {
runId,
workflowName,
status: 'running',
phases: [],
declaredPhases: [],
currentPhase: null,
agents: [],
agentCount: 0,
updatedAt: Date.now(),
}
byId.set(runId, p)
}
return p
}
const apply = (event: ProgressEvent): void => {
// log 不产生可见状态变更(面板无日志视图):早退,避免无谓的快照重建与 React 重渲染
if (event.type === 'log') return
const runId = event.runId
const p = ensure(
runId,
'workflowName' in event ? event.workflowName : 'workflow',
)
p.updatedAt = Date.now()
switch (event.type) {
case 'run_started':
p.workflowName = event.workflowName
p.status = 'running'
p.declaredPhases = event.meta?.phases?.map(ph => ph.title) ?? []
break
case 'phase_started':
if (!p.phases.some(ph => ph.title === event.phase)) {
p.phases.push({ title: event.phase, status: 'running' })
}
p.currentPhase = event.phase
break
case 'phase_done':
for (const ph of p.phases)
if (ph.title === event.phase) ph.status = 'done'
if (p.currentPhase === event.phase) p.currentPhase = null
break
case 'agent_started': {
let a = p.agents.find(x => x.id === event.agentId)
if (!a) {
a = {
id: event.agentId,
label: event.label,
phase: event.phase,
status: 'running',
}
p.agents.push(a)
p.agentCount = p.agents.length
} else {
a.status = 'running'
a.label = event.label
a.phase = event.phase
}
break
}
case 'agent_done': {
let a = p.agents.find(x => x.id === event.agentId)
if (!a) {
a = {
id: event.agentId,
label: event.label,
phase: event.phase,
status: 'done',
...(event.result.kind === 'ok'
? {
outputShape:
typeof event.result.output === 'object' &&
event.result.output !== null
? ('object' as const)
: ('text' as const),
}
: {}),
}
p.agents.push(a)
p.agentCount = p.agents.length
} else {
a.status = 'done'
a.resultKind = event.result.kind
if (event.result.kind === 'ok') {
a.outputShape =
typeof event.result.output === 'object' &&
event.result.output !== null
? 'object'
: 'text'
}
}
break
}
case 'run_done':
p.status = event.status
if (event.returnValue !== undefined) p.returnValue = event.returnValue
if (event.error !== undefined) p.error = event.error
break
}
notify()
}
bus.subscribe(apply)
return {
apply,
list: () => snapshot,
get: id => byId.get(id),
subscribe: fn => {
listeners.add(fn)
return () => listeners.delete(fn)
},
getSnapshot: () => snapshot,
}
}

12
src/workflow/registry.ts Normal file
View File

@@ -0,0 +1,12 @@
import { AgentAdapterRegistry } from '@claude-code-best/workflow-engine'
import { claudeCodeBackend } from './backends/claudeCodeBackend.js'
/**
* 构建多后端 registry。v1depth B只注册单一 claude-code adapter 为默认,
* 不预填路由规则——扩第二个 provider adapter 时再补 .route(...)。
*/
export function buildRegistry(): AgentAdapterRegistry {
const reg = new AgentAdapterRegistry()
reg.register(claudeCodeBackend).default('claude-code')
return reg
}

224
src/workflow/service.ts Normal file
View File

@@ -0,0 +1,224 @@
import {
listNamedWorkflows,
parseScript,
resolveNamedWorkflow,
runWorkflow,
WORKFLOW_DIR_NAME,
type WorkflowHostContext,
type WorkflowInput,
type WorkflowPorts,
} from '@claude-code-best/workflow-engine'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getProjectRoot } from '../bootstrap/state.js'
import { logForDebugging } from '../utils/debug.js'
import { buildHostBundle, makeHostHandle } from './hostHandle.js'
import { installWorkflowNotifications } from './notifications.js'
import { createProgressBus } from './progress/bus.js'
import {
createProgressStoreFromBus,
type ProgressStore,
type RunProgress,
} from './progress/store.js'
import { createWorkflowPorts } from './ports.js'
import type { CanUseToolFn } from '../hooks/useCanUseTool.js'
import type { ToolUseContext } from '../Tool.js'
/**
* WorkflowService工具U7与面板U9共享的唯一入口。
*
* - `ports`:共享的 WorkflowPorts工具描述符透传给引擎。
* - `launch`:解析脚本 → parseScript 快速校验 → taskRegistrar.register拿 runId+signal
* → detached runWorkflow → 结束后 complete/fail/kill。
* - `kill/listRuns/getRun/subscribe/listNamed`:面板与工具的辅助查询。
*/
export type WorkflowService = {
/** 共享端口(工具描述符用)。 */
ports: WorkflowPorts
/** 面板/工具启动 workflow解析脚本 → register → detached runWorkflow。 */
launch(
input: Pick<
WorkflowInput,
| 'script'
| 'name'
| 'scriptPath'
| 'args'
| 'description'
| 'resumeFromRunId'
| 'title'
>,
toolUseContext: ToolUseContext,
canUseTool: CanUseToolFn,
): Promise<{ runId: string }>
kill(runId: string): void
/**
* 进程退出 / 配置卸载时清理:杀掉所有 running run避免孤儿 task。
* 已完成/失败的 run 不受影响。幂等——多次调用安全。
*/
shutdown(): void
listRuns(): RunProgress[]
getRun(runId: string): RunProgress | undefined
subscribe(listener: () => void): () => void
listNamed(workflowDir?: string): Promise<string[]>
}
let cached: WorkflowService | null = null
/** 进程单例。工具与面板共享同一 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)
// 安装状态变更通知桥接commit 0768d4dc 承诺但旧实现落空的"完成时自动通知"
installWorkflowNotifications(service)
cached = service
return cached
}
/**
* 构造 service注入 ports + store
*
* 生产路径用 {@link getWorkflowService};测试用本函数直接注入 fake ports
* 避免触碰真实的 getProjectRoot/getCwd/analytics 等模块级副作用。
*/
export function makeService(
ports: WorkflowPorts,
store: ProgressStore,
): WorkflowService {
const buildHost = (
toolUseContext: ToolUseContext,
canUseTool: CanUseToolFn,
): WorkflowHostContext => ({
handle: makeHostHandle(buildHostBundle(toolUseContext, canUseTool)),
// 用 projectRoot 与 ports.ts hostFactory / journalStore 保持同根;
// 进入 worktree/子目录时不会让命名 workflow 解析与 journal 落盘不同步。
cwd: getProjectRoot(),
budgetTotal: null, // turn 级预算注入点(未来从 settings 读)
toolUseId: toolUseContext.toolUseId,
})
async function resolveSource(input: {
script?: string
name?: string
scriptPath?: string
}): Promise<{
script: string
workflowFile?: string
workflowName: string
}> {
if (input.script) {
return { script: input.script, workflowName: 'workflow' }
}
if (input.scriptPath) {
return {
script: await readFile(input.scriptPath, 'utf-8'),
workflowFile: input.scriptPath,
workflowName: 'workflow',
}
}
if (input.name) {
const dir = join(getProjectRoot(), WORKFLOW_DIR_NAME)
const found = await resolveNamedWorkflow(dir, input.name)
if (!found) {
throw new Error(
`命名 workflow "${input.name}" 未找到(查找 ${WORKFLOW_DIR_NAME}/`,
)
}
return {
script: found.content,
workflowFile: found.path,
workflowName: input.name,
}
}
throw new Error('必须提供 script、name 或 scriptPath 之一')
}
return {
ports,
async launch(input, toolUseContext, canUseTool) {
const { script, workflowFile, workflowName } = await resolveSource(input)
try {
parseScript(script)
} catch (e) {
throw new Error(`脚本校验失败:${(e as Error).message}`)
}
const host = buildHost(toolUseContext, canUseTool)
const { runId, signal } = ports.taskRegistrar.register(
{
workflowName,
...(workflowFile ? { workflowFile } : {}),
...(input.description ? { summary: input.description } : {}),
...(host.toolUseId ? { toolUseId: host.toolUseId } : {}),
...(input.resumeFromRunId ? { runId: input.resumeFromRunId } : {}),
},
host.handle,
)
// detached不 await让调用方立即拿到 runId结束路由到 registrar。
void runWorkflow({
script,
...(input.args !== undefined ? { args: input.args } : {}),
runId,
workflowName,
ports,
host: host.handle,
signal,
cwd: host.cwd,
budgetTotal: host.budgetTotal,
...(input.resumeFromRunId ? { resume: true } : {}),
})
.then(result => {
if (result.status === 'completed') {
ports.taskRegistrar.complete(runId)
} else if (result.status === 'failed') {
ports.taskRegistrar.fail(runId, result.error ?? 'failed')
} else {
ports.taskRegistrar.kill(runId)
}
})
.catch(e => ports.taskRegistrar.fail(runId, (e as Error).message))
logForDebugging(`workflow launched: ${runId} (${workflowName})`)
return { runId }
},
kill(runId) {
ports.taskRegistrar.kill(runId)
},
shutdown() {
// 仅杀 running已完成/失败的 run taskRegistrar 已回收 bindingkill 是 no-op。
// taskRegistrar.kill 对未知 runId 安全 no-op因此幂等——多次 shutdown 不重复抛错。
for (const run of store.list()) {
if (run.status === 'running') ports.taskRegistrar.kill(run.runId)
}
},
listRuns: () => store.list(),
getRun: id => store.get(id),
subscribe: fn => store.subscribe(fn),
async listNamed(workflowDir) {
return listNamedWorkflows(
workflowDir ?? join(getProjectRoot(), WORKFLOW_DIR_NAME),
)
},
}
}
/** 测试用:重置单例(避免跨用例污染)。 */
export function __resetWorkflowServiceForTests(): void {
cached = null
}
/**
* 返回已实例化的 service不创建。进程退出 / 配置卸载时用本函数 peek
* 没用过 workflow 则 cached 仍为 null——避免在 exit hook 里副作用地创建 bus/ports。
*/
export function peekWorkflowService(): WorkflowService | null {
return cached
}

50
src/workflow/wiring.ts Normal file
View File

@@ -0,0 +1,50 @@
import {
createWorkflowTool,
type WorkflowToolDescriptor,
} from '@claude-code-best/workflow-engine'
import { buildTool, type Tool } from '../Tool.js'
import { getWorkflowService } from './service.js'
/**
* 把引擎自包含描述符适配为 buildTool 兼容的 Tool。
* 描述符统一走 service 单例(共享 ports/registry/store
*/
function buildWorkflowTool(): Tool {
const { ports } = getWorkflowService()
const descriptor: WorkflowToolDescriptor = createWorkflowTool(ports)
return buildTool({
name: descriptor.name,
maxResultSizeChars: 50_000,
inputSchema: descriptor.inputSchema,
isEnabled: () => descriptor.isEnabled(),
isReadOnly: input => descriptor.isReadOnly(input),
isConcurrencySafe: () => true,
async description() {
return descriptor.description()
},
async prompt() {
return descriptor.prompt()
},
async call(input, context, canUseTool, parentMessage, onProgress) {
const result = await descriptor.call(
input,
context,
canUseTool,
parentMessage,
onProgress,
)
return { data: result.data }
},
renderToolUseMessage: input => descriptor.renderToolUseMessage(input),
mapToolResultToToolResultBlockParam: (data, toolUseId) =>
descriptor.mapToolResultToToolResultBlockParam(data, toolUseId),
})
}
// 单例tools.ts 注册与 PermissionRequest 引用需为同一实例switch 按引用匹配)。
let cached: Tool | null = null
export function createWorkflowToolCore(): Tool {
if (!cached) cached = buildWorkflowTool()
return cached
}