merge: maestro/tsk_O9q0_u78_NPo [tsk_O9q0_u78_NPo]

This commit is contained in:
maestro
2026-06-13 11:35:20 +08:00
6 changed files with 512 additions and 6 deletions
+22
View File
@@ -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';
@@ -205,6 +206,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 };
+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;
}
+6
View File
@@ -687,6 +687,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[] {