maestro/src/bridge/delegate-runs-api.ts
oss-sync 63d34d7cf6
All checks were successful
CI / build-and-test (push) Successful in 6m22s
sync: update from private repo (061c701c)
2026-07-09 03:01:06 +00:00

130 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<typeof reconstructDelegateRuns>;
}> = [];
// ローカルタスクは 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;
}