import { useState, useMemo, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { fetchLocalFileContent } from '../../../api'; // Mirror of `src/progress/event-log.ts` EventBase. Kept as a duplicate // here because the Vite UI build is a separate project from the engine. interface TraceEvent { v: 1; ts: string; seq: number; eventId: string; runId: string; parentEventId?: string; correlationId?: string; llmToolCallId?: string; movement?: string; iteration?: number; kind: string; payload: unknown; } interface ParseSummary { events: TraceEvent[]; skipped: number; unknownVersion: number; } function parseEventsJsonl(raw: string): ParseSummary { const out: TraceEvent[] = []; let skipped = 0; let unknownVersion = 0; for (const line of raw.split('\n')) { if (!line.trim()) continue; let parsed: unknown; try { parsed = JSON.parse(line); } catch { skipped++; continue; } if (!parsed || typeof parsed !== 'object') { skipped++; continue; } const obj = parsed as Record; if (obj.v !== 1) { unknownVersion++; continue; } if (typeof obj.kind !== 'string' || typeof obj.seq !== 'number' || typeof obj.eventId !== 'string') { skipped++; continue; } out.push(obj as unknown as TraceEvent); } return { events: out, skipped, unknownVersion }; } // Categorize for filter chips and color coding. const CATEGORIES: Array<{ id: string; label: string; kinds: string[]; tone: string }> = [ { id: 'run', label: 'Run', kinds: ['run_start', 'run_complete'], tone: 'bg-surface-2 text-slate-700 border-hairline' }, { id: 'movement', label: 'Movement', kinds: ['movement_start', 'movement_complete', 'transition', 'complete'], tone: 'bg-blue-50 text-blue-800 border-blue-100 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30' }, { id: 'tool', label: 'Tool', kinds: ['tool_call', 'tool_result'], tone: 'bg-canvas text-slate-700 border-hairline' }, { id: 'llm', label: 'LLM', kinds: ['llm_call_start', 'llm_call_end'], tone: 'bg-indigo-50 text-indigo-800 border-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:border-indigo-500/30' }, { id: 'cache', label: 'Cache', kinds: ['cache_set', 'cache_hit', 'cache_invalidate'], tone: 'bg-amber-50 text-amber-800 border-amber-100 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30' }, { id: 'memory', label: 'Memory', kinds: ['memory_invalidate', 'memory_update_call', 'memory_handoff_write', 'memory_handoff_read', 'memory_delta_write', 'memory_delta_absorb', 'memory_snapshot_written', 'memory_snapshot_failed'], tone: 'bg-emerald-50 text-emerald-800 border-emerald-100 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30' }, { id: 'watchdog', label: 'Watchdog', kinds: ['watchdog_fire', 'followup_detected'], tone: 'bg-red-50 text-red-800 border-red-100 dark:bg-red-500/15 dark:text-red-300 dark:border-red-500/30' }, { id: 'context', label: 'Context', kinds: ['context_action'], tone: 'bg-violet-50 text-violet-800 border-violet-100 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30' }, ]; /** * Color a duration bar by magnitude. Bars are inline next to tool_result / * llm_call_end rows so users can scan a timeline and spot the long tail at * a glance — XPostDetail taking 3 min stands out as red, a 200ms Read fades * to almost nothing. */ function durationBarStyle(ms: number): { widthPct: number; tone: string } { if (!Number.isFinite(ms) || ms <= 0) return { widthPct: 0, tone: 'bg-slate-200' }; // Log scale: 100ms = 10%, 1s = 30%, 10s = 60%, 100s = 90%, >180s = 100%. const widthPct = Math.min(100, Math.max(4, Math.log10(ms) * 22 - 22)); const tone = ms >= 60_000 ? 'bg-red-400' : ms >= 10_000 ? 'bg-orange-400' : ms >= 2_000 ? 'bg-amber-400' : ms >= 500 ? 'bg-emerald-400' : 'bg-slate-300'; return { widthPct, tone }; } function formatDurationLabel(ms: number): string { if (!Number.isFinite(ms) || ms < 0) return '?'; if (ms < 1000) return `${Math.round(ms)}ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; const sec = Math.round(ms / 1000); return `${Math.floor(sec / 60)}m${(sec % 60).toString().padStart(2, '0')}s`; } function toneFor(kind: string): string { for (const c of CATEGORIES) if (c.kinds.includes(kind)) return c.tone; return 'bg-canvas text-slate-600 border-slate-200'; } function categoryFor(kind: string): string { for (const c of CATEGORIES) if (c.kinds.includes(kind)) return c.id; return 'other'; } function summarizePayload(event: TraceEvent): string { const p = event.payload as Record | null; if (!p) return ''; switch (event.kind) { case 'tool_call': { const args = p.args as Record | undefined; const filePath = args?.['file_path'] ?? args?.['path'] ?? args?.['url'] ?? args?.['pattern']; return `${String(p.tool ?? '?')}${filePath ? ` ${filePath}` : ''}`; } case 'tool_result': return `${String(p.tool ?? '?')} ${p.isError ? '⚠ error' : 'ok'}${p.cacheHit ? ' (cached)' : ''} ${formatDurationLabel(Number(p.durationMs ?? 0))}`; case 'llm_call_start': return `iter=${p.iteration ?? '?'} msgs=${p.messageCount ?? '?'}`; case 'llm_call_end': { const tokens = (typeof p.promptTokens === 'number' && typeof p.completionTokens === 'number') ? ` in=${p.promptTokens} out=${p.completionTokens}` : ''; const shape = (p.toolCalls as number) > 0 ? ` tools=${p.toolCalls}` : (p.textChars as number) > 0 ? ` text=${p.textChars}c` : ''; return `${formatDurationLabel(Number(p.durationMs ?? 0))}${tokens}${shape}${p.hadError ? ' ⚠' : ''}`; } case 'cache_set': return `${String(p.tool ?? '?')} (${String(p.volatility ?? '?')})`; case 'cache_hit': return `${String(p.tool ?? '?')} from ${String(p.sourceMovement ?? '?')} (${p.ageMs ?? '?'}ms ago)`; case 'cache_invalidate': case 'memory_invalidate': return `${String(p.trigger ?? '')} → ${p.entriesEvicted ?? 0} entries`; case 'memory_update_call': { const counts = p.counts as Record | null; if (!counts) return p.empty ? 'empty payload' : ''; const parts: string[] = []; if (counts.factsAdded) parts.push(`facts +${counts.factsAdded}`); if (counts.factsMerged) parts.push(`facts merged ${counts.factsMerged}`); if (counts.decisionsAdded) parts.push(`decisions +${counts.decisionsAdded}`); if (counts.openQuestionsAdded) parts.push(`open_questions +${counts.openQuestionsAdded}`); if (counts.doNotRepeatAdded) parts.push(`do_not_repeat +${counts.doNotRepeatAdded}`); return parts.join(', ') || 'no changes'; } case 'memory_handoff_write': return p.skipped ? `skipped: ${p.reason}` : `→ child #${p.subtaskIndex ?? '?'} (${p.factsCount ?? 0}f / ${p.decisionsCount ?? 0}d)`; case 'memory_handoff_read': return `from parent ${String(p.parentJobId ?? '?')}`; case 'memory_delta_write': return p.skipped ? `skipped: ${p.reason}` : `${p.childStatus} ${p.partial ? '(partial) ' : ''}(${p.factsCount ?? 0}f / ${p.decisionsCount ?? 0}d)`; case 'memory_delta_absorb': return `${String(p.outcome ?? '?')}${p.childJobId ? ` ← ${p.childJobId}` : ''}`; case 'memory_snapshot_written': { const parts: string[] = []; if (typeof p.facts === 'number') parts.push(`${p.facts}f`); if (typeof p.decisions === 'number') parts.push(`${p.decisions}d`); if (typeof p.openQuestions === 'number') parts.push(`${p.openQuestions}q`); const counts = parts.length ? ` (${parts.join('/')})` : ''; const sizeKb = typeof p.bytes === 'number' ? ` ${(p.bytes / 1024).toFixed(1)}KB` : ''; return `${String(p.status ?? '?')} → ${String(p.path ?? '?')}${counts}${sizeKb}`; } case 'memory_snapshot_failed': return `${String(p.status ?? '?')} write failed: ${String(p.error ?? '?')}`; case 'watchdog_fire': return `${String(p.kind2 ?? '')} at iter=${p.iteration ?? '?'}`; case 'followup_detected': return `movement=${String(p.movementName ?? '?')}`; case 'context_action': return `${String(p.type ?? '?')} ratio=${typeof p.ratio === 'number' ? (p.ratio * 100).toFixed(0) + '%' : '?'}`; case 'transition': return `→ ${String(p.nextStep ?? '?')}`; case 'complete': return `${String(p.status ?? '?')}`; case 'movement_start': return `visit ${p.visitCount ?? '?'}/${p.maxVisits ?? '?'}`; case 'movement_complete': return `→ ${String(p.next ?? '?')}`; case 'run_start': return `piece=${String(p.pieceName ?? '?')}`; case 'run_complete': { const cancel = p.cancel as { phase?: string; movement?: string } | undefined; const cancelInfo = cancel?.phase ? ` cancel:${cancel.phase}@${cancel.movement ?? '?'}` : ''; const snapshot = p.memorySnapshotPath ? ` snapshot:${String(p.memorySnapshotPath).replace(/^logs\//, '')}` : ''; return `${String(p.status ?? '?')}${p.abortReason ? ` (${p.abortReason})` : ''}${cancelInfo}${snapshot}`; } default: return ''; } } interface TraceTabProps { taskId: number; } export function TraceTab({ taskId }: TraceTabProps) { const [refreshKey, setRefreshKey] = useState(0); const [expanded, setExpanded] = useState>(new Set()); const [enabledCategories, setEnabledCategories] = useState>( new Set(CATEGORIES.map((c) => c.id).concat(['other'])), ); const [movementFilter, setMovementFilter] = useState('all'); const [search, setSearch] = useState(''); const { data, isLoading, error } = useQuery({ queryKey: ['trace-events', taskId, refreshKey], queryFn: async () => { try { return await fetchLocalFileContent(taskId, 'logs', 'events.jsonl'); } catch (err) { // events.jsonl が存在しない初回タスクなどは空扱い if (err instanceof Error && /not.*found|404/i.test(err.message)) return ''; throw err; } }, refetchInterval: 5000, // 自動 5 秒ポーリング staleTime: 0, }); const summary = useMemo(() => { if (!data) return { events: [], skipped: 0, unknownVersion: 0 }; return parseEventsJsonl(data); }, [data]); const movements = useMemo(() => { const set = new Set(); for (const e of summary.events) if (e.movement) set.add(e.movement); return ['all', ...Array.from(set).sort()]; }, [summary.events]); /** * Per-tool aggregation: total wall-clock time spent + call count, sorted * by total time descending. Surfaces "XPostDetail consumed 12 min over * 4 calls" at a glance — the kind of thing buried in 500-line event * lists otherwise. Only counts non-cache hits so cached results don't * dilute the signal. */ const toolTimings = useMemo(() => { const stats = new Map(); let llmTotalMs = 0; let llmCount = 0; for (const e of summary.events) { const p = e.payload as Record | null; if (!p) continue; if (e.kind === 'tool_result' && !p.cacheHit) { const tool = String(p.tool ?? '?'); const ms = Number(p.durationMs ?? 0); const cur = stats.get(tool) ?? { totalMs: 0, count: 0, errors: 0, maxMs: 0 }; cur.totalMs += ms; cur.count += 1; if (p.isError) cur.errors += 1; if (ms > cur.maxMs) cur.maxMs = ms; stats.set(tool, cur); } if (e.kind === 'llm_call_end') { llmTotalMs += Number(p.durationMs ?? 0); llmCount += 1; } } const tools = Array.from(stats.entries()) .map(([tool, s]) => ({ tool, ...s })) .sort((a, b) => b.totalMs - a.totalMs); return { tools, llm: { totalMs: llmTotalMs, count: llmCount } }; }, [summary.events]); const filtered = useMemo(() => { const term = search.trim().toLowerCase(); return summary.events.filter((e) => { const cat = categoryFor(e.kind); if (!enabledCategories.has(cat)) return false; if (movementFilter !== 'all' && e.movement !== movementFilter) return false; if (term) { const haystack = `${e.kind} ${e.movement ?? ''} ${summarizePayload(e)} ${JSON.stringify(e.payload)}`.toLowerCase(); if (!haystack.includes(term)) return false; } return true; }); }, [summary.events, enabledCategories, movementFilter, search]); // Group consecutive events by correlationId so tool_call ↔ tool_result are // visually paired without manual interaction. const grouped = useMemo(() => { const groups: Array<{ correlationId?: string; events: TraceEvent[] }> = []; for (const e of filtered) { const last = groups[groups.length - 1]; if (e.correlationId && last && last.correlationId === e.correlationId) { last.events.push(e); } else { groups.push({ correlationId: e.correlationId, events: [e] }); } } return groups; }, [filtered]); // Auto-collapse expansion state when raw data is reloaded from polling. useEffect(() => { setExpanded((prev) => { const next = new Set(); for (const id of prev) { if (summary.events.some((e) => e.eventId === id)) next.add(id); } return next; }); }, [summary.events]); function toggleCategory(id: string): void { setEnabledCategories((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function toggleExpanded(eventId: string): void { setExpanded((prev) => { const next = new Set(prev); if (next.has(eventId)) next.delete(eventId); else next.add(eventId); return next; }); } if (isLoading) { return
読み込み中...
; } if (error) { return
エラー: {String(error)}
; } if (summary.events.length === 0) { return (
no trace yet
events.jsonl がまだ存在しません。タスクが少なくとも一度実行されると、engine 内部動作のトレースがここに表示されます。
); } return (
{/* Filter bar — sticky so it stays visible while scrolling. */}
{CATEGORIES.map((c) => { const on = enabledCategories.has(c.id); return ( ); })}
setSearch(e.target.value)} className="text-2xs flex-1 min-w-0 bg-transparent outline-none text-slate-900 placeholder:text-slate-400" />
{filtered.length} / {summary.events.length} events {summary.skipped > 0 && ⚠ {summary.skipped} skipped} {summary.unknownVersion > 0 && ⚠ {summary.unknownVersion} unknown version}
{/* Tool / LLM time aggregation — surfaces "what ate the wall-clock". */} {(toolTimings.tools.length > 0 || toolTimings.llm.count > 0) && (
time by source
{toolTimings.llm.count > 0 && (() => { const bar = durationBarStyle(toolTimings.llm.totalMs); return (
llm × {toolTimings.llm.count}
{formatDurationLabel(toolTimings.llm.totalMs)}
); })()} {toolTimings.tools.map(({ tool, totalMs, count, errors, maxMs }) => { const bar = durationBarStyle(totalMs); return (
0 ? 'text-red-700 dark:text-red-300' : 'text-slate-700'}`} title={tool}> {tool} × {count} {errors > 0 ? ⚠{errors} : null}
{formatDurationLabel(totalMs)}
); })}
総時間 (cache hit 除外、max は個別呼び出しの最大値)
)} {/* Event list */}
{grouped.map((group, gi) => { const grouped_ = group.correlationId && group.events.length > 1; return (
{group.events.map((e) => { const open = expanded.has(e.eventId); const tone = toneFor(e.kind); return (
{open && (
{JSON.stringify(e, null, 2)}
)}
); })}
); })}
); }