feat(transcript): 归档详情内回放执行/复审转录 (tsk_O9q0_u78_NPo)

- API:新增 GET /api/runs/:id/transcript,读 run.transcriptRef 指向的
  jsonl,逐行解析成脱敏后的结构化消息数组。校验 runId 合法且关联任务/
  项目存在;路径只允许在 transcriptDir 下(防目录穿越)。
- 脱敏:只透传 assistant 文本 / tool_use(名+摘要参数) / tool_result(截断)
  / 结论 / 模型回退;命中 secret/token/env 等敏感键名的值剔除为 [已脱敏]。
- 看板 archiveModal:执行历史从"文件路径"换成可展开的消息流,按 type
  分类渲染(文本/工具调用/工具结果/结论/回退),长内容折叠,复用 mdToHtml。
  executor 与 reviewer/security run 并列可回放。
- Store:新增 getRun(runId)。
- 测试:transcript 解析(样例 jsonl/脱敏/截断/坏行容错/路径穿越)+ 端点
  inject(合法/未知 runId/无转录/越界路径)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 10:42:57 +08:00
parent 872354cf7c
commit 0957a9049a
6 changed files with 512 additions and 6 deletions
+191
View File
@@ -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<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
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.contentstring | 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<string, unknown>)) {
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<TranscriptMessage, 'seq'>) => messages.push({ seq: seq++, ...m });
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
let m: Record<string, unknown>;
try {
m = JSON.parse(trimmed) as Record<string, unknown>;
} 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<string, unknown>;
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<string, unknown>;
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;
}