oss-sync caa0d03900
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (fd190dc)
2026-06-06 00:39:26 +00:00

510 lines
23 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 { 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<string, unknown>;
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<string, unknown> | null;
if (!p) return '';
switch (event.kind) {
case 'tool_call': {
const args = p.args as Record<string, unknown> | 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<string, number> | 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<Set<string>>(new Set());
const [enabledCategories, setEnabledCategories] = useState<Set<string>>(
new Set(CATEGORIES.map((c) => c.id).concat(['other'])),
);
const [movementFilter, setMovementFilter] = useState<string>('all');
const [search, setSearch] = useState<string>('');
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<string>();
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<string, { totalMs: number; count: number; errors: number; maxMs: number }>();
let llmTotalMs = 0;
let llmCount = 0;
for (const e of summary.events) {
const p = e.payload as Record<string, unknown> | 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<string>();
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 <div className="text-[13px] text-slate-500 p-4">...</div>;
}
if (error) {
return <div className="text-[13px] text-red-600 p-4">: {String(error)}</div>;
}
if (summary.events.length === 0) {
return (
<div className="text-xs text-slate-500 p-4 leading-relaxed">
<div className="section-label mb-1.5">no trace yet</div>
events.jsonl engine
</div>
);
}
return (
<div className="flex flex-col gap-3">
{/* Filter bar — sticky so it stays visible while scrolling. */}
<div className="sticky top-0 z-10 -mx-3 px-3 -mt-3 pt-3 pb-2 bg-surface/95 backdrop-blur border-b border-hairline">
<div className="flex flex-wrap gap-1 mb-2">
{CATEGORIES.map((c) => {
const on = enabledCategories.has(c.id);
return (
<button
key={c.id}
onClick={() => toggleCategory(c.id)}
className={`h-6 px-2 text-[10px] font-medium border rounded transition-colors ${
on ? c.tone : 'bg-canvas text-slate-400 border-hairline hover:text-slate-600'
}`}
>
{c.label}
</button>
);
})}
<button
onClick={() => toggleCategory('other')}
className={`h-6 px-2 text-[10px] font-medium border rounded transition-colors ${
enabledCategories.has('other')
? 'bg-canvas text-slate-700 border-hairline'
: 'bg-canvas text-slate-400 border-hairline-soft hover:text-slate-600'
}`}
>
Other
</button>
</div>
<div className="flex gap-1.5 items-center">
<select
value={movementFilter}
onChange={(e) => setMovementFilter(e.target.value)}
className="h-7 text-2xs border border-hairline rounded-md px-2 bg-canvas text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring"
>
{movements.map((m) => (
<option key={m} value={m}>{m === 'all' ? 'all movements' : m}</option>
))}
</select>
<div className="flex-1 min-w-0 flex items-center gap-1.5 bg-canvas border border-hairline rounded-md h-7 px-2 focus-within:ring-2 focus-within:ring-accent-ring">
<svg aria-hidden="true" className="w-3 h-3 text-slate-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="search kind, movement, payload..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="text-2xs flex-1 min-w-0 bg-transparent outline-none text-slate-900 placeholder:text-slate-400"
/>
</div>
<button
onClick={() => setRefreshKey((k) => k + 1)}
className="h-7 w-7 flex items-center justify-center text-xs border border-hairline rounded-md text-slate-500 bg-canvas hover:bg-surface transition-colors"
title="手動更新(自動 5 秒ごとにも更新されます)"
>
</button>
</div>
<div className="text-[10px] text-slate-500 font-mono tabular-nums mt-1.5">
{filtered.length} / {summary.events.length} events
{summary.skipped > 0 && <span className="ml-2 text-amber-600"> {summary.skipped} skipped</span>}
{summary.unknownVersion > 0 && <span className="ml-2 text-amber-600"> {summary.unknownVersion} unknown version</span>}
</div>
</div>
{/* Tool / LLM time aggregation — surfaces "what ate the wall-clock". */}
{(toolTimings.tools.length > 0 || toolTimings.llm.count > 0) && (
<div className="border border-hairline rounded-md p-2 bg-canvas">
<div className="section-label mb-1.5">time by source</div>
<div className="flex flex-col gap-0.5">
{toolTimings.llm.count > 0 && (() => {
const bar = durationBarStyle(toolTimings.llm.totalMs);
return (
<div className="flex items-center gap-2 text-2xs font-mono">
<span className="min-w-[14ch] text-indigo-700 dark:text-indigo-300 shrink-0">llm × {toolTimings.llm.count}</span>
<div className="flex-1 min-w-0 h-2 bg-slate-100 rounded relative overflow-hidden">
<div className={`${bar.tone} h-full`} style={{ width: `${bar.widthPct}%` }} />
</div>
<span className="min-w-[6ch] text-right tabular-nums text-slate-700 shrink-0">
{formatDurationLabel(toolTimings.llm.totalMs)}
</span>
</div>
);
})()}
{toolTimings.tools.map(({ tool, totalMs, count, errors, maxMs }) => {
const bar = durationBarStyle(totalMs);
return (
<div key={tool} className="flex items-center gap-2 text-2xs font-mono">
<span className={`min-w-[14ch] shrink-0 truncate ${errors > 0 ? 'text-red-700 dark:text-red-300' : 'text-slate-700'}`} title={tool}>
{tool} × {count}
{errors > 0 ? <span className="text-red-600"> {errors}</span> : null}
</span>
<div className="flex-1 min-w-0 h-2 bg-slate-100 rounded relative overflow-hidden">
<div className={`${bar.tone} h-full`} style={{ width: `${bar.widthPct}%` }} />
</div>
<span className="min-w-[6ch] text-right tabular-nums text-slate-700 shrink-0" title={`max ${formatDurationLabel(maxMs)}`}>
{formatDurationLabel(totalMs)}
</span>
</div>
);
})}
</div>
<div className="text-[10px] text-slate-500 mt-1.5">
(cache hit max )
</div>
</div>
)}
{/* Event list */}
<div className="flex flex-col">
{grouped.map((group, gi) => {
const grouped_ = group.correlationId && group.events.length > 1;
return (
<div key={gi} className={grouped_ ? 'border-l-2 border-hairline pl-2 my-0.5' : ''}>
{group.events.map((e) => {
const open = expanded.has(e.eventId);
const tone = toneFor(e.kind);
return (
<div key={e.eventId} className={`border rounded-md text-xs mb-1 ${tone}`}>
<button
onClick={() => toggleExpanded(e.eventId)}
className="w-full text-left px-2 py-1.5 flex items-center gap-2 cursor-pointer"
>
<span className="font-mono text-[10px] text-slate-500 shrink-0 w-[12ch] tabular-nums">
{new Date(e.ts).toLocaleTimeString(undefined, { hour12: false })}
</span>
<span className="font-mono font-semibold text-2xs shrink-0 min-w-[14ch]">{e.kind}</span>
{e.movement && (
<span className="font-mono text-[10px] text-slate-500 shrink-0">
{e.movement}{typeof e.iteration === 'number' ? `:${e.iteration}` : ''}
</span>
)}
<span className="flex-1 truncate font-mono text-2xs">{summarizePayload(e)}</span>
{(() => {
// Inline magnitude bar for any event carrying a durationMs.
// Visual sort: a 3-minute fetch jumps off the screen even
// when scrolled past, so you don't need to read every row.
const p = e.payload as Record<string, unknown> | null;
const ms = (e.kind === 'tool_result' || e.kind === 'llm_call_end') && p
? Number(p.durationMs ?? 0)
: 0;
if (ms <= 0) return null;
const bar = durationBarStyle(ms);
return (
<div className="w-[60px] h-1.5 bg-slate-100 rounded shrink-0 overflow-hidden" title={`${ms}ms`}>
<div className={`${bar.tone} h-full`} style={{ width: `${bar.widthPct}%` }} />
</div>
);
})()}
<span className="text-slate-400 shrink-0 text-[10px]">{open ? '▾' : '▸'}</span>
</button>
{open && (
<div className="border-t border-current/15 px-2 py-1.5 bg-white/60 font-mono text-2xs whitespace-pre overflow-x-auto leading-relaxed">
{JSON.stringify(e, null, 2)}
</div>
)}
</div>
);
})}
</div>
);
})}
</div>
</div>
);
}