mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
feat(workflow): 复刻 ultracode 手册并修复 worktree/inline/opt-in 三处缺口
围绕 ultracode skill 审查 agent 系统一致性后:
- ultracode.ts: 用系统提示版完整 Workflow 编排手册替换中文精简版
- HIGH#1 isolation:'worktree': claudeCodeBackend.run() 用 createAgentWorktree +
runWithCwdOverride 包裹 runAgent + finally 清理实现真正的 cwd 隔离;slug 用
sha256(runId:agentId) 派生以匹配 cleanupStaleAgentWorktrees 清理正则
(修 runId 为 w+base36 非 UUID 导致的泄漏盲区);worktree.ts 注释同步修正
- HIGH#2 inline 持久化: 新增 persistInlineScript,WorkflowTool + service 两条
inline 路径对称持久化到 .claude/workflow-runs/<runId>/script.js,返回可复用
scriptPath(闭环 inline→编辑→scriptPath 重提迭代循环)
- HIGH#3 opt-in 分工: ultracode/WorkflowTool/effort 注明 session reminder 由
harness 注入,repo 内无 ultracode 信号,保持 feature('WORKFLOW_SCRIPTS') +
isEnabled 两层 gate,不自造注入
- 测试: 新增 persistInline.test.ts;扩展 claudeCodeBackend(isolation 4 用例)/
WorkflowTool(inline)/service(scriptPath)/ultracode(harness)
含配套 workflow engine/panel 完善与 run-state-persistence design doc。
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -34,9 +34,11 @@ test('RunProgress 字段契约:面板读取的 key 均存在', () => {
|
||||
workflowName: 'review',
|
||||
status: 'running',
|
||||
phases: [{ title: 'Find', status: 'done' }],
|
||||
declaredPhases: ['Find', 'Review'],
|
||||
currentPhase: 'Review',
|
||||
agents: [{ id: 1, label: 'review:api', phase: 'Review', status: 'running' }],
|
||||
agentCount: 1,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
// 面板 WorkflowList/Detail 读取的路径
|
||||
@@ -56,10 +58,12 @@ test('RunProgress 完成/失败形态:returnValue/error 可选', () => {
|
||||
workflowName: 'w',
|
||||
status: 'completed',
|
||||
phases: [],
|
||||
declaredPhases: [],
|
||||
currentPhase: null,
|
||||
agents: [],
|
||||
agentCount: 0,
|
||||
returnValue: 'ok',
|
||||
startedAt: 2,
|
||||
updatedAt: 2,
|
||||
};
|
||||
const failed: RunProgress = {
|
||||
@@ -67,10 +71,12 @@ test('RunProgress 完成/失败形态:returnValue/error 可选', () => {
|
||||
workflowName: 'w',
|
||||
status: 'failed',
|
||||
phases: [],
|
||||
declaredPhases: [],
|
||||
currentPhase: null,
|
||||
agents: [],
|
||||
agentCount: 0,
|
||||
error: 'boom',
|
||||
startedAt: 3,
|
||||
updatedAt: 3,
|
||||
};
|
||||
expect(completed.returnValue).toBe('ok');
|
||||
|
||||
@@ -21,6 +21,7 @@ mock.module(
|
||||
content: [{ type: 'text', text: 'agent-text' }],
|
||||
usage: { output_tokens: 42 },
|
||||
totalTokens: 42,
|
||||
totalToolUseCount: 3,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@@ -42,6 +43,39 @@ mock.module('src/utils/uuid.js', () => ({ createAgentId: () => 'agent-1' }))
|
||||
mock.module('src/services/analytics/index.js', () => ({ logEvent: () => {} }))
|
||||
mock.module('src/utils/debug.js', () => ({ logForDebugging: () => {} }))
|
||||
|
||||
// isolation:'worktree' 测试用:mock worktree 三件套(避免真跑 git worktree add)。
|
||||
// 注意 mock.module 是 process-global;worktreeState 在工厂外定义供测试重置。
|
||||
// 不 mock cwd.js:runWithCwdOverride 真跑 AsyncLocalStorage 对 mock runAgent 无害,
|
||||
// 且避免污染同进程其他依赖 pwd/getCwd 的测试。
|
||||
const worktreeState = {
|
||||
shouldThrow: false,
|
||||
hasChanges: false,
|
||||
created: [] as string[],
|
||||
removed: [] as string[],
|
||||
changesCalls: 0,
|
||||
}
|
||||
mock.module('src/utils/worktree.js', () => ({
|
||||
createAgentWorktree: async (slug: string) => {
|
||||
if (worktreeState.shouldThrow) throw new Error('wt boom')
|
||||
worktreeState.created.push(slug)
|
||||
return {
|
||||
worktreePath: '/fake/wt',
|
||||
worktreeBranch: 'wt-branch',
|
||||
headCommit: 'abc123',
|
||||
gitRoot: '/fake',
|
||||
hookBased: false,
|
||||
}
|
||||
},
|
||||
hasWorktreeChanges: async () => {
|
||||
worktreeState.changesCalls++
|
||||
return worktreeState.hasChanges
|
||||
},
|
||||
removeAgentWorktree: async (path: string) => {
|
||||
worktreeState.removed.push(path)
|
||||
return true
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
claudeCodeBackend,
|
||||
resolveAgentDefinition,
|
||||
@@ -77,15 +111,68 @@ function ctx() {
|
||||
}
|
||||
}
|
||||
|
||||
test('文本 agent → ok + token 计量', async () => {
|
||||
test('文本 agent → ok + token/tool/model 计量', async () => {
|
||||
const res = await claudeCodeBackend.run({ prompt: 'do it' }, ctx())
|
||||
expect(res.kind).toBe('ok')
|
||||
if (res.kind === 'ok') {
|
||||
expect(res.output).toBe('agent-text')
|
||||
expect(res.usage.outputTokens).toBe(42)
|
||||
// 面板展示字段:tokenCount(=totalTokens) / toolCount / model(fallback mainLoopModel 'm')
|
||||
expect(res.tokenCount).toBe(42)
|
||||
expect(res.toolCount).toBe(3)
|
||||
expect(res.model).toBe('m')
|
||||
}
|
||||
})
|
||||
|
||||
test('isolation:worktree → 创建 worktree + 无变更自动清理;slug 匹配清理正则', async () => {
|
||||
worktreeState.shouldThrow = false
|
||||
worktreeState.hasChanges = false
|
||||
worktreeState.created = []
|
||||
worktreeState.removed = []
|
||||
worktreeState.changesCalls = 0
|
||||
const res = await claudeCodeBackend.run(
|
||||
{ prompt: 'do', isolation: 'worktree' },
|
||||
ctx(),
|
||||
)
|
||||
expect(res.kind).toBe('ok')
|
||||
expect(worktreeState.created).toHaveLength(1)
|
||||
// slug 必须匹配 cleanupStaleAgentWorktrees 的清理正则 ^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$
|
||||
expect(worktreeState.created[0]).toMatch(/^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$/)
|
||||
expect(worktreeState.changesCalls).toBe(1)
|
||||
expect(worktreeState.removed).toHaveLength(1) // 无变更 → auto-remove
|
||||
})
|
||||
|
||||
test('isolation:worktree 有变更 → 保留 worktree(不 remove)', async () => {
|
||||
worktreeState.hasChanges = true
|
||||
worktreeState.created = []
|
||||
worktreeState.removed = []
|
||||
worktreeState.changesCalls = 0
|
||||
const res = await claudeCodeBackend.run(
|
||||
{ prompt: 'do', isolation: 'worktree' },
|
||||
ctx(),
|
||||
)
|
||||
expect(res.kind).toBe('ok')
|
||||
expect(worktreeState.removed).toHaveLength(0) // 有变更 → 保留
|
||||
expect(worktreeState.changesCalls).toBe(1)
|
||||
})
|
||||
|
||||
test('isolation:worktree 创建失败 → fail-closed 返 dead(不静默退化共享 cwd)', async () => {
|
||||
worktreeState.shouldThrow = true
|
||||
const res = await claudeCodeBackend.run(
|
||||
{ prompt: 'do', isolation: 'worktree' },
|
||||
ctx(),
|
||||
)
|
||||
expect(res.kind).toBe('dead')
|
||||
worktreeState.shouldThrow = false
|
||||
})
|
||||
|
||||
test('无 isolation → 不创建 worktree', async () => {
|
||||
worktreeState.created = []
|
||||
const res = await claudeCodeBackend.run({ prompt: 'do' }, ctx())
|
||||
expect(res.kind).toBe('ok')
|
||||
expect(worktreeState.created).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('runAgent 抛错 → dead', async () => {
|
||||
// 覆盖 mock 让 runAgent 抛(last-write-wins)
|
||||
mock.module(
|
||||
|
||||
@@ -47,6 +47,7 @@ function makeRun(
|
||||
currentPhase: null,
|
||||
agents: [],
|
||||
agentCount: 0,
|
||||
startedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
...overrides,
|
||||
}
|
||||
|
||||
@@ -173,3 +173,59 @@ test('agent_done 落地 outputShape(ok·object / ok·text / dead 无)', () =
|
||||
expect(agents.find(a => a.id === 1)?.outputShape).toBe('text')
|
||||
expect(agents.find(a => a.id === 2)?.outputShape).toBeUndefined()
|
||||
})
|
||||
|
||||
test('agent_progress 实时更新 token/tool(按 agentId 关联)', () => {
|
||||
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_progress',
|
||||
runId: 'r1',
|
||||
agentId: 0,
|
||||
tokenCount: 1200,
|
||||
toolCount: 2,
|
||||
})
|
||||
let a = store.get('r1')!.agents.find(x => x.id === 0)!
|
||||
expect(a.tokenCount).toBe(1200)
|
||||
expect(a.toolCount).toBe(2)
|
||||
bus.emit({
|
||||
type: 'agent_progress',
|
||||
runId: 'r1',
|
||||
agentId: 0,
|
||||
tokenCount: 2400,
|
||||
toolCount: 3,
|
||||
})
|
||||
a = store.get('r1')!.agents.find(x => x.id === 0)!
|
||||
expect(a.tokenCount).toBe(2400)
|
||||
expect(a.toolCount).toBe(3)
|
||||
})
|
||||
|
||||
test('agent_done 落地 model/tokenCount/toolCount(ok 变体)', () => {
|
||||
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_done',
|
||||
runId: 'r1',
|
||||
agentId: 0,
|
||||
phase: 'A',
|
||||
result: {
|
||||
kind: 'ok',
|
||||
output: 'x',
|
||||
usage: { outputTokens: 5 },
|
||||
model: 'glm-5.2',
|
||||
tokenCount: 22900,
|
||||
toolCount: 1,
|
||||
},
|
||||
})
|
||||
const a = store.get('r1')!.agents.find(x => x.id === 0)!
|
||||
expect(a.model).toBe('glm-5.2')
|
||||
expect(a.tokenCount).toBe(22900)
|
||||
expect(a.toolCount).toBe(1)
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ function run(partial: Partial<RunProgress>): RunProgress {
|
||||
currentPhase: null,
|
||||
agents: [],
|
||||
agentCount: 0,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
...partial,
|
||||
}
|
||||
|
||||
@@ -145,6 +145,29 @@ test('launch → completed;store 出现该 run', async () => {
|
||||
expect(r!.workflowName).toBe('workflow')
|
||||
})
|
||||
|
||||
test('launch inline script → 返回 scriptPath(持久化到 cwdOverride 目录)', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-svc-'))
|
||||
try {
|
||||
const { ports, store } = fakePorts()
|
||||
const svc = makeService(ports, store, dir)
|
||||
const result = await svc.launch(
|
||||
{ script: `return agent('x')` },
|
||||
stubTUC,
|
||||
stubCanUseTool,
|
||||
)
|
||||
expect(result.scriptPath).toBe(
|
||||
join(dir, '.claude', 'workflow-runs', 'run-1', 'script.js'),
|
||||
)
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
expect(await readFile(result.scriptPath!, 'utf-8')).toBe(
|
||||
`return agent('x')`,
|
||||
)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('kill 走 taskRegistrar.kill', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store, killed } = fakePorts()
|
||||
|
||||
@@ -3,12 +3,15 @@ import type { AgentProgress, RunProgress } from '../progress/store.js'
|
||||
import {
|
||||
STATUS_DOT,
|
||||
RUN_STATUS_COLOR,
|
||||
RUN_STATUS_TEXT,
|
||||
PHASE_MARK,
|
||||
PHASE_COLOR,
|
||||
agentVisual,
|
||||
formatTokenCount,
|
||||
agentMetaText,
|
||||
} from '../panel/status.js'
|
||||
|
||||
test('STATUS_DOT / RUN_STATUS_COLOR 覆盖四种 run 状态且为非空字符', () => {
|
||||
test('STATUS_DOT / RUN_STATUS_COLOR / RUN_STATUS_TEXT 覆盖四种 run 状态', () => {
|
||||
const statuses: RunProgress['status'][] = [
|
||||
'running',
|
||||
'completed',
|
||||
@@ -18,11 +21,14 @@ test('STATUS_DOT / RUN_STATUS_COLOR 覆盖四种 run 状态且为非空字符',
|
||||
for (const s of statuses) {
|
||||
expect(STATUS_DOT[s].length).toBeGreaterThan(0)
|
||||
expect(RUN_STATUS_COLOR[s]).toBeTruthy()
|
||||
expect(RUN_STATUS_TEXT[s].length).toBeGreaterThan(0)
|
||||
}
|
||||
expect(STATUS_DOT.running).toBe('●')
|
||||
expect(STATUS_DOT.completed).toBe('✓')
|
||||
expect(STATUS_DOT.failed).toBe('✗')
|
||||
expect(STATUS_DOT.killed).toBe('■')
|
||||
expect(RUN_STATUS_TEXT.completed).toBe('done')
|
||||
expect(RUN_STATUS_TEXT.running).toBe('running')
|
||||
})
|
||||
|
||||
test('PHASE_MARK / PHASE_COLOR 覆盖 running/done/pending', () => {
|
||||
@@ -32,44 +38,51 @@ test('PHASE_MARK / PHASE_COLOR 覆盖 running/done/pending', () => {
|
||||
expect(PHASE_COLOR.pending).toBe('subtle')
|
||||
})
|
||||
|
||||
test('agentVisual:running → ● warning running', () => {
|
||||
test('agentVisual:running → ● warning', () => {
|
||||
const a: AgentProgress = { id: 1, status: 'running' }
|
||||
expect(agentVisual(a)).toEqual({
|
||||
mark: '●',
|
||||
color: 'warning',
|
||||
suffix: 'running',
|
||||
})
|
||||
expect(agentVisual(a)).toEqual({ mark: '●', color: 'warning' })
|
||||
})
|
||||
|
||||
test('agentVisual:done·object → ✓ success object', () => {
|
||||
test('agentVisual:done·ok → ✓ success(不再带 outputShape 后缀)', () => {
|
||||
const a: AgentProgress = {
|
||||
id: 1,
|
||||
status: 'done',
|
||||
resultKind: 'ok',
|
||||
outputShape: 'object',
|
||||
}
|
||||
expect(agentVisual(a)).toEqual({
|
||||
mark: '✓',
|
||||
color: 'success',
|
||||
suffix: 'object',
|
||||
})
|
||||
expect(agentVisual(a)).toEqual({ mark: '✓', color: 'success' })
|
||||
})
|
||||
|
||||
test('agentVisual:done·text → ✓ success text', () => {
|
||||
test('agentVisual:dead → ✗ error', () => {
|
||||
const a: AgentProgress = { id: 1, status: 'done', resultKind: 'dead' }
|
||||
expect(agentVisual(a)).toEqual({ mark: '✗', color: 'error' })
|
||||
})
|
||||
|
||||
test('formatTokenCount:<1000 原值,≥1000 保留 1 位小数 + k', () => {
|
||||
expect(formatTokenCount(undefined)).toBe('0')
|
||||
expect(formatTokenCount(0)).toBe('0')
|
||||
expect(formatTokenCount(42)).toBe('42')
|
||||
expect(formatTokenCount(1000)).toBe('1.0k')
|
||||
expect(formatTokenCount(22900)).toBe('22.9k')
|
||||
})
|
||||
|
||||
test('agentMetaText:model · Nk tok · N tool', () => {
|
||||
const a: AgentProgress = {
|
||||
id: 1,
|
||||
status: 'done',
|
||||
resultKind: 'ok',
|
||||
outputShape: 'text',
|
||||
model: 'glm-5.2',
|
||||
tokenCount: 22900,
|
||||
toolCount: 1,
|
||||
}
|
||||
expect(agentVisual(a)).toEqual({
|
||||
mark: '✓',
|
||||
color: 'success',
|
||||
suffix: 'text',
|
||||
})
|
||||
expect(agentMetaText(a)).toBe('glm-5.2 · 22.9k tok · 1 tool')
|
||||
})
|
||||
|
||||
test('agentVisual:dead → ✗ error dead', () => {
|
||||
const a: AgentProgress = { id: 1, status: 'done', resultKind: 'dead' }
|
||||
expect(agentVisual(a)).toEqual({ mark: '✗', color: 'error', suffix: 'dead' })
|
||||
test('agentMetaText:无 model 时省略前段', () => {
|
||||
const a: AgentProgress = {
|
||||
id: 1,
|
||||
status: 'running',
|
||||
tokenCount: 500,
|
||||
toolCount: 2,
|
||||
}
|
||||
expect(agentMetaText(a)).toBe('500 tok · 2 tool')
|
||||
})
|
||||
|
||||
@@ -15,8 +15,16 @@ import {
|
||||
type BuiltInAgentDefinition,
|
||||
} from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
import { createUserMessage, extractTextContent } from '../../utils/messages.js'
|
||||
import { getTokenCountFromUsage } from '../../utils/tokens.js'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createAgentId } from '../../utils/uuid.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { runWithCwdOverride } from '../../utils/cwd.js'
|
||||
import {
|
||||
createAgentWorktree,
|
||||
hasWorktreeChanges,
|
||||
removeAgentWorktree,
|
||||
} from '../../utils/worktree.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import type { ModelAlias } from '../../utils/model/aliases.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
@@ -74,6 +82,57 @@ export function extractStructuredOutput(
|
||||
return null
|
||||
}
|
||||
|
||||
type WorkflowWorktreeInfo = Awaited<ReturnType<typeof createAgentWorktree>>
|
||||
|
||||
/**
|
||||
* 为 workflow agent 的 worktree 隔离生成 slug:sha256(runId:agentId) 派生 hex 段,
|
||||
* 匹配 cleanupStaleAgentWorktrees 的清理正则 `^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$`。
|
||||
* taskId 是 `w`+base36(非 UUID),不能直接塞 runId 进正则段;sha256 是确定性映射,
|
||||
* agentId 保证同 runId 多 agent 的 slug 唯一(无共享计数器,无线程安全问题)。
|
||||
*/
|
||||
function makeWorkflowWorktreeSlug(runId: string, agentId: string): string {
|
||||
const h = createHash('sha256').update(`${runId}:${agentId}`).digest('hex')
|
||||
return `wf_${h.slice(0, 8)}-${h.slice(8, 11)}-${parseInt(h.slice(11, 17), 16) % 100000}`
|
||||
}
|
||||
|
||||
/**
|
||||
* agent 完成后清理 worktree:hookBased 保留(无法检测 VCS 变更);否则用
|
||||
* hasWorktreeChanges(fail-closed)检测,无变更 auto-remove,有变更/检测失败保留
|
||||
* 并 log 路径(v1 用日志而非扩 AgentRunResult,避免动 journal 序列化)。
|
||||
*/
|
||||
async function cleanupWorkflowWorktree(
|
||||
info: WorkflowWorktreeInfo,
|
||||
agentType: string,
|
||||
): Promise<void> {
|
||||
if (info.hookBased || !info.headCommit) return
|
||||
let changed = true
|
||||
try {
|
||||
changed = await hasWorktreeChanges(info.worktreePath, info.headCommit)
|
||||
} catch (e) {
|
||||
logForDebugging(
|
||||
`workflow worktree change-detect failed (${agentType}): ${(e as Error).message}`,
|
||||
)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) {
|
||||
try {
|
||||
await removeAgentWorktree(
|
||||
info.worktreePath,
|
||||
info.worktreeBranch,
|
||||
info.gitRoot,
|
||||
)
|
||||
} catch (e) {
|
||||
logForDebugging(
|
||||
`workflow worktree remove failed (${agentType}): ${(e as Error).message}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logForDebugging(
|
||||
`workflow worktree retained (has changes, ${agentType}): ${info.worktreePath}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 深度集成后端:从活会话解析 agent/model/tools,委托核心 runAgent。 */
|
||||
export const claudeCodeBackend: AgentAdapter = {
|
||||
id: 'claude-code',
|
||||
@@ -89,6 +148,28 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
const model = mapWorkflowModel(params.model)
|
||||
const agentId = createAgentId()
|
||||
|
||||
// isolation:'worktree' — 在独立 git worktree 里跑 agent,并发写互不冲突。
|
||||
let worktreeInfo: WorkflowWorktreeInfo | null = null
|
||||
if (params.isolation === 'worktree') {
|
||||
try {
|
||||
worktreeInfo = await createAgentWorktree(
|
||||
makeWorkflowWorktreeSlug(ctx.runId, agentId),
|
||||
)
|
||||
} catch (e) {
|
||||
// fail-closed:隔离未达成不静默退化为共享 cwd(否则并发写数据竞争)
|
||||
logForDebugging(
|
||||
`workflow worktree creation failed (${agentDef.agentType}): ${(e as Error).message}`,
|
||||
)
|
||||
return { kind: 'dead' }
|
||||
}
|
||||
}
|
||||
// runWithCwdOverride 让 agent 内的 Bash/Read 等工具看到 worktree 路径
|
||||
// (AsyncLocalStorage 跨 await 保持);runAgent 的 worktreePath 参数仅写 metadata。
|
||||
const runInCwd = worktreeInfo
|
||||
? <T>(fn: () => T): T =>
|
||||
runWithCwdOverride(worktreeInfo!.worktreePath, fn)
|
||||
: <T>(fn: () => T): T => fn()
|
||||
|
||||
const workerPermissionContext = {
|
||||
...appState.toolPermissionContext,
|
||||
mode: agentDef.permissionMode ?? 'acceptEdits',
|
||||
@@ -106,29 +187,54 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
const promptMessages = [createUserMessage({ content: promptText })]
|
||||
const messages: Message[] = []
|
||||
const startTime = Date.now()
|
||||
// 运行中进度累计(onProgress 推送 → agent_progress 事件 → 面板实时刷新 token/tool)。
|
||||
let tokenCount = 0
|
||||
let toolCount = 0
|
||||
|
||||
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)
|
||||
}
|
||||
await runInCwd(async () => {
|
||||
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 } : {}),
|
||||
...(worktreeInfo ? { worktreePath: worktreeInfo.worktreePath } : {}),
|
||||
})) {
|
||||
messages.push(msg as Message)
|
||||
// 累计运行中进度:assistant message 带 usage(累积值→覆盖)、content 内 tool_use(增量)。
|
||||
if (msg.type === 'assistant' && msg.message) {
|
||||
const usage = msg.message.usage as
|
||||
| Parameters<typeof getTokenCountFromUsage>[0]
|
||||
| undefined
|
||||
if (usage) tokenCount = getTokenCountFromUsage(usage)
|
||||
const content = msg.message.content as
|
||||
| Array<{ type: string }>
|
||||
| undefined
|
||||
if (content)
|
||||
toolCount += content.filter(b => b.type === 'tool_use').length
|
||||
}
|
||||
ctx.onProgress?.({ tokenCount, toolCount })
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
logForDebugging(
|
||||
`workflow sub-agent error (${agentDef.agentType}): ${(e as Error).message}`,
|
||||
)
|
||||
logEvent('tengu_workflow_agent', { ok: 0 })
|
||||
return { kind: 'dead' }
|
||||
} finally {
|
||||
if (worktreeInfo) {
|
||||
const info = worktreeInfo
|
||||
worktreeInfo = null
|
||||
await cleanupWorkflowWorktree(info, agentDef.agentType)
|
||||
}
|
||||
}
|
||||
|
||||
const finalized = finalizeAgentTool(messages, agentId, {
|
||||
@@ -141,6 +247,10 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
})
|
||||
const outputTokens =
|
||||
finalized.usage?.output_tokens ?? finalized.totalTokens ?? 0
|
||||
// 面板展示用:完成时 context 总 token、工具调用次数、解析后 model id。
|
||||
const finalTokenCount = finalized.totalTokens ?? 0
|
||||
const finalToolCount = finalized.totalToolUseCount ?? 0
|
||||
const resolvedModel = model ?? toolUseContext.options.mainLoopModel
|
||||
logEvent('tengu_workflow_agent', { ok: 1, outputTokens })
|
||||
|
||||
if (params.schema) {
|
||||
@@ -150,9 +260,19 @@ export const claudeCodeBackend: AgentAdapter = {
|
||||
kind: 'ok',
|
||||
output: structured as object,
|
||||
usage: { outputTokens },
|
||||
model: resolvedModel,
|
||||
toolCount: finalToolCount,
|
||||
tokenCount: finalTokenCount,
|
||||
}
|
||||
}
|
||||
const text = extractTextContent(finalized.content, '\n')
|
||||
return { kind: 'ok', output: text, usage: { outputTokens } }
|
||||
return {
|
||||
kind: 'ok',
|
||||
output: text,
|
||||
usage: { outputTokens },
|
||||
model: resolvedModel,
|
||||
toolCount: finalToolCount,
|
||||
tokenCount: finalTokenCount,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,36 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { Box, Text, useAnimationFrame } from '@anthropic/ink';
|
||||
import type { Theme } from '@anthropic/ink';
|
||||
import type { AgentProgress } from '../progress/store.js';
|
||||
import { agentVisual } from './status.js';
|
||||
import { agentMetaText, agentVisual } from './status.js';
|
||||
|
||||
const LABEL_WIDTH = 18;
|
||||
const SPINNER_FRAMES = ['·', '✢', '✱', '✶', '✻', '✽'];
|
||||
const FRAME_MS = 120;
|
||||
const LABEL_MAX = 18;
|
||||
|
||||
/**
|
||||
* 右 agent 列表(已按选中 phase 过滤)。
|
||||
* 光标行铺橙底;每行:标记 + label + 行尾状态文字(running/object/text/dead)。
|
||||
* 选中行:仅在本列聚焦(focused=true)时铺 selectionBg 底(保留 fg,非反色);
|
||||
* 焦点不在本列时不铺底色,避免“虚假聚焦”。
|
||||
* running agent 的状态符由 useAnimationFrame 驱动 spinner 动画(共享 clock,全局同步);
|
||||
* 右侧 `model · Nk tok · N tool` 由 agent_progress / agent_done 实时刷新。
|
||||
*/
|
||||
export function AgentList({
|
||||
agents,
|
||||
selectedIndex,
|
||||
focused,
|
||||
}: {
|
||||
agents: AgentProgress[];
|
||||
selectedIndex: number;
|
||||
focused: boolean;
|
||||
}): React.ReactNode {
|
||||
// 顶层订阅一次动画帧:所有 running agent 共享同一 frame(同步动画,省去逐行 hook)。
|
||||
const [ref, time] = useAnimationFrame(FRAME_MS);
|
||||
const frame = SPINNER_FRAMES[Math.floor(time / FRAME_MS) % SPINNER_FRAMES.length];
|
||||
|
||||
if (agents.length === 0) {
|
||||
return <Text color="subtle">(no agents in this phase)</Text>;
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box ref={ref} 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);
|
||||
const highlighted = selected && focused;
|
||||
const running = a.status === 'running';
|
||||
const mark = running ? frame : v.mark;
|
||||
const label = (a.label ?? `agent-${a.id}`).slice(0, LABEL_MAX);
|
||||
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 key={a.id} backgroundColor={highlighted ? 'selectionBg' : undefined} justifyContent="space-between">
|
||||
<Box>
|
||||
<Text color={v.color as keyof Theme}>{mark}</Text>
|
||||
<Text> {label}</Text>
|
||||
</Box>
|
||||
<Text color="subtle">{agentMetaText(a)}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { Box, Text, useAnimationFrame } 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';
|
||||
|
||||
const SPINNER_FRAMES = ['·', '✢', '✱', '✶', '✻', '✽'];
|
||||
const FRAME_MS = 120;
|
||||
|
||||
type PhaseRow = {
|
||||
title: string;
|
||||
status?: PhaseStatus;
|
||||
@@ -14,32 +17,45 @@ type PhaseRow = {
|
||||
|
||||
/**
|
||||
* 左 phase 侧栏:第一行 All(汇总 done/total),其后 merged phases(含 pending ○)。
|
||||
* 选中行铺橙底(文字色不变);selectedIndex=0 表示 All。
|
||||
* 选中行:仅在本列聚焦(focused=true)时铺 selectionBg 底(保留 fg,非反色)+ `>` 标记;
|
||||
* 焦点不在本列时不铺底色,避免“虚假聚焦”。running phase 状态符由 useAnimationFrame 驱动 spinner 动画。
|
||||
* 样式对齐参考图:`> ✓ Scan 3/3`。
|
||||
*/
|
||||
export function PhaseSidebar({
|
||||
phases,
|
||||
agents,
|
||||
selectedIndex,
|
||||
focused,
|
||||
}: {
|
||||
phases: MergedPhase[];
|
||||
agents: AgentProgress[];
|
||||
selectedIndex: number;
|
||||
focused: boolean;
|
||||
}): React.ReactNode {
|
||||
const [ref, time] = useAnimationFrame(FRAME_MS);
|
||||
const frame = SPINNER_FRAMES[Math.floor(time / FRAME_MS) % SPINNER_FRAMES.length];
|
||||
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">
|
||||
<Box ref={ref} 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;
|
||||
const highlighted = selected && focused;
|
||||
const running = row.status === 'running';
|
||||
const mark = running ? frame : row.status ? PHASE_MARK[row.status] : ' ';
|
||||
const color = (row.status ? PHASE_COLOR[row.status] : 'subtle') as keyof Theme;
|
||||
return (
|
||||
<Box key={row.title}>
|
||||
<Text backgroundColor={selected ? 'claude' : undefined} color={color}>
|
||||
{selected ? '▶' : ' '}
|
||||
{mark} {row.title.padEnd(10)} {row.done}/{row.total}
|
||||
<Box key={row.title} backgroundColor={highlighted ? 'selectionBg' : undefined} justifyContent="space-between">
|
||||
<Box>
|
||||
<Text color={selected ? 'claude' : undefined}>{highlighted ? '>' : ' '}</Text>
|
||||
<Text> </Text>
|
||||
<Text color={color}>{mark}</Text>
|
||||
<Text> {row.title}</Text>
|
||||
</Box>
|
||||
<Text color="subtle">
|
||||
{row.done}/{row.total}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { Box, Text, useAnimationFrame } from '@anthropic/ink';
|
||||
import type { Theme } 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 { RUN_STATUS_COLOR, RUN_STATUS_TEXT } from './status.js';
|
||||
import { type FocusColumn, type WorkflowKeyboardHandlers, useWorkflowKeyboard } from './useWorkflowKeyboard.js';
|
||||
import { ALL_PHASE, filterAgentsByPhase, mergePhases } from './selectors.js';
|
||||
import { ALL_PHASE, filterAgentsByPhase, formatDuration, mergePhases } from './selectors.js';
|
||||
|
||||
/**
|
||||
* 夹紧选中索引到有效区间(空列表→0;越界→末位;负/NaN→0)。
|
||||
@@ -124,33 +126,52 @@ export function WorkflowsPanel({
|
||||
const running = runs.filter(r => r.status === 'running').length;
|
||||
const done = runs.length - running;
|
||||
const phaseHeader = selectedPhaseTitle ?? ALL_PHASE;
|
||||
const agentDone = focused ? focused.agents.filter(a => a.status === 'done').length : 0;
|
||||
// 每秒刷新 header 耗时(共享 clock;订阅即触发重渲染,耗时走墙钟)。
|
||||
const [clockRef] = useAnimationFrame(1000);
|
||||
const elapsed = focused ? Date.now() - focused.startedAt : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
|
||||
<Box ref={clockRef} flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
|
||||
<Box justifyContent="space-between">
|
||||
<Text bold>Workflows</Text>
|
||||
<Text color="subtle">
|
||||
{running} running · {done} done
|
||||
</Text>
|
||||
<Text bold>{focused?.workflowName ?? 'Workflows'}</Text>
|
||||
{focused ? (
|
||||
<Text color="subtle">
|
||||
{agentDone}/{focused.agentCount} agents · {formatDuration(elapsed)} ·{' '}
|
||||
<Text color={RUN_STATUS_COLOR[focused.status] as keyof Theme}>{RUN_STATUS_TEXT[focused.status]}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color="subtle">
|
||||
{running} running · {done} done
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{focused?.description ? <Text color="subtle">{focused.description}</Text> : null}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<TabsBar runs={runs} activeRunId={activeRunId} />
|
||||
</Box>
|
||||
{runs.length > 1 ? (
|
||||
<Box marginTop={1}>
|
||||
<TabsBar runs={runs} activeRunId={activeRunId} />
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Box width="25%" flexDirection="column">
|
||||
<Text color={focusColumn === 'phases' ? 'claude' : 'subtle'} bold>
|
||||
PHASES
|
||||
Phases
|
||||
</Text>
|
||||
<PhaseSidebar phases={phases} agents={focused?.agents ?? []} selectedIndex={clampedPhase} />
|
||||
<PhaseSidebar
|
||||
phases={phases}
|
||||
agents={focused?.agents ?? []}
|
||||
selectedIndex={clampedPhase}
|
||||
focused={focusColumn === 'phases'}
|
||||
/>
|
||||
</Box>
|
||||
<Text color="subtle">│</Text>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
<Text color={focusColumn === 'agents' ? 'claude' : 'subtle'} bold>
|
||||
AGENTS · {phaseHeader}
|
||||
{phaseHeader} · {visibleAgents.length} agents
|
||||
</Text>
|
||||
<AgentList agents={visibleAgents} selectedIndex={clampedAgent} />
|
||||
<AgentList agents={visibleAgents} selectedIndex={clampedAgent} focused={focusColumn === 'agents'} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -58,3 +58,14 @@ export function filterAgentsByPhase(
|
||||
export function tabLabel(workflowName: string, runId: string): string {
|
||||
return `${workflowName}#${runId.slice(-4)}`
|
||||
}
|
||||
|
||||
/** 毫秒 → 紧凑耗时(<60s → `Ns`;<60m → `MmSSs`;否则 `HhMMm`)。面板 header 用。 */
|
||||
export function formatDuration(ms: number): string {
|
||||
const s = Math.floor(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
const m = Math.floor(s / 60)
|
||||
const ss = s % 60
|
||||
if (m < 60) return `${m}m${String(ss).padStart(2, '0')}s`
|
||||
const h = Math.floor(m / 60)
|
||||
return `${h}h${String(m % 60).padStart(2, '0')}m`
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ export const RUN_STATUS_COLOR: Record<RunProgress['status'], string> = {
|
||||
killed: 'subtle',
|
||||
}
|
||||
|
||||
/** run 状态 → 展示文字(header 用;对齐参考图 done/running)。 */
|
||||
export const RUN_STATUS_TEXT: Record<RunProgress['status'], string> = {
|
||||
running: 'running',
|
||||
completed: 'done',
|
||||
failed: 'failed',
|
||||
killed: 'killed',
|
||||
}
|
||||
|
||||
/** phase 在侧栏的合并状态(含 pending:meta 声明但未启动)。 */
|
||||
export type PhaseStatus = 'running' | 'done' | 'pending'
|
||||
|
||||
@@ -31,23 +39,35 @@ export const PHASE_COLOR: Record<PhaseStatus, string> = {
|
||||
pending: 'subtle',
|
||||
}
|
||||
|
||||
/** agent 行的视觉三件套:标记字符 + 颜色 + 行尾文字后缀。 */
|
||||
export type AgentVisual = { mark: string; color: string; suffix: string }
|
||||
/** agent 行的视觉:标记字符 + 颜色(running 由 UI 用 spinner 动画覆盖 mark)。 */
|
||||
export type AgentVisual = { mark: string; color: string }
|
||||
|
||||
/**
|
||||
* agent 状态 → 视觉。
|
||||
* - running → ● warning
|
||||
* - running → ● warning(UI 用 spinner 动画覆盖 mark)
|
||||
* - done·dead → ✗ error
|
||||
* - done·ok:outputShape='object' → object;否则 text
|
||||
* - done·ok → ✓ success
|
||||
*/
|
||||
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',
|
||||
}
|
||||
if (a.status === 'running') return { mark: '●', color: 'warning' }
|
||||
if (a.resultKind === 'dead') return { mark: '✗', color: 'error' }
|
||||
return { mark: '✓', color: 'success' }
|
||||
}
|
||||
|
||||
/** token 数 → 展示字符串(<1000 原值;否则保留 1 位小数 + k)。 */
|
||||
export function formatTokenCount(n: number | undefined): string {
|
||||
if (!n) return '0'
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
|
||||
}
|
||||
|
||||
/**
|
||||
* agent 行右侧统计文本:`model · Nk tok · N tool`。
|
||||
* 无 model 时省略前段;running 中 token/tool 由 agent_progress 实时刷新。
|
||||
*/
|
||||
export function agentMetaText(a: AgentProgress): string {
|
||||
const parts: string[] = []
|
||||
if (a.model) parts.push(a.model)
|
||||
parts.push(`${formatTokenCount(a.tokenCount)} tok`)
|
||||
parts.push(`${a.toolCount ?? 0} tool`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@ export type AgentProgress = {
|
||||
resultKind?: string
|
||||
/** 仅 done·ok 时有意义:output 是对象→'object',否则→'text'。dead/skipped 无。 */
|
||||
outputShape?: 'text' | 'object'
|
||||
/** 实际解析后的 model id(agent_done 带入;运行中无)。 */
|
||||
model?: string
|
||||
/** context 总 token(agent_progress 实时 / agent_done 落地最终值)。 */
|
||||
tokenCount?: number
|
||||
/** 累计工具调用次数(agent_progress 实时 / agent_done 落地最终值)。 */
|
||||
toolCount?: number
|
||||
}
|
||||
|
||||
export type RunProgress = {
|
||||
@@ -24,6 +30,10 @@ export type RunProgress = {
|
||||
agentCount: number
|
||||
returnValue?: unknown
|
||||
error?: string
|
||||
/** run_started 时间戳(面板算运行耗时用)。 */
|
||||
startedAt: number
|
||||
/** workflow 描述(来自 run_started.meta.description)。 */
|
||||
description?: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
@@ -59,6 +69,7 @@ export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore {
|
||||
currentPhase: null,
|
||||
agents: [],
|
||||
agentCount: 0,
|
||||
startedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
byId.set(runId, p)
|
||||
@@ -80,6 +91,7 @@ export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore {
|
||||
p.workflowName = event.workflowName
|
||||
p.status = 'running'
|
||||
p.declaredPhases = event.meta?.phases?.map(ph => ph.title) ?? []
|
||||
p.description = event.meta?.description ?? undefined
|
||||
break
|
||||
case 'phase_started':
|
||||
if (!p.phases.some(ph => ph.title === event.phase)) {
|
||||
@@ -110,6 +122,15 @@ export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'agent_progress': {
|
||||
// 实时进度:仅更新 token/tool(高频,但每 agent message 一次,频率可控)。
|
||||
const ap = p.agents.find(x => x.id === event.agentId)
|
||||
if (ap) {
|
||||
ap.tokenCount = event.tokenCount
|
||||
ap.toolCount = event.toolCount
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'agent_done': {
|
||||
let a = p.agents.find(x => x.id === event.agentId)
|
||||
if (!a) {
|
||||
@@ -125,6 +146,9 @@ export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore {
|
||||
event.result.output !== null
|
||||
? ('object' as const)
|
||||
: ('text' as const),
|
||||
tokenCount: event.result.tokenCount,
|
||||
toolCount: event.result.toolCount,
|
||||
model: event.result.model,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
@@ -139,6 +163,9 @@ export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore {
|
||||
event.result.output !== null
|
||||
? 'object'
|
||||
: 'text'
|
||||
a.tokenCount = event.result.tokenCount
|
||||
a.toolCount = event.result.toolCount
|
||||
a.model = event.result.model
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
listNamedWorkflows,
|
||||
parseScript,
|
||||
persistInlineScript,
|
||||
resolveNamedWorkflow,
|
||||
runWorkflow,
|
||||
WORKFLOW_DIR_NAME,
|
||||
@@ -49,7 +50,7 @@ export type WorkflowService = {
|
||||
>,
|
||||
toolUseContext: ToolUseContext,
|
||||
canUseTool: CanUseToolFn,
|
||||
): Promise<{ runId: string }>
|
||||
): Promise<{ runId: string; scriptPath?: string }>
|
||||
kill(runId: string): void
|
||||
/**
|
||||
* 进程退出 / 配置卸载时清理:杀掉所有 running run,避免孤儿 task。
|
||||
@@ -86,6 +87,7 @@ export function getWorkflowService(): WorkflowService {
|
||||
export function makeService(
|
||||
ports: WorkflowPorts,
|
||||
store: ProgressStore,
|
||||
cwdOverride?: string,
|
||||
): WorkflowService {
|
||||
const buildHost = (
|
||||
toolUseContext: ToolUseContext,
|
||||
@@ -94,7 +96,8 @@ export function makeService(
|
||||
handle: makeHostHandle(buildHostBundle(toolUseContext, canUseTool)),
|
||||
// 用 projectRoot 与 ports.ts hostFactory / journalStore 保持同根;
|
||||
// 进入 worktree/子目录时不会让命名 workflow 解析与 journal 落盘不同步。
|
||||
cwd: getProjectRoot(),
|
||||
// cwdOverride 仅供测试注入临时目录(避免 inline 持久化写真实项目目录)。
|
||||
cwd: cwdOverride ?? getProjectRoot(),
|
||||
budgetTotal: null, // turn 级预算注入点(未来从 settings 读)
|
||||
toolUseId: toolUseContext.toolUseId,
|
||||
})
|
||||
@@ -158,6 +161,23 @@ export function makeService(
|
||||
host.handle,
|
||||
)
|
||||
|
||||
// inline 入口持久化脚本到 run 目录(与 WorkflowTool 对称),返回可复用路径。
|
||||
// 写盘失败降级(log),不阻断 run(script 已在内存)。
|
||||
let persistedScriptPath: string | undefined
|
||||
if (!workflowFile && input.script) {
|
||||
try {
|
||||
persistedScriptPath = await persistInlineScript(
|
||||
input.script,
|
||||
runId,
|
||||
host.cwd,
|
||||
)
|
||||
} catch (e) {
|
||||
logForDebugging(
|
||||
`workflow inline script persist failed: ${(e as Error).message}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// detached:不 await,让调用方立即拿到 runId;结束路由到 registrar。
|
||||
void runWorkflow({
|
||||
script,
|
||||
@@ -183,7 +203,10 @@ export function makeService(
|
||||
.catch(e => ports.taskRegistrar.fail(runId, (e as Error).message))
|
||||
|
||||
logForDebugging(`workflow launched: ${runId} (${workflowName})`)
|
||||
return { runId }
|
||||
return {
|
||||
runId,
|
||||
...(persistedScriptPath ? { scriptPath: persistedScriptPath } : {}),
|
||||
}
|
||||
},
|
||||
|
||||
kill(runId) {
|
||||
@@ -193,8 +216,17 @@ export function makeService(
|
||||
shutdown() {
|
||||
// 仅杀 running:已完成/失败的 run taskRegistrar 已回收 binding,kill 是 no-op。
|
||||
// taskRegistrar.kill 对未知 runId 安全 no-op,因此幂等——多次 shutdown 不重复抛错。
|
||||
// 每个 kill 单独 try/catch:kill 内部走 setAppState,进程 exit 阶段触发 React 重渲染
|
||||
// 可能抛错(render 已卸载等);单个失败不应阻断其他 run 的清理。
|
||||
for (const run of store.list()) {
|
||||
if (run.status === 'running') ports.taskRegistrar.kill(run.runId)
|
||||
if (run.status !== 'running') continue
|
||||
try {
|
||||
ports.taskRegistrar.kill(run.runId)
|
||||
} catch (e) {
|
||||
logForDebugging(
|
||||
`workflow shutdown: kill ${run.runId} failed: ${(e as Error).message}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
createWorkflowTool,
|
||||
workflowInputSchema,
|
||||
WORKFLOW_TOOL_NAME,
|
||||
type WorkflowToolDescriptor,
|
||||
} from '@claude-code-best/workflow-engine'
|
||||
import { buildTool, type Tool } from '../Tool.js'
|
||||
@@ -8,25 +10,37 @@ import { getWorkflowService } from './service.js'
|
||||
/**
|
||||
* 把引擎自包含描述符适配为 buildTool 兼容的 Tool。
|
||||
* 描述符统一走 service 单例(共享 ports/registry/store)。
|
||||
*
|
||||
* ports 解析延迟到首次实际方法调用(lazy):tools.ts 在模块加载阶段(feature-gated)
|
||||
* 调用 createWorkflowToolCore(),若此时立即解析 ports 会触发 service 实例化,
|
||||
* 进而调用 getProjectRoot 等模块级副作用——这在 bootstrap 完成前可能拿到错误路径。
|
||||
* Tool 对象本身的单例由 createWorkflowToolCore 的 cached 保证(PermissionRequest
|
||||
* 按引用匹配),ports 单例由 getWorkflowService 保证。
|
||||
*/
|
||||
function buildWorkflowTool(): Tool {
|
||||
const { ports } = getWorkflowService()
|
||||
const descriptor: WorkflowToolDescriptor = createWorkflowTool(ports)
|
||||
let cachedDescriptor: WorkflowToolDescriptor | null = null
|
||||
const descriptor = (): WorkflowToolDescriptor => {
|
||||
if (!cachedDescriptor) {
|
||||
const { ports } = getWorkflowService()
|
||||
cachedDescriptor = createWorkflowTool(ports)
|
||||
}
|
||||
return cachedDescriptor
|
||||
}
|
||||
return buildTool({
|
||||
name: descriptor.name,
|
||||
name: WORKFLOW_TOOL_NAME,
|
||||
maxResultSizeChars: 50_000,
|
||||
inputSchema: descriptor.inputSchema,
|
||||
isEnabled: () => descriptor.isEnabled(),
|
||||
isReadOnly: input => descriptor.isReadOnly(input),
|
||||
inputSchema: workflowInputSchema,
|
||||
isEnabled: () => descriptor().isEnabled(),
|
||||
isReadOnly: input => descriptor().isReadOnly(input),
|
||||
isConcurrencySafe: () => true,
|
||||
async description() {
|
||||
return descriptor.description()
|
||||
return descriptor().description()
|
||||
},
|
||||
async prompt() {
|
||||
return descriptor.prompt()
|
||||
return descriptor().prompt()
|
||||
},
|
||||
async call(input, context, canUseTool, parentMessage, onProgress) {
|
||||
const result = await descriptor.call(
|
||||
const result = await descriptor().call(
|
||||
input,
|
||||
context,
|
||||
canUseTool,
|
||||
@@ -35,9 +49,9 @@ function buildWorkflowTool(): Tool {
|
||||
)
|
||||
return { data: result.data }
|
||||
},
|
||||
renderToolUseMessage: input => descriptor.renderToolUseMessage(input),
|
||||
renderToolUseMessage: input => descriptor().renderToolUseMessage(input),
|
||||
mapToolResultToToolResultBlockParam: (data, toolUseId) =>
|
||||
descriptor.mapToolResultToToolResultBlockParam(data, toolUseId),
|
||||
descriptor().mapToolResultToToolResultBlockParam(data, toolUseId),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user