diff --git a/src/api/server.ts b/src/api/server.ts index 91284f5..f41267f 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -10,6 +10,7 @@ import { resolvedExecutorModels } from '../executor/models.js'; import { mergeBranch } from '../executor/merge.js'; import { git, removeWorktree } from '../executor/worktree.js'; import { resolveLogo, LOGO_MIME } from './logo.js'; +import { readTranscript, TranscriptError } from '../executor/transcript.js'; import { createReadStream } from 'node:fs'; import { createUsageFetcher, type UsageInfo } from '../daemon/usage.js'; @@ -196,6 +197,27 @@ export function buildServer(opts: ApiOptions): FastifyInstance { return store.listRuns(id); }); + // 执行转录回放:读 run.transcriptRef 指向的 jsonl,逐行解析成脱敏后的结构化消息数组。 + // 校验:runId 合法 → 关联任务/项目存在(可访问)→ 有转录记录 → 路径在 transcriptDir 下(防穿越)。 + app.get('/api/runs/:id/transcript', (req, reply) => { + const { id } = req.params as { id: string }; + const run = store.getRun(id); + if (!run) return reply.code(404).send({ error: `run 不存在: ${id}` }); + const task = store.getTask(run.taskId); + if (!task) return reply.code(404).send({ error: `run 关联任务不存在: ${run.taskId}` }); + if (!store.getProject(task.projectId)) return reply.code(404).send({ error: `run 关联项目不存在: ${task.projectId}` }); + if (!run.transcriptRef) return reply.code(404).send({ error: '该 run 没有转录记录' }); + try { + const messages = readTranscript(run.transcriptRef); + return { runId: run.id, taskId: run.taskId, kind: run.kind, status: run.status, messages }; + } catch (e) { + if (e instanceof TranscriptError) { + return reply.code(e.code === 'invalid_path' ? 400 : 404).send({ error: e.message }); + } + throw e; + } + }); + // 单任务全量事件(升序):归档详情的状态流转时间线 app.get('/api/tasks/:id/events', (req) => { const { id } = req.params as { id: string }; diff --git a/src/executor/transcript.ts b/src/executor/transcript.ts new file mode 100644 index 0000000..7e56764 --- /dev/null +++ b/src/executor/transcript.ts @@ -0,0 +1,191 @@ +import { readFileSync } from 'node:fs'; +import { resolve, sep } from 'node:path'; +import { transcriptDir } from './cc.js'; + +/** + * 结构化转录消息(脱敏后)。看板按 type 分类渲染。 + * - text : assistant 文本 + * - tool_use : 工具调用(名 + 摘要参数,已脱敏 / 截断) + * - tool_result: 工具结果(截断) + * - result : 会话结论文本 + * - fallback : 模型回退记录 + */ +export interface TranscriptMessage { + seq: number; + type: 'text' | 'tool_use' | 'tool_result' | 'result' | 'fallback'; + role?: 'assistant' | 'user'; + text?: string; // text / result / tool_result 正文 + name?: string; // tool_use:工具名 + input?: string; // tool_use:脱敏后的摘要参数(JSON 字符串) + isError?: boolean; // tool_result:是否报错 + truncated?: boolean; // 是否因过长被截断 + from?: string; // fallback:原模型 + to?: string; // fallback:回退模型 +} + +export class TranscriptError extends Error { + readonly code: 'invalid_path' | 'not_found'; + constructor(message: string, code: 'invalid_path' | 'not_found') { + super(message); + this.code = code; + this.name = 'TranscriptError'; + } +} + +/** tool_result 等长文本截断上限(字符) */ +const MAX_TEXT = 4000; +/** tool_use 参数摘要截断上限 */ +const MAX_INPUT = 2000; +const REDACTED = '[已脱敏]'; + +/** 敏感字段名(命中即整值脱敏):凭证 / 密钥 / 环境变量等一律不外传 */ +const SECRET_KEY_RE = + /(secret|token|password|passwd|api[-_]?key|apikey|credential|bearer|cookie|session[-_]?key|private[-_]?key|access[-_]?key|client[-_]?secret|refresh[-_]?token|^env$|环境变量)/i; + +/** 递归脱敏:命中敏感键名的值替换为 [已脱敏],其余原样保留 */ +function redact(value: unknown, depth = 0): unknown { + if (depth > 6) return '…'; + if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1)); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = SECRET_KEY_RE.test(k) ? REDACTED : redact(v, depth + 1); + } + return out; + } + return value; +} + +function truncate(text: string, max: number): { text: string; truncated: boolean } { + if (text.length <= max) return { text, truncated: false }; + return { text: text.slice(0, max), truncated: true }; +} + +/** tool_use 参数 → 脱敏 + 截断的摘要字符串 */ +function summarizeInput(input: unknown): { text: string; truncated: boolean } { + if (input === undefined || input === null) return { text: '', truncated: false }; + let json: string; + try { + json = JSON.stringify(redact(input)); + } catch { + json = String(input); + } + return truncate(json ?? '', MAX_INPUT); +} + +/** tool_result.content(string | block[] | 其它)→ 纯文本 */ +function extractToolResultText(content: unknown): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .map((b) => { + if (b && typeof b === 'object' && 'text' in (b as Record)) { + return String((b as { text: unknown }).text ?? ''); + } + return typeof b === 'string' ? b : JSON.stringify(b); + }) + .join('\n'); + } + if (content === undefined || content === null) return ''; + return typeof content === 'object' ? JSON.stringify(content) : String(content); +} + +/** + * 校验转录路径只允许落在 transcriptDir 下(防目录穿越)。 + * 不解析符号链接(文件可能尚未存在),仅做规范化前缀比对。 + */ +export function assertWithinTranscriptDir(ref: string, baseDir = transcriptDir()): string { + const base = resolve(baseDir); + const target = resolve(ref); + if (target !== base && !target.startsWith(base + sep)) { + throw new TranscriptError(`转录路径越界:${ref}`, 'invalid_path'); + } + return target; +} + +/** + * 读取并解析 jsonl 转录为脱敏后的结构化消息数组。 + * - 路径越界 → TranscriptError('invalid_path')(调用方映射 400) + * - 文件不存在 → TranscriptError('not_found')(调用方映射 404) + * - 单行解析失败:跳过该行(容错,不整体失败) + */ +export function readTranscript(ref: string, baseDir = transcriptDir()): TranscriptMessage[] { + const target = assertWithinTranscriptDir(ref, baseDir); + + let raw: string; + try { + raw = readFileSync(target, 'utf8'); + } catch { + throw new TranscriptError(`转录文件不存在:${ref}`, 'not_found'); + } + + const messages: TranscriptMessage[] = []; + let seq = 0; + const push = (m: Omit) => messages.push({ seq: seq++, ...m }); + + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + let m: Record; + try { + m = JSON.parse(trimmed) as Record; + } catch { + continue; + } + + const type = m.type; + + // 自定义:模型回退 + if (type === 'maestro.model_fallback') { + push({ type: 'fallback', from: String(m.from ?? ''), to: String(m.to ?? '') }); + continue; + } + + // assistant:文本块 + 工具调用块(thinking / 其它块忽略) + if (type === 'assistant') { + const content = (m.message as { content?: unknown })?.content; + if (Array.isArray(content)) { + for (const block of content) { + const b = block as Record; + if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) { + const { text, truncated } = truncate(b.text, MAX_TEXT); + push({ type: 'text', role: 'assistant', text, truncated }); + } else if (b.type === 'tool_use') { + const { text, truncated } = summarizeInput(b.input); + push({ type: 'tool_use', name: String(b.name ?? '工具'), input: text, truncated }); + } + } + } + continue; + } + + // user:工具结果 + if (type === 'user') { + const content = (m.message as { content?: unknown })?.content; + if (Array.isArray(content)) { + for (const block of content) { + const b = block as Record; + if (b.type === 'tool_result') { + const { text, truncated } = truncate(extractToolResultText(b.content), MAX_TEXT); + push({ type: 'tool_result', role: 'user', text, isError: b.is_error === true, truncated }); + } + } + } + continue; + } + + // result:会话结论 + if (type === 'result') { + const txt = m.result; + if (typeof txt === 'string' && txt.trim()) { + const { text, truncated } = truncate(txt, MAX_TEXT); + push({ type: 'result', text, truncated }); + } + continue; + } + + // system / 其它(init 等):不外传 + } + + return messages; +} diff --git a/src/store/store.ts b/src/store/store.ts index 0495a0e..f79ce13 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -639,6 +639,12 @@ export class Store { return rows.map(rowToRun); } + /** 单条 run(转录查看器校验 runId 合法性用);不存在 → undefined */ + getRun(runId: string): Run | undefined { + const row = this.db.prepare(`SELECT * FROM runs WHERE id = ?`).get(runId) as RunRow | undefined; + return row ? rowToRun(row) : undefined; + } + // ---------- Events ---------- /** 单任务全量事件(升序):状态流转/审批/运行历史,供归档详情时间线 */ listTaskEvents(taskId: string): Event[] { diff --git a/test/transcript.test.ts b/test/transcript.test.ts new file mode 100644 index 0000000..28519af --- /dev/null +++ b/test/transcript.test.ts @@ -0,0 +1,183 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { readTranscript, assertWithinTranscriptDir, TranscriptError } from '../src/executor/transcript.js'; +import { Store } from '../src/store/index.js'; +import { buildServer } from '../src/api/server.js'; + +/** 临时数据目录 + transcripts 子目录;返回 {dir, transcriptsDir} */ +function tmpData(): { dir: string; transcripts: string } { + const dir = mkdtempSync(join(tmpdir(), 'maestro-transcript-')); + const transcripts = join(dir, 'transcripts'); + mkdirSync(transcripts, { recursive: true }); + return { dir, transcripts }; +} + +/** 写一行原始 SDK 风格消息到 jsonl */ +function jsonl(lines: unknown[]): string { + return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'; +} + +const SAMPLE = [ + { type: 'system', subtype: 'init', session_id: 's1' }, + { type: 'assistant', session_id: 's1', message: { role: 'assistant', content: [ + { type: 'text', text: '我先看一下文件结构。' }, + { type: 'tool_use', id: 'tu1', name: 'Bash', input: { command: 'ls -la', description: '列目录' } }, + ] } }, + { type: 'user', session_id: 's1', message: { role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'tu1', content: 'total 4\nfile.txt', is_error: false }, + ] } }, + { type: 'maestro.model_fallback', from: 'opusX', to: 'sonnetY', reason: 'not_found' }, + { type: 'result', subtype: 'success', is_error: false, result: '完成:改了一行。' }, +]; + +test('readTranscript:样例 jsonl → 结构化消息数组', () => { + const { transcripts } = tmpData(); + const ref = join(transcripts, 'run_abc.jsonl'); + writeFileSync(ref, jsonl(SAMPLE)); + + const msgs = readTranscript(ref, transcripts); + const types = msgs.map((m) => m.type); + // system/init 不外传;其余按序结构化 + assert.deepEqual(types, ['text', 'tool_use', 'tool_result', 'fallback', 'result']); + + const text = msgs[0]; + assert.equal(text.role, 'assistant'); + assert.equal(text.text, '我先看一下文件结构。'); + + const toolUse = msgs[1]; + assert.equal(toolUse.name, 'Bash'); + assert.match(toolUse.input!, /ls -la/); + + const toolResult = msgs[2]; + assert.match(toolResult.text!, /file\.txt/); + assert.equal(toolResult.isError, false); + + const fb = msgs[3]; + assert.equal(fb.from, 'opusX'); + assert.equal(fb.to, 'sonnetY'); + + assert.equal(msgs[4].text, '完成:改了一行。'); +}); + +test('脱敏:含 secret/token/env 的工具参数被剔除', () => { + const { transcripts } = tmpData(); + const ref = join(transcripts, 'run_secret.jsonl'); + writeFileSync(ref, jsonl([ + { type: 'assistant', message: { content: [ + { type: 'tool_use', name: 'Deploy', input: { + host: 'example.com', + apiKey: 'sk-LIVE-should-not-leak', + token: 'ghp_secrettoken', + env: { ANTHROPIC_API_KEY: 'sk-ant-xxx' }, + password: 'hunter2', + note: 'safe-to-show', + } }, + ] } }, + ])); + + const msgs = readTranscript(ref, transcripts); + const input = msgs[0].input!; + assert.doesNotMatch(input, /should-not-leak/); + assert.doesNotMatch(input, /ghp_secrettoken/); + assert.doesNotMatch(input, /sk-ant-xxx/); + assert.doesNotMatch(input, /hunter2/); + assert.match(input, /\[已脱敏\]/); + // 非敏感字段保留 + assert.match(input, /example\.com/); + assert.match(input, /safe-to-show/); +}); + +test('长内容被截断并标记 truncated', () => { + const { transcripts } = tmpData(); + const ref = join(transcripts, 'run_long.jsonl'); + const big = 'x'.repeat(10_000); + writeFileSync(ref, jsonl([ + { type: 'user', message: { content: [{ type: 'tool_result', content: big }] } }, + ])); + const msgs = readTranscript(ref, transcripts); + assert.equal(msgs[0].truncated, true); + assert.ok(msgs[0].text!.length < big.length); +}); + +test('路径穿越被拒绝(invalid_path)', () => { + const { transcripts } = tmpData(); + assert.throws( + () => assertWithinTranscriptDir(join(transcripts, '..', '..', 'etc', 'passwd'), transcripts), + (e: unknown) => e instanceof TranscriptError && e.code === 'invalid_path', + ); + // 合法路径放行 + assert.doesNotThrow(() => assertWithinTranscriptDir(join(transcripts, 'run_ok.jsonl'), transcripts)); +}); + +test('坏行容错:非法 JSON 行被跳过,不影响其余解析', () => { + const { transcripts } = tmpData(); + const ref = join(transcripts, 'run_bad.jsonl'); + writeFileSync(ref, 'not-json\n' + JSON.stringify({ type: 'result', result: 'ok' }) + '\n\n'); + const msgs = readTranscript(ref, transcripts); + assert.equal(msgs.length, 1); + assert.equal(msgs[0].text, 'ok'); +}); + +// ── 端点测试(Fastify inject)── +test('GET /api/runs/:id/transcript:合法 run → 结构化消息', async () => { + const { dir, transcripts } = tmpData(); + process.env.MAESTRO_DATA_DIR = dir; // transcriptDir() 在调用时读取 + const store = new Store(':memory:'); + const p = store.createProject({ name: 't', repoPath: '/tmp/t-' + Math.random() }); + const task = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' }); + const run = store.startRun(task.id, 'executor'); + const ref = join(transcripts, run.id + '.jsonl'); + writeFileSync(ref, jsonl(SAMPLE)); + store.finishRun(run.id, 'succeeded', { transcriptRef: ref }); + + const app = buildServer({ store }); + const res = await app.inject({ method: 'GET', url: `/api/runs/${run.id}/transcript` }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.runId, run.id); + assert.equal(body.kind, 'executor'); + assert.ok(Array.isArray(body.messages)); + assert.equal(body.messages.length, 5); + await app.close(); + store.close(); +}); + +test('GET /api/runs/:id/transcript:未知 runId → 404', async () => { + const store = new Store(':memory:'); + const app = buildServer({ store }); + const res = await app.inject({ method: 'GET', url: '/api/runs/run_nope/transcript' }); + assert.equal(res.statusCode, 404); + await app.close(); + store.close(); +}); + +test('GET /api/runs/:id/transcript:无转录记录 → 404', async () => { + const store = new Store(':memory:'); + const p = store.createProject({ name: 't', repoPath: '/tmp/t-' + Math.random() }); + const task = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' }); + const run = store.startRun(task.id, 'executor'); // 未 finishRun,transcriptRef = null + const app = buildServer({ store }); + const res = await app.inject({ method: 'GET', url: `/api/runs/${run.id}/transcript` }); + assert.equal(res.statusCode, 404); + await app.close(); + store.close(); +}); + +test('GET /api/runs/:id/transcript:越界路径 → 400', async () => { + const { dir } = tmpData(); + process.env.MAESTRO_DATA_DIR = dir; + const store = new Store(':memory:'); + const p = store.createProject({ name: 't', repoPath: '/tmp/t-' + Math.random() }); + const task = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' }); + const run = store.startRun(task.id, 'executor'); + // 指向 transcriptDir 之外(穿越) + store.finishRun(run.id, 'failed', { transcriptRef: '/etc/passwd' }); + const app = buildServer({ store }); + const res = await app.inject({ method: 'GET', url: `/api/runs/${run.id}/transcript` }); + assert.equal(res.statusCode, 400); + await app.close(); + store.close(); +}); diff --git a/web/app.js b/web/app.js index a11abd4..e2819a3 100644 --- a/web/app.js +++ b/web/app.js @@ -585,12 +585,18 @@ function renderArchiveModal(t, runs, events) { .join(''); const runRows = runs.length ? `
执行历史(${runs.length} 次 run)
-
${runs.map((x) => kv( - `${RUN_KIND[x.kind] || esc(x.kind)} · ${RUN_ST[x.status] || esc(x.status)}`, - `${fmtFull(x.startedAt)} → ${x.endedAt ? fmtFull(x.endedAt) : '—'} · 时长 ${fmtDuration(x.startedAt, x.endedAt)}` + - (x.error ? `
错误:${esc(x.error)}` : '') + - (x.transcriptRef ? `
转录 ${esc(x.transcriptRef)}` : ''), - )).join('')}
` : ''; +
${runs.map((x) => ` +
+
+ ${RUN_KIND[x.kind] || esc(x.kind)} + ${RUN_ST[x.status] || esc(x.status)} + ${fmtFull(x.startedAt)} → ${x.endedAt ? fmtFull(x.endedAt) : '—'} · ${fmtDuration(x.startedAt, x.endedAt)} + + ${x.transcriptRef ? `` : ''} +
+ ${x.error ? `
错误:${esc(x.error)}
` : ''} + ${x.transcriptRef ? `` : ''} +
`).join('')}
` : ''; const resultBlock = r ? `${reviewReportBlocks(r)}
执行结果
@@ -647,6 +653,41 @@ function renderArchiveModal(t, runs, events) { `; } +// 过长内容折叠为
,短内容直接
+function foldable(text, truncated, threshold = 400) {
+  const body = esc(text) + (truncated ? esc('\n…(已截断)') : '');
+  if ((text || '').length <= threshold) return `
${body}
`; + return `
展开(${(text || '').length} 字符)
${body}
`; +} + +// 结构化转录消息流 → HTML(按 type 分类;文本/结论复用 mdToHtml,工具调用/结果折叠) +function renderTranscript(messages) { + if (!messages || !messages.length) return '
(无可展示的转录内容)
'; + return `
${messages.map((m) => { + if (m.type === 'text') { + return `
assistant +
${mdToHtml(m.text || '')}
${m.truncated ? '
…(已截断)
' : ''}
`; + } + if (m.type === 'result') { + return `
结论 +
${mdToHtml(m.text || '')}
${m.truncated ? '
…(已截断)
' : ''}
`; + } + if (m.type === 'fallback') { + return `
模型回退 + ${esc(m.from)} → ${esc(m.to)}
`; + } + if (m.type === 'tool_use') { + return `
工具调用 + ${esc(m.name)}${m.input ? foldable(m.input, m.truncated) : ''}
`; + } + if (m.type === 'tool_result') { + return `
+ 工具结果${m.isError ? ' · 报错' : ''}${foldable(m.text || '', m.truncated)}
`; + } + return ''; + }).join('')}
`; +} + // ── 渲染:全局预览(全屏读方案 + 就地裁决) ── function renderPreview() { const root = $('#previewRoot'); @@ -1183,6 +1224,22 @@ document.addEventListener('click', (ev) => { case 'archive-detail': openArchiveDetail(id); break; + + case 'run-transcript': { + ev.stopPropagation(); + const box = document.getElementById(`transcript-${id}`); + if (!box) break; + if (!box.hidden) { box.hidden = true; el.textContent = '▶ 查看转录'; break; } + box.hidden = false; + el.textContent = '▼ 收起转录'; + if (!box.dataset.loaded) { + box.innerHTML = '
加载中…
'; + api(`/api/runs/${id}/transcript`) + .then((data) => { box.dataset.loaded = '1'; box.innerHTML = renderTranscript(data.messages); }) + .catch((e) => { box.innerHTML = `
${esc(e.message)}
`; }); + } + break; + } case 'archive-modal-close': $('#archiveModalRoot').hidden = true; $('#archiveModalRoot').innerHTML = ''; diff --git a/web/style.css b/web/style.css index 497a893..290ed9f 100644 --- a/web/style.css +++ b/web/style.css @@ -826,3 +826,50 @@ li.ev-updated { --ev: var(--muted); } .detail-confirm-msg { font-size: 12px; color: var(--ink); } + +/* ── 归档详情:执行历史 + 转录回放 ── */ +.run-list { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; } +.run-item { border: 1px solid var(--line-soft); background: var(--panel); padding: 8px 10px; } +.run-head { display: flex; align-items: center; gap: 10px; font-size: 12px; flex-wrap: wrap; } +.run-head .spacer { flex: 1; } +.run-kind { color: var(--ink); font-weight: 600; } +.run-st { font-size: 11px; padding: 1px 6px; border: 1px solid var(--line); color: var(--muted); } +.run-st-succeeded { color: var(--green); border-color: var(--green-dim); } +.run-st-failed { color: var(--red); border-color: var(--red-dim); } +.run-st-started { color: var(--cyan); border-color: var(--cyan-dim); } +.run-time { color: var(--faint); font-size: 11px; letter-spacing: 0; } +.run-err { margin-top: 6px; font-size: 12px; color: var(--red); } + +.transcript { margin-top: 8px; border-top: 1px dashed var(--line-soft); padding-top: 8px; } +.transcript-loading, .transcript-empty { color: var(--muted); font-size: 12px; } +.transcript-err { color: var(--red); font-size: 12px; } + +.t-stream { display: flex; flex-direction: column; gap: 8px; } +.t-msg { font-size: 12.5px; border-left: 2px solid var(--line); padding: 2px 0 2px 10px; } +.t-msg .doc.md { margin-top: 2px; } +.t-text { border-left-color: var(--green-dim); } +.t-result { border-left-color: var(--green); } +.t-tool { border-left-color: var(--cyan-dim); } +.t-tool-result { border-left-color: var(--amber-dim); } +.t-tool-result.is-err { border-left-color: var(--red-dim); } +.t-fallback { border-left-color: var(--amber); } +.t-badge { + display: inline-block; font-size: 10px; letter-spacing: .08em; + padding: 1px 6px; margin-right: 8px; border: 1px solid var(--line); color: var(--muted); +} +.t-badge-a { color: var(--green); border-color: var(--green-dim); } +.t-badge-r { color: var(--green); border-color: var(--green); } +.t-badge-t { color: var(--cyan); border-color: var(--cyan-dim); } +.t-badge-tr { color: var(--amber); border-color: var(--amber-dim); } +.t-badge-w { color: var(--amber); border-color: var(--amber); } +.t-tool-name { color: var(--cyan); font-weight: 600; } +.t-fallback-txt { color: var(--amber); } +.t-trunc { color: var(--faint); font-size: 11px; margin-top: 2px; } +.t-pre { + margin: 4px 0 0; padding: 6px 8px; background: var(--bg-deep); border: 1px solid var(--line-soft); + font-size: 11.5px; white-space: pre-wrap; word-break: break-word; max-height: 320px; overflow-y: auto; +} +.t-fold { margin-top: 4px; } +.t-fold > summary { cursor: pointer; color: var(--muted); font-size: 11.5px; } +.t-fold > summary:hover { color: var(--green); } +.t-fold .t-pre { max-height: 480px; }