mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
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:
145
src/workflow/WorkflowPermissionRequest.tsx
Normal file
145
src/workflow/WorkflowPermissionRequest.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/workflow/__tests__/WorkflowsPanel.test.tsx
Normal file
100
src/workflow/__tests__/WorkflowsPanel.test.tsx
Normal 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('✗');
|
||||
});
|
||||
|
||||
// 修复 M:useSyncExternalStore / 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');
|
||||
});
|
||||
142
src/workflow/__tests__/claudeCodeBackend.test.ts
Normal file
142
src/workflow/__tests__/claudeCodeBackend.test.ts
Normal 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()
|
||||
})
|
||||
175
src/workflow/__tests__/notifications.test.ts
Normal file
175
src/workflow/__tests__/notifications.test.ts
Normal 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),
|
||||
)
|
||||
|
||||
// 第一次 emit:listener 记录初始 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)
|
||||
})
|
||||
})
|
||||
109
src/workflow/__tests__/ports.test.ts
Normal file
109
src/workflow/__tests__/ports.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
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。
|
||||
|
||||
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_started,store 能看到
|
||||
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 对象承载 tasks,registerTask 走真实代码路径。
|
||||
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 回归', () => {
|
||||
// 历史 bug:hostFactory.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())
|
||||
})
|
||||
23
src/workflow/__tests__/progressBus.test.ts
Normal file
23
src/workflow/__tests__/progressBus.test.ts
Normal 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()
|
||||
})
|
||||
175
src/workflow/__tests__/progressStore.test.ts
Normal file
175
src/workflow/__tests__/progressStore.test.ts
Normal 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 落地 outputShape(ok·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()
|
||||
})
|
||||
81
src/workflow/__tests__/selectors.test.ts
Normal file
81
src/workflow/__tests__/selectors.test.ts
Normal 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('filterAgentsByPhase:All / 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('tabLabel:workflow 名 + runId 后 4 位短码', () => {
|
||||
expect(tabLabel('review-changes', 'wf_abc123def')).toBe('review-changes#3def')
|
||||
})
|
||||
335
src/workflow/__tests__/service.test.ts
Normal file
335
src/workflow/__tests__/service.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
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。
|
||||
|
||||
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 ports:registry.run 返回固定 AgentRunResult,taskRegistrar 带 binding,
|
||||
// journalStore 内存空实现。progressEmitter.emit → bus.emit(store 已在构造时订阅 bus)。
|
||||
// 注意:runWorkflow 自身会发 run_started/run_done;taskRegistrar 只管 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 → completed;store 出现该 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)
|
||||
// 触发 ScriptError:meta 字面量缺 description(validateMeta 要求 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 status,error 透传;
|
||||
// 或透传到 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)
|
||||
})
|
||||
|
||||
// ---- 修复 N:shutdown 清理 ----
|
||||
|
||||
test('shutdown 杀掉所有 running run(taskRegistrar.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()
|
||||
})
|
||||
75
src/workflow/__tests__/status.test.ts
Normal file
75
src/workflow/__tests__/status.test.ts
Normal 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('agentVisual:running → ● warning running', () => {
|
||||
const a: AgentProgress = { id: 1, status: 'running' }
|
||||
expect(agentVisual(a)).toEqual({
|
||||
mark: '●',
|
||||
color: 'warning',
|
||||
suffix: 'running',
|
||||
})
|
||||
})
|
||||
|
||||
test('agentVisual:done·object → ✓ success object', () => {
|
||||
const a: AgentProgress = {
|
||||
id: 1,
|
||||
status: 'done',
|
||||
resultKind: 'ok',
|
||||
outputShape: 'object',
|
||||
}
|
||||
expect(agentVisual(a)).toEqual({
|
||||
mark: '✓',
|
||||
color: 'success',
|
||||
suffix: 'object',
|
||||
})
|
||||
})
|
||||
|
||||
test('agentVisual:done·text → ✓ success text', () => {
|
||||
const a: AgentProgress = {
|
||||
id: 1,
|
||||
status: 'done',
|
||||
resultKind: 'ok',
|
||||
outputShape: 'text',
|
||||
}
|
||||
expect(agentVisual(a)).toEqual({
|
||||
mark: '✓',
|
||||
color: 'success',
|
||||
suffix: 'text',
|
||||
})
|
||||
})
|
||||
|
||||
test('agentVisual:dead → ✗ error dead', () => {
|
||||
const a: AgentProgress = { id: 1, status: 'done', resultKind: 'dead' }
|
||||
expect(agentVisual(a)).toEqual({ mark: '✗', color: 'error', suffix: 'dead' })
|
||||
})
|
||||
30
src/workflow/__tests__/useWorkflowKeyboard.test.ts
Normal file
30
src/workflow/__tests__/useWorkflowKeyboard.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { routeWorkflowKey } from '../panel/useWorkflowKeyboard.js'
|
||||
|
||||
test('Tab → nextTab;Shift+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 → kill;r → resume;n → 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()
|
||||
})
|
||||
158
src/workflow/backends/claudeCodeBackend.ts
Normal file
158
src/workflow/backends/claudeCodeBackend.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
// 深度集成后端:从活会话解析 agent/model/tools,委托核心 runAgent。
|
||||
// 实现 AgentAdapter 接口,由 registry(U5)注册并路由。
|
||||
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 是顶层 ModelAlias;workflow 的 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 } }
|
||||
},
|
||||
}
|
||||
42
src/workflow/hostHandle.ts
Normal file
42
src/workflow/hostHandle.ts
Normal 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
|
||||
}
|
||||
34
src/workflow/namedWorkflowCommands.ts
Normal file
34
src/workflow/namedWorkflowCommands.ts
Normal 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}`,
|
||||
},
|
||||
]
|
||||
},
|
||||
}))
|
||||
}
|
||||
87
src/workflow/notifications.ts
Normal file
87
src/workflow/notifications.ts
Normal 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}>`
|
||||
}
|
||||
39
src/workflow/panel/AgentList.tsx
Normal file
39
src/workflow/panel/AgentList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/workflow/panel/PhaseSidebar.tsx
Normal file
49
src/workflow/panel/PhaseSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/workflow/panel/TabsBar.tsx
Normal file
37
src/workflow/panel/TabsBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
src/workflow/panel/WorkflowsPanel.tsx
Normal file
162
src/workflow/panel/WorkflowsPanel.tsx
Normal 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 订阅 WorkflowService(store 返回稳定快照,无变更不重渲染)。
|
||||
* - 焦点状态: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 title(0 = 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>
|
||||
);
|
||||
}
|
||||
16
src/workflow/panel/panelCall.tsx
Normal file
16
src/workflow/panel/panelCall.tsx
Normal 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>
|
||||
);
|
||||
60
src/workflow/panel/selectors.ts
Normal file
60
src/workflow/panel/selectors.ts
Normal 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 declaredPhases(meta 声明)与 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)}`
|
||||
}
|
||||
53
src/workflow/panel/status.ts
Normal file
53
src/workflow/panel/status.ts
Normal 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 在侧栏的合并状态(含 pending:meta 声明但未启动)。 */
|
||||
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·ok:outputShape='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',
|
||||
}
|
||||
}
|
||||
105
src/workflow/panel/useWorkflowKeyboard.ts
Normal file
105
src/workflow/panel/useWorkflowKeyboard.ts
Normal 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
165
src/workflow/ports.ts
Normal 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 // v1:skip/retry 不接线(seam 保留)
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
hostFactory: makeHostFactory(),
|
||||
agentAdapterRegistry: registry,
|
||||
agentRunner: {
|
||||
// 死代码兜底:hooks 始终走 agentAdapterRegistry(ports 必设)。若到此说明 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}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
20
src/workflow/progress/bus.ts
Normal file
20
src/workflow/progress/bus.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
166
src/workflow/progress/store.ts
Normal file
166
src/workflow/progress/store.ts
Normal 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
12
src/workflow/registry.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { AgentAdapterRegistry } from '@claude-code-best/workflow-engine'
|
||||
import { claudeCodeBackend } from './backends/claudeCodeBackend.js'
|
||||
|
||||
/**
|
||||
* 构建多后端 registry。v1(depth 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
224
src/workflow/service.ts
Normal 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 已回收 binding,kill 是 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
50
src/workflow/wiring.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user