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:
@@ -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.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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user