feat: Paseo-like orchestrator Phase 1-2 — trace system, session persistence, timeline, run_command, auto-fix loop
Phase 1: Trace System + Observability - tool_traces DB table + insert/update service - tool_trace_start/tool_trace_finish WS frames (contracts + FE types) - Instrumented tool-phase.ts with timing around every tool call - GET /api/chats/:id/traces paginated endpoint - Trace viewer frontend (collapsible panel with timing bars + token breakdown) Phase 2: Session Persistence + Resume - agent_snapshots table (UPSERT per chat, persisted on turn boundaries) - save/load/delete service functions - Agent snapshot sent on WS reconnect - Session timeline view (vertical timeline with scroll-to + restore) Tooling: - run_command tool (execFile, 30s timeout, 32KB cap, path-guarded) - Auto-fix loop: after write tools, runs pnpm build, injects errors into next turn
This commit is contained in:
251
apps/web/src/components/TraceViewer.tsx
Normal file
251
apps/web/src/components/TraceViewer.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { ToolTrace } from '@/api/types';
|
||||
|
||||
interface Props {
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
// Max latency used as the 100% reference for the bar visualization
|
||||
const MAX_LATENCY_REF = 30_000; // 30s
|
||||
|
||||
function latencyBarWidth(latencyMs: number | null): number {
|
||||
if (latencyMs == null) return 0;
|
||||
return Math.min(latencyMs / MAX_LATENCY_REF, 1);
|
||||
}
|
||||
|
||||
function TraceRow({ trace }: { trace: ToolTrace }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isError = trace.outcome !== null && trace.outcome !== 'success';
|
||||
const barWidth = latencyBarWidth(trace.latency_ms);
|
||||
const latencyLabel =
|
||||
trace.latency_ms != null
|
||||
? trace.latency_ms >= 1000
|
||||
? `${(trace.latency_ms / 1000).toFixed(1)}s`
|
||||
: `${trace.latency_ms}ms`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/40 last:border-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex items-center gap-2 w-full text-left px-2 py-1.5 hover:bg-muted/40 text-[11px]"
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
|
||||
</span>
|
||||
<span className="font-medium truncate min-w-0">
|
||||
{trace.tool_name}
|
||||
</span>
|
||||
{isError && (
|
||||
<span className="shrink-0 text-destructive" title={trace.error ?? 'error'}>
|
||||
<AlertCircle size={10} />
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-muted-foreground font-mono tabular-nums min-w-[3rem] text-right">
|
||||
{latencyLabel ?? '—'}
|
||||
</span>
|
||||
<span className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden min-w-[24px] max-w-[60px]">
|
||||
<span
|
||||
className="block h-full rounded-full bg-primary/30 transition-all"
|
||||
style={{ width: `${barWidth * 100}%` }}
|
||||
/>
|
||||
</span>
|
||||
{trace.tokens_used != null && trace.tokens_used > 0 && (
|
||||
<span className="shrink-0 text-muted-foreground font-mono tabular-nums">
|
||||
{trace.tokens_used}t
|
||||
</span>
|
||||
)}
|
||||
{trace.cache_tokens != null && trace.cache_tokens > 0 && (
|
||||
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
|
||||
c{trace.cache_tokens}
|
||||
</span>
|
||||
)}
|
||||
{trace.reasoning_tokens != null && trace.reasoning_tokens > 0 && (
|
||||
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
|
||||
r{trace.reasoning_tokens}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="px-3 pb-2 space-y-1.5 text-[11px] border-t border-border/40 pt-1.5">
|
||||
<div>
|
||||
<span className="text-muted-foreground font-medium">Input</span>
|
||||
<pre className="mt-0.5 font-mono text-[10px] leading-relaxed text-muted-foreground bg-muted/30 rounded p-1.5 overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(trace.tool_input, null, 1)}
|
||||
</pre>
|
||||
</div>
|
||||
{trace.tool_output != null && (
|
||||
<div>
|
||||
<span className="text-muted-foreground font-medium">Output</span>
|
||||
<pre className="mt-0.5 font-mono text-[10px] leading-relaxed text-muted-foreground bg-muted/30 rounded p-1.5 overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">
|
||||
{trace.tool_output.length > 2000
|
||||
? `${trace.tool_output.slice(0, 2000)}…`
|
||||
: trace.tool_output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{trace.error != null && (
|
||||
<div className="text-destructive text-[10px] font-mono leading-relaxed bg-destructive/10 rounded p-1.5">
|
||||
{trace.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceGroup({ toolName, traces }: { toolName: string; traces: ToolTrace[] }) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const totalLatency = traces.reduce((sum, t) => sum + (t.latency_ms ?? 0), 0);
|
||||
const totalTokens = traces.reduce((sum, t) => sum + (t.tokens_used ?? 0), 0);
|
||||
const errorCount = traces.filter(
|
||||
(t) => t.outcome !== null && t.outcome !== 'success',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
className="flex items-center gap-1.5 w-full text-left px-2 py-1 text-[11px] font-medium text-muted-foreground hover:bg-muted/30 sticky top-0 bg-background"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
|
||||
<span>{toolName}</span>
|
||||
<span className="text-muted-foreground/60 font-mono tabular-nums">
|
||||
×{traces.length}
|
||||
</span>
|
||||
{totalTokens > 0 && (
|
||||
<span className="text-muted-foreground/60 font-mono tabular-nums text-[10px]">
|
||||
{totalTokens}t
|
||||
</span>
|
||||
)}
|
||||
{totalLatency > 0 && (
|
||||
<span className="text-muted-foreground/60 font-mono tabular-nums text-[10px]">
|
||||
{totalLatency >= 1000
|
||||
? `${(totalLatency / 1000).toFixed(1)}s`
|
||||
: `${totalLatency}ms`}
|
||||
</span>
|
||||
)}
|
||||
{errorCount > 0 && (
|
||||
<span className="ml-auto text-destructive text-[10px] font-medium">
|
||||
{errorCount} error{errorCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{!collapsed && traces.map((trace) => (
|
||||
<TraceRow key={trace.id} trace={trace} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TraceViewer({ chatId }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [traces, setTraces] = useState<ToolTrace[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTraces = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.chats.getTraces(chatId);
|
||||
setTraces(res.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'failed to load traces');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
void fetchTraces();
|
||||
}
|
||||
}, [open, fetchTraces]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, ToolTrace[]>();
|
||||
for (const t of traces) {
|
||||
const existing = map.get(t.tool_name);
|
||||
if (existing) {
|
||||
existing.push(t);
|
||||
} else {
|
||||
map.set(t.tool_name, [t]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [traces]);
|
||||
|
||||
const totalCount = traces.length;
|
||||
const errorCount = traces.filter(
|
||||
(t) => t.outcome !== null && t.outcome !== 'success',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-[11px] font-medium text-muted-foreground hover:bg-muted/20"
|
||||
>
|
||||
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<span>Tool traces</span>
|
||||
{totalCount > 0 && (
|
||||
<span className="font-mono tabular-nums text-muted-foreground/60">
|
||||
{totalCount}
|
||||
</span>
|
||||
)}
|
||||
{errorCount > 0 && (
|
||||
<span className="text-destructive ml-auto text-[10px] font-medium">
|
||||
{errorCount} error{errorCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{loading && (
|
||||
<span className="ml-auto inline-block w-1.5 h-3 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="max-h-80 overflow-y-auto border-t border-border/40">
|
||||
{loading && traces.length === 0 && (
|
||||
<div className="px-3 py-4 text-[11px] text-muted-foreground text-center">
|
||||
Loading traces…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="px-3 py-2 text-[11px] text-destructive">
|
||||
{error}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchTraces()}
|
||||
className="ml-2 underline hover:no-underline"
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && traces.length === 0 && (
|
||||
<div className="px-3 py-4 text-[11px] text-muted-foreground text-center">
|
||||
No tool traces yet.
|
||||
</div>
|
||||
)}
|
||||
{traces.length > 0 && (
|
||||
<div className="divide-y divide-border/40">
|
||||
{Array.from(groups.entries()).map(([toolName, groupTraces]) => (
|
||||
<TraceGroup
|
||||
key={toolName}
|
||||
toolName={toolName}
|
||||
traces={groupTraces}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user