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:
2026-06-08 02:26:47 +00:00
parent 8f061c8d43
commit 9ef8f1948a
22 changed files with 2231 additions and 101 deletions

View 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>
);
}