import { Router, type Request, type Response } from 'express'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { type Repository, localTaskRepoName } from '../db/repository.js'; import { logger } from '../logger.js'; import { logRoot } from '../spaces/runtime-paths.js'; import { parseEventLine, type EventBase } from '../progress/event-log.js'; import { reconstructDelegateRuns } from '../progress/delegate-runs.js'; import { canViewTask, resolveSpaceAccess, collectAllSubJobs, readJobEvents, isJobWithinWorkspace } from './local-api-helpers.js'; const MAX_SUBJOBS = 200; /** 巨大 events.jsonl の安全弁: 末尾 N バイトだけ読む(先頭行が途中で切れたら捨てる)。 */ const MAX_EVENTS_BYTES = 32 * 1024 * 1024; function readEvents(task: { workspacePath: string | null; runtimeDir?: string | null }): EventBase[] { if (!task.workspacePath) return []; const path = join(logRoot({ workspacePath: task.workspacePath, runtimeDir: task.runtimeDir }), 'events.jsonl'); if (!existsSync(path)) return []; let raw: string; try { raw = readFileSync(path, 'utf-8'); } catch { return []; } if (raw.length > MAX_EVENTS_BYTES) raw = raw.slice(raw.length - MAX_EVENTS_BYTES); const out: EventBase[] = []; for (const line of raw.split('\n')) { if (!line.trim()) continue; const r = parseEventLine(line); if (r.kind === 'ok') out.push(r.event); } return out; } export function createDelegateRunsRouter(repo: Repository): Router { const router = Router(); router.get('/:id/delegate-runs', async (req: Request, res: Response) => { try { const taskId = Number(req.params.id); const viewer = req.user as Express.User | undefined; const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return; const runs = reconstructDelegateRuns(readEvents(task!)); const subtasks: Array<{ jobId: string; issueNumber: number; depth: number; status: string; runs: ReturnType; }> = []; // ローカルタスクは issueNumber === taskId の擬似リポジトリパターンで格納される const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId); if (latestJob) { const nodes = await collectAllSubJobs(repo, latestJob.id); for (const { job, depth } of nodes.slice(0, MAX_SUBJOBS)) { if (!job.worktreePath) continue; // fail-closed: workspacePath 未設定なら containment を評価できないので読まない。 // NOTE(将来): runtime_dir が有効化され task.workspacePath が null になるスペースモデルでは、 // ここで全サブタスクグループが隠れる。その際は latestJob.worktreePath をアンカーにする。 if (!isJobWithinWorkspace(task!.workspacePath, job.worktreePath)) continue; const subRuns = reconstructDelegateRuns(readJobEvents(job.worktreePath)); if (subRuns.length === 0) continue; subtasks.push({ jobId: job.id, issueNumber: job.issueNumber, depth, status: job.status, runs: subRuns }); } } res.json({ runs, subtasks }); } catch (err) { logger.error(`[delegate-runs] list error: ${err}`); res.status(500).json({ error: 'Failed to fetch delegate runs' }); } }); router.get('/:id/delegate-runs/:delegateRunId/timeline', async (req: Request, res: Response) => { try { const taskId = Number(req.params.id); const runId = req.params.delegateRunId; const ownerJobId = typeof req.query.jobId === 'string' ? req.query.jobId : null; // reconstructDelegateRuns と同じ帰属判定。tool_call/tool_result は // ペアリング用に correlationId を per-tool UUID で上書きするため、実行への // 帰属は delegateRunId タグで見る(旧ログは correlationId フォールバック)。 const belongsToRun = (e: EventBase): boolean => (e.delegateRunId ?? e.correlationId) === runId; const viewer = req.user as Express.User | undefined; const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return; let events: EventBase[]; if (ownerJobId) { // jobId 指定: そのジョブの events.jsonl を読む(fail-closed containment チェック付き) const job = await repo.getJob(ownerJobId, viewer ? { viewer } : undefined); if (!job || !job.worktreePath || !isJobWithinWorkspace(task!.workspacePath, job.worktreePath)) { res.status(404).json({ error: 'job not found' }); return; } events = readJobEvents(job.worktreePath); } else { // jobId 未指定: 親 events を読み、run 帰属イベントが無ければサブジョブを線形探索 events = readEvents(task!); if (!events.some(belongsToRun)) { const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId); if (latestJob) { for (const { job } of (await collectAllSubJobs(repo, latestJob.id)).slice(0, MAX_SUBJOBS)) { if (!job.worktreePath) continue; if (!isJobWithinWorkspace(task!.workspacePath, job.worktreePath)) continue; const ev = readJobEvents(job.worktreePath); if (ev.some(belongsToRun)) { events = ev; break; } } } } } res.json({ events: events.filter(belongsToRun) }); } catch (err) { logger.error(`[delegate-runs] timeline error: ${err}`); res.status(500).json({ error: 'Failed to fetch delegate run timeline' }); } }); return router; }