From 65b87ada394546094d86ee111deda41945f7b33b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 07:13:03 +0000 Subject: [PATCH 01/22] prototype(web): whole-session zoomable resource timeline Replace the chunk-at-a-time resource sparkline with a whole-session timeline prototype: - uPlot panels (CPU cores, memory used vs cache, disk, tool-call lane) sharing one x axis and crosshair, with reservation lines and OOM marks - level-of-detail data layer: the index draws the whole session, 5 s chunks load only when zoomed past the overview and are cached forever (chunks are immutable), so chunks never appear in the UI - sleeps between wake cycles compressed into labelled breaks (active-time axis) with a clock-time toggle - touch scrub with a fixed readout, pinch zoom, navigator strip with draggable window, range presets and a busiest-moments list - dev-only /prototype/resource-timeline route with synthetic sessions modelled on production ones and a proposed-vs-current backend switch Prototype branch: not for merge as-is. Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 1 + apps/web/src/App.tsx | 10 + .../chat/SessionResourceHistoryDrawer.tsx | 579 +----------------- .../resource-timeline/ResourceTimeline.tsx | 222 +++++++ .../resource-timeline/TimelineControls.tsx | 171 ++++++ .../resource-timeline/TimelineNavigator.tsx | 194 ++++++ .../chat/resource-timeline/TimelinePanels.tsx | 233 +++++++ .../chat/resource-timeline/chart-theme.ts | 100 +++ .../chat/resource-timeline/format.ts | 76 +++ .../chat/resource-timeline/panels.ts | 413 +++++++++++++ .../chat/resource-timeline/readout.ts | 151 +++++ .../chat/resource-timeline/resource-source.ts | 228 +++++++ .../chat/resource-timeline/series.ts | 275 +++++++++ .../chat/resource-timeline/time-axis.ts | 174 ++++++ .../chat/resource-timeline/timeline-view.ts | 96 +++ .../chat/resource-timeline/types.ts | 110 ++++ .../chat/resource-timeline/useElementWidth.ts | 15 + .../useResourceTimelineData.ts | 147 +++++ .../resource-timeline/useTimelineGestures.ts | 154 +++++ .../chat/resource-timeline/useUplot.ts | 51 ++ .../ProjectMessageViewDrawers.tsx | 14 +- .../resource-timeline-prototype/index.tsx | 132 ++++ .../mock-sessions.ts | 384 ++++++++++++ .../mock-source.ts | 161 +++++ apps/web/tests/unit/app-routes.test.tsx | 2 + pnpm-lock.yaml | 84 ++- 26 files changed, 3606 insertions(+), 571 deletions(-) create mode 100644 apps/web/src/components/chat/resource-timeline/ResourceTimeline.tsx create mode 100644 apps/web/src/components/chat/resource-timeline/TimelineControls.tsx create mode 100644 apps/web/src/components/chat/resource-timeline/TimelineNavigator.tsx create mode 100644 apps/web/src/components/chat/resource-timeline/TimelinePanels.tsx create mode 100644 apps/web/src/components/chat/resource-timeline/chart-theme.ts create mode 100644 apps/web/src/components/chat/resource-timeline/format.ts create mode 100644 apps/web/src/components/chat/resource-timeline/panels.ts create mode 100644 apps/web/src/components/chat/resource-timeline/readout.ts create mode 100644 apps/web/src/components/chat/resource-timeline/resource-source.ts create mode 100644 apps/web/src/components/chat/resource-timeline/series.ts create mode 100644 apps/web/src/components/chat/resource-timeline/time-axis.ts create mode 100644 apps/web/src/components/chat/resource-timeline/timeline-view.ts create mode 100644 apps/web/src/components/chat/resource-timeline/types.ts create mode 100644 apps/web/src/components/chat/resource-timeline/useElementWidth.ts create mode 100644 apps/web/src/components/chat/resource-timeline/useResourceTimelineData.ts create mode 100644 apps/web/src/components/chat/resource-timeline/useTimelineGestures.ts create mode 100644 apps/web/src/components/chat/resource-timeline/useUplot.ts create mode 100644 apps/web/src/pages/resource-timeline-prototype/index.tsx create mode 100644 apps/web/src/pages/resource-timeline-prototype/mock-sessions.ts create mode 100644 apps/web/src/pages/resource-timeline-prototype/mock-source.ts diff --git a/apps/web/package.json b/apps/web/package.json index 19ccc83839..c794398930 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -46,6 +46,7 @@ "recharts": "3.10.1", "remark-gfm": "catalog:", "tailwindcss": "4.3.3", + "uplot": "1.6.32", "valibot": "catalog:" }, "devDependencies": { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7eadf3b938..e94792a374 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -145,6 +145,10 @@ const ProjectTriggerDetail = lazyNamed( ); const ProjectTriggers = lazyNamed(() => import('./pages/ProjectTriggers'), 'ProjectTriggers'); const ProjectEvents = lazyNamed(() => import('./pages/ProjectEvents'), 'ProjectEvents'); +const ResourceTimelinePrototype = lazyNamed( + () => import('./pages/resource-timeline-prototype'), + 'ResourceTimelinePrototype' +); const SamPrototype = lazyNamed(() => import('./pages/SamPrototype'), 'SamPrototype'); const Settings = lazyNamed(() => import('./pages/Settings'), 'Settings'); const SettingsAgents = lazyNamed(() => import('./pages/SettingsAgents'), 'SettingsAgents'); @@ -229,6 +233,7 @@ function SuperadminRoute({ children }: { children: ReactNode }) { export const DEV_ONLY_ROUTE_PATHS = [ '/sam', + '/prototype/resource-timeline', '/__test/trial-chat-gate', '/__test/error-boundary', '/ui-standards', @@ -269,6 +274,11 @@ export default function App() { <> {/* SAM prototype — local/test only, no auth */} )} /> + {/* Resource timeline prototype — mock sessions, never shipped */} + )} + /> {/* Harness for Playwright audits — mounts trial components with mock data */} + importWithRetry(() => import('./resource-timeline/ResourceTimeline')).then((module) => ({ + default: module.ResourceTimeline, + })) +); + interface SessionResourceHistoryDrawerProps { - projectId: string; - sessionId: string; + source: ResourceHistorySource; onClose: () => void; } -function formatBytes(value: number | null | undefined): string { - if (value == null || !Number.isFinite(value)) return '—'; - if (value <= 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let next = value; - let unit = 0; - while (next >= 1024 && unit < units.length - 1) { - next /= 1024; - unit += 1; - } - return `${next >= 10 ? next.toFixed(0) : next.toFixed(1)} ${units[unit]}`; -} - -function formatDuration(startedAt: number, endedAt: number): string { - const seconds = Math.max(0, Math.round((endedAt - startedAt) / 1000)); - if (seconds < 90) return `${seconds}s`; - const minutes = Math.round(seconds / 60); - if (minutes < 90) return `${minutes}m`; - return `${(minutes / 60).toFixed(1)}h`; -} - -function formatTime(ts: number): string { - return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); -} - -function toolSpanLabel(span: WorkspaceResourceToolSpan): string { - const toolName = span.toolName?.trim(); - if (toolName) return toolName; - const kind = span.kind?.trim(); - if (!kind || kind === 'acp_tool_call') return 'tool'; - return kind.replaceAll('_', ' '); -} - -function sampleMemoryMiB(sample: WorkspaceResourceSample): number { - return Number(sample.memoryBytes ?? 0) / (1024 * 1024); -} - -function sampleWorkingSetMiB(sample: WorkspaceResourceSample): number | null { - if (sample.memoryWorkingSetBytes == null) return null; - return Number(sample.memoryWorkingSetBytes) / (1024 * 1024); -} - -function sampleCpuMillis(sample: WorkspaceResourceSample): number { - return Number(sample.cpuMillis ?? 0); -} - -function clampPercent(value: number): number { - return Math.max(0, Math.min(100, value)); -} - -function sampleX(samples: WorkspaceResourceSample[], sample: WorkspaceResourceSample): number { - if (samples.length <= 1) return 0; - const start = samples[0]?.t; - const end = samples.at(-1)?.t; - if (typeof start !== 'number' || typeof end !== 'number') return 0; - if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0; - return clampPercent(((sample.t - start) / (end - start)) * 100); -} - -function seriesPoints( - samples: WorkspaceResourceSample[], - valueForSample: (sample: WorkspaceResourceSample) => number | null, - scaleMax?: number -): string { - const values = samples.map(valueForSample).filter((value): value is number => value != null); - const max = Math.max(1, scaleMax ?? 0, ...values); - return samples - .filter((sample) => valueForSample(sample) != null) - .map((sample) => { - const x = sampleX(samples, sample); - const value = valueForSample(sample) ?? 0; - const y = 100 - (value / max) * 84 - 8; - return `${x.toFixed(2)},${y.toFixed(2)}`; - }) - .join(' '); -} - -function seriesSegments( - samples: WorkspaceResourceSample[], - valueForSample: (sample: WorkspaceResourceSample) => number | null, - scaleMax?: number -): string[] { - const values = samples.map(valueForSample).filter((value): value is number => value != null); - const max = Math.max(1, scaleMax ?? 0, ...values); - const segments: string[][] = []; - let current: string[] = []; - - for (const sample of samples) { - const value = valueForSample(sample); - if (value == null) { - if (current.length > 0) segments.push(current); - current = []; - continue; - } - const x = sampleX(samples, sample); - const y = 100 - (value / max) * 84 - 8; - current.push(`${x.toFixed(2)},${y.toFixed(2)}`); - } - if (current.length > 0) segments.push(current); - return segments.map((segment) => segment.join(' ')); -} - -export function ResourceSparkline({ - samples, - toolSpans, -}: Readonly<{ samples: WorkspaceResourceSample[]; toolSpans: WorkspaceResourceToolSpan[] }>) { - const cpuPoints = useMemo(() => seriesPoints(samples, sampleCpuMillis), [samples]); - const memoryScaleMax = useMemo(() => Math.max(1, ...samples.map(sampleMemoryMiB)), [samples]); - const totalMemoryPoints = useMemo( - () => seriesPoints(samples, sampleMemoryMiB, memoryScaleMax), - [memoryScaleMax, samples] - ); - const workingSetSegments = useMemo( - () => seriesSegments(samples, sampleWorkingSetMiB, memoryScaleMax), - [memoryScaleMax, samples] - ); - const eventMarkers = useMemo( - () => - samples - .map((sample) => ({ sample, x: sampleX(samples, sample) })) - .filter(({ sample }) => sample.gap || sample.oom || sample.oomKill || sample.counterReset), - [samples] - ); - const chunkIoRead = samples.reduce((total, sample) => total + Number(sample.ioReadBytes ?? 0), 0); - const chunkIoWrite = samples.reduce( - (total, sample) => total + Number(sample.ioWriteBytes ?? 0), - 0 - ); - - if (samples.length === 0) { - return ( -
- No samples in this chunk. -
- ); - } - - const start = samples[0]?.t ?? 0; - const end = samples.at(-1)?.t ?? start; - const spanWidth = Math.max(1, end - start); - - return ( -
- {/* - `preserveAspectRatio="none"`: without it the default `xMidYMid meet` - letterboxes the 1:1 viewBox into a centred SQUARE — measured 224px of - drawn width inside a 323px card (144px at this `h-36`), so a third of - the chart area was empty and the timeline was compressed to a third of - its width. Every polyline already carries - `vectorEffect="non-scaling-stroke"`, which only matters under - non-uniform scaling: stretching was always the intent. - */} - - {toolSpans.map((span) => { - const x = ((span.startedAt - start) / spanWidth) * 100; - const width = - ((Math.max(span.endedAt ?? span.startedAt, span.startedAt + 1) - span.startedAt) / - spanWidth) * - 100; - return ( - - ); - })} - - - {workingSetSegments.map((points, index) => ( - - ))} - {eventMarkers.map(({ sample, x }) => ( - - - - - ))} - -
- CPU: green solid line, normalized to the CPU peak for this chunk. - Memory needed: purple solid line (working set, when reported). - Total RAM: faint purple dashed line, including reclaimable file cache. - - Blue bands: concurrent tool windows. Dashed markers: gaps, counter resets, or OOM samples. - - - Chunk I/O deltas: {formatBytes(chunkIoRead)} read · {formatBytes(chunkIoWrite)} write. - -
- {toolSpans.length > 0 && ( -
-
- Tool windows -
-
    - {toolSpans.map((span) => { - const label = toolSpanLabel(span); - return ( -
  • - - {label} · {formatTime(span.startedAt)} - {span.approximate ? ' · approximate end' : ''} - - - {formatDuration(span.startedAt, span.endedAt ?? span.startedAt)} - {span.concurrency ? ` · ${span.concurrency} concurrent` : ''} - -
  • - ); - })} -
-
- )} -
- ); -} - -export function StatCard({ - icon: Icon, - label, - value, -}: Readonly<{ icon: typeof Cpu; label: string; value: string }>) { - return ( -
-
- - {label} -
-
{value}
-
- ); -} - -function formatCpuPeak(summary: WorkspaceResourceSummary): string { - if (summary.cpuPeakMillis == null) return '—'; - return `${Math.round(summary.cpuPeakMillis)} ms/sample`; -} - -function detailPointLabel(detail: WorkspaceResourceHistoryResponse['detail']): string { - if (!detail) return ''; - if (detail.downsampled) return `${detail.samples.length}/${detail.originalSampleCount} points`; - return `${detail.samples.length} points`; -} - -export function ResourceHistoryContent({ - isLoading, - isError, - isFetching, - history, - summary, - effectiveChunkId, - detail, - onSelectChunk, -}: Readonly<{ - isLoading: boolean; - isError: boolean; - isFetching: boolean; - history: WorkspaceResourceHistoryResponse | undefined; - summary: WorkspaceResourceSummary | null | undefined; - effectiveChunkId: string | null; - detail: WorkspaceResourceHistoryResponse['detail']; - onSelectChunk: (chunkId: string) => void; -}>) { - if (isLoading && !history) { - return ( -
- -
- ); - } - - if (isError) { - return ( -
- Resource history could not be loaded. -
- ); - } - - const chunks = history?.chunks ?? []; - if (!summary && chunks.length === 0 && !detail) { - return ( -
- No retained resource history is available for this session yet. -
- ); - } - - return ( - <> - {/* 1. Stat cards */} - {summary && ( -
- - - - - - -
- )} - - {/* 2. OOM banner */} - {summary && summary.oomCount > 0 && ( -
- - - {summary.oomCount} OOM event{summary.oomCount === 1 ? '' : 's'} observed in retained - samples. - -
- )} - - {/* 3. Chart (auto-loaded for newest chunk) */} - {detail && ( -
-
-

- Detail timeline -

- {detailPointLabel(detail)} -
- -
- )} - - {effectiveChunkId && !detail && isFetching && ( -
- -
- )} - - {/* 4. Correlation disclaimer (contextual, after chart) */} - {detail && ( -
- Correlation is based on concurrent tool windows and background resource usage. It is not - per-process causal attribution. Disk space is not sampled on the hot loop. -
- )} - - {/* 5. Chunks disclosure (collapsed by default) */} - {chunks.length > 0 && ( - - )} - - ); -} - -export function ChunkButton({ - chunk, - selected, - onSelect, -}: Readonly<{ chunk: WorkspaceResourceChunk; selected: boolean; onSelect: () => void }>) { - return ( - - ); -} - -function ChunksDisclosure({ - chunks, - effectiveChunkId, - onSelectChunk, -}: Readonly<{ - chunks: WorkspaceResourceChunk[]; - effectiveChunkId: string | null; - onSelectChunk: (chunkId: string) => void; -}>) { - const [open, setOpen] = useState(false); - return ( -
- - {open && ( -
- {chunks.map((chunk) => ( - onSelectChunk(chunk.id)} - /> - ))} -
- )} -
- ); -} - -export function SessionResourceHistoryDrawer({ - projectId, - sessionId, - onClose, -}: SessionResourceHistoryDrawerProps) { +export function SessionResourceHistoryDrawer({ source, onClose }: SessionResourceHistoryDrawerProps) { const panelRef = useRef(null); useDialogFocusTrap(panelRef, onClose); - const [selectedChunkId, setSelectedChunkId] = useState(null); - - const query = useQuery({ - queryKey: ['session-resource-history', projectId, sessionId, selectedChunkId], - queryFn: () => getSessionResourceHistory(projectId, sessionId, { chunkId: selectedChunkId }), - staleTime: 15_000, - placeholderData: keepPreviousData, - }); - - const history = query.data; - const summary = history?.summary; - const latestChunk = history?.chunks[0] ?? null; - const effectiveChunkId = selectedChunkId ?? latestChunk?.id ?? null; - const detail = query.isPlaceholderData ? undefined : history?.detail; - - useEffect(() => { - setSelectedChunkId(null); - }, [projectId, sessionId]); - - useEffect(() => { - if (selectedChunkId === null && latestChunk?.id && !query.isPlaceholderData) { - setSelectedChunkId(latestChunk.id); - } - }, [selectedChunkId, latestChunk?.id, query.isPlaceholderData]); return createPortal( <> @@ -586,17 +54,16 @@ export function SessionResourceHistoryDrawer({ -
- +
+ + +
+ } + > + +
, diff --git a/apps/web/src/components/chat/resource-timeline/ResourceTimeline.tsx b/apps/web/src/components/chat/resource-timeline/ResourceTimeline.tsx new file mode 100644 index 0000000000..eeffdae4ef --- /dev/null +++ b/apps/web/src/components/chat/resource-timeline/ResourceTimeline.tsx @@ -0,0 +1,222 @@ +import { Button, Spinner } from '@simple-agent-manager/ui'; +import { AlertTriangle } from 'lucide-react'; +import { useCallback, useMemo, useRef, useState } from 'react'; + +import { formatBytes, formatDayTime, formatElapsed } from './format'; +import { type PanelState,PLOT_LEFT_GUTTER_PX, PLOT_RIGHT_PADDING_PX } from './panels'; +import { readoutAtCursor, readoutForRange } from './readout'; +import type { ResourceHistorySource } from './resource-source'; +import { buildSeries, findPeaks, type UsagePeak } from './series'; +import { sleeps, type TimeAxisMode, toAxis, toReal } from './time-axis'; +import { useTimelineView } from './timeline-view'; +import { PEAK_CONTEXT_MS, PeakList, RangeControls, ReadoutBar } from './TimelineControls'; +import { TimelineNavigator } from './TimelineNavigator'; +import { TimelinePanels } from './TimelinePanels'; +import type { ResourceTimelineIndex } from './types'; +import { useElementWidth } from './useElementWidth'; +import { useResourceTimelineData, useResourceTimelineIndex, useTimelineAxis } from './useResourceTimelineData'; + +/** Peaks closer together than this count as one moment. */ +const PEAK_SEPARATION_MS = 20 * 60_000; +const PEAKS_PER_METRIC = 3; +/** "Latest" shows this much of the newest data. */ +const LATEST_SPAN_MS = 15 * 60_000; + +/** + * The whole-session resource timeline. The drawer loads this module lazily, so + * the project chat route never downloads uPlot unless someone opens Resources. + */ +export function ResourceTimeline({ source }: Readonly<{ source: ResourceHistorySource }>) { + const indexQuery = useResourceTimelineIndex(source); + + if (indexQuery.isPending) { + return ( +
+ +
+ ); + } + if (!indexQuery.data) { + return ( +
+

Resource history could not be loaded.

+ +
+ ); + } + if (indexQuery.data.chunks.length === 0) { + return ; + } + return ; +} + +function EmptyTimeline({ uploadIntervalMs }: Readonly<{ uploadIntervalMs: number }>) { + return ( +
+

No resource samples yet

+

+ The workspace samples CPU, memory and disk every few seconds and uploads them every{' '} + {formatElapsed(uploadIntervalMs)}, so the first data appears about {formatElapsed(uploadIntervalMs)} after the + session starts. +

+
+ ); +} + +function SessionSummary({ index, activeMs }: Readonly<{ index: ResourceTimelineIndex; activeMs: number }>) { + const first = index.runs[0]?.startedAt ?? index.chunks[0]?.startedAt ?? 0; + const last = index.chunks.at(-1)?.endedAt ?? first; + const nodes = new Set(index.runs.map((run) => run.nodeId).filter(Boolean)).size; + const highWater = Math.max(0, ...index.chunks.map((chunk) => chunk.memoryHighWaterBytes ?? 0)); + const wakes = index.runs.length; + return ( +
+

+ {formatElapsed(activeMs)} active + {wakes > 1 && ` over ${formatElapsed(last - first)} · ${wakes} wake cycles`} + {nodes > 1 && ` on ${nodes} nodes`} +

+

+ Data until {formatDayTime(last)} · uploaded every {formatElapsed(index.uploadIntervalMs)} + {highWater > 0 && ` · kernel memory peak ${formatBytes(highWater)}`} +

+ {index.completeness.kind === 'truncated' && ( +

+

+ )} +
+ ); +} + +function TimelineBody({ source, index }: Readonly<{ source: ResourceHistorySource; index: ResourceTimelineIndex }>) { + const [axisMode, setAxisMode] = useState('active'); + const axis = useTimelineAxis(index, axisMode); + const { view, intent, setRange, zoom, showSpan, showLatest, showAll } = useTimelineView(axis); + /** The instant under the finger or pointer, as wall-clock time so it survives axis changes. */ + const [cursorT, setCursorT] = useState(null); + + const measureRef = useRef(null); + const width = useElementWidth(measureRef); + const plotWidth = Math.max(1, width - PLOT_LEFT_GUTTER_PX - PLOT_RIGHT_PADDING_PX); + + const data = useResourceTimelineData(source, index, axis, { min: view.min, max: view.max, widthPx: plotWidth }); + const series = useMemo( + () => buildSeries(data.aggregates, axis, view.min, view.max, plotWidth, index.sampleIntervalMs), + [data.aggregates, axis, view.min, view.max, plotWidth, index.sampleIntervalMs] + ); + const hasWorkingSet = useMemo( + () => data.overview.some((aggregate) => aggregate.workingSetMaxBytes != null), + [data.overview] + ); + const panelState = useMemo( + () => ({ + axis, + viewMin: view.min, + viewMax: view.max, + series, + reservation: index.reservation, + toolSpans: data.toolSpans, + hasWorkingSet, + }), + [axis, view.min, view.max, series, index.reservation, data.toolSpans, hasWorkingSet] + ); + + const cursorX = cursorT == null ? null : toAxis(axis, cursorT); + const cursorInView = cursorX != null && cursorX >= view.min && cursorX <= view.max; + const readout = cursorInView + ? readoutAtCursor(cursorX, series, axis, index.runs, data.toolSpans, index.sampleIntervalMs) + : readoutForRange(view.min, view.max, axis, data.aggregates); + + const peaks = useMemo( + () => + [ + ...findPeaks(data.overview, 'memory', PEAKS_PER_METRIC, PEAK_SEPARATION_MS), + ...findPeaks(data.overview, 'cpu', PEAKS_PER_METRIC, PEAK_SEPARATION_MS), + ].sort((a, b) => a.at - b.at), + [data.overview] + ); + + const onCursor = useCallback((x: number | null) => setCursorT(x == null ? null : toReal(axis, x)), [axis]); + const onAxisMode = useCallback((mode: TimeAxisMode) => setAxisMode(mode), []); + const centre = cursorInView ? cursorX : (view.min + view.max) / 2; + const runLabel = useCallback( + (t: number) => { + const position = index.runs.findIndex((run) => t >= run.startedAt && t <= run.endedAt); + return position === -1 ? 'between runs' : `run ${position + 1} of ${index.runs.length}`; + }, + [index.runs] + ); + const onPeak = useCallback( + (peak: UsagePeak) => { + setCursorT(peak.at); + showSpan(PEAK_CONTEXT_MS, toAxis(axis, peak.at)); + }, + [axis, showSpan] + ); + + return ( +
+ + setCursorT(null)} + /> +
+ + +
+ 0} + onAll={showAll} + onSpan={(spanMs) => showSpan(spanMs, centre)} + onLatest={() => showLatest(LATEST_SPAN_MS)} + onAxisMode={onAxisMode} + /> + +
+ About this data +
    +
  • + Sampled every {formatElapsed(index.sampleIntervalMs)} from the workspace container's cgroup. It covers + everything in the container, not one process. +
  • +
  • CPU is in cores: 1.0 means one core fully busy.
  • +
  • + Memory “used” is the working set the kernel cannot reclaim; “incl. cache” adds page + cache it can drop under pressure. Size workspaces by “used”. +
  • +
  • + Tool calls line up with usage by time only. A spike during a tool call is a correlation, not proof that the + tool caused it. +
  • +
  • Zoomed out, each point is an average with its peak shaded; zoom in for individual samples.
  • +
+
+
+ ); +} diff --git a/apps/web/src/components/chat/resource-timeline/TimelineControls.tsx b/apps/web/src/components/chat/resource-timeline/TimelineControls.tsx new file mode 100644 index 0000000000..9502cfe6e6 --- /dev/null +++ b/apps/web/src/components/chat/resource-timeline/TimelineControls.tsx @@ -0,0 +1,171 @@ +import { AlertTriangle, ChevronsRight, Loader2, X } from 'lucide-react'; + +import { formatBytes, formatCores, formatDayTime, formatElapsed } from './format'; +import type { Readout } from './readout'; +import type { UsagePeak } from './series'; +import type { TimeAxisMode } from './time-axis'; + +const PRESET_SPANS_MS = [ + { label: '5m', ms: 5 * 60_000 }, + { label: '15m', ms: 15 * 60_000 }, + { label: '1h', ms: 60 * 60_000 }, + { label: '3h', ms: 3 * 60 * 60_000 }, +]; + +/** Tapping a peak shows this much time around it. */ +export const PEAK_CONTEXT_MS = 10 * 60_000; + +const chip = + 'rounded-full border px-2.5 py-1 text-xs transition-colors focus-visible:outline-2 focus-visible:outline-focus-ring'; +const chipIdle = `${chip} border-border-default text-fg-muted hover:bg-surface-hover hover:text-fg-primary`; +const chipActive = `${chip} border-accent bg-accent/15 text-fg-primary`; + +export function ReadoutBar({ + readout, + pendingChunks, + failedChunks, + onClear, +}: Readonly<{ readout: Readout; pendingChunks: number; failedChunks: number; onClear: () => void }>) { + return ( +
+
+
{readout.time}
+
{readout.context}
+ {readout.oomKills > 0 && ( +
+
+ )} +
+
+ {pendingChunks > 0 && ( + + + )} + {failedChunks > 0 && pendingChunks === 0 && ( + + detail unavailable + + )} + {readout.mode === 'cursor' && ( + + )} +
+
+ ); +} + +export function RangeControls({ + fullSpanMs, + viewSpanMs, + followingLatest, + axisMode, + hasSleeps, + onAll, + onSpan, + onLatest, + onAxisMode, +}: Readonly<{ + fullSpanMs: number; + viewSpanMs: number; + followingLatest: boolean; + axisMode: TimeAxisMode; + hasSleeps: boolean; + onAll: () => void; + onSpan: (spanMs: number) => void; + onLatest: () => void; + onAxisMode: (mode: TimeAxisMode) => void; +}>) { + const showingAll = viewSpanMs >= fullSpanMs * 0.999; + const presets = PRESET_SPANS_MS.filter((preset) => preset.ms < fullSpanMs * 0.9); + return ( +
+ + {presets.map((preset) => { + const active = !showingAll && Math.abs(viewSpanMs - preset.ms) < preset.ms * 0.02; + return ( + + ); + })} + + {hasSleeps && ( +
+ {(['active', 'clock'] as const).map((mode) => ( + + ))} +
+ )} +
+ ); +} + +export function PeakList({ + peaks, + runLabel, + onSelect, +}: Readonly<{ peaks: UsagePeak[]; runLabel: (t: number) => string; onSelect: (peak: UsagePeak) => void }>) { + if (peaks.length === 0) return null; + return ( +
+

Busiest moments

+
    + {peaks.map((peak) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/components/chat/resource-timeline/TimelineNavigator.tsx b/apps/web/src/components/chat/resource-timeline/TimelineNavigator.tsx new file mode 100644 index 0000000000..afa440369f --- /dev/null +++ b/apps/web/src/components/chat/resource-timeline/TimelineNavigator.tsx @@ -0,0 +1,194 @@ +import { type PointerEvent, useLayoutEffect, useMemo, useRef } from 'react'; +import type uPlot from 'uplot'; + +import { type ChartTheme, useChartTheme, withAlpha } from './chart-theme'; +import { formatDayTime } from './format'; +import { PLOT_LEFT_GUTTER_PX, PLOT_RIGHT_PADDING_PX } from './panels'; +import { buildSeries } from './series'; +import { sleeps, type TimeAxis } from './time-axis'; +import type { ViewRange } from './timeline-view'; +import type { ResourceAggregate } from './types'; +import { useElementWidth } from './useElementWidth'; +import { useUplot } from './useUplot'; + +const HEIGHT = 40; +/** Grips are thin to look at but wide to touch. */ +const GRIP_HIT_PX = 44; + +type Drag = + | { kind: 'move'; startX: number; view: ViewRange } + | { kind: 'min' | 'max'; view: ViewRange } + | { kind: 'draw'; anchor: number }; + +interface TimelineNavigatorProps { + axis: TimeAxis; + overview: readonly ResourceAggregate[]; + view: ViewRange; + sessionStart: number | null; + sessionEnd: number | null; + onRange: (min: number, max: number) => void; +} + +function navigatorOptions(theme: ChartTheme, axisRef: { current: TimeAxis }): Omit { + return { + height: HEIGHT, + legend: { show: false }, + cursor: { show: false }, + select: { show: false, left: 0, top: 0, width: 0, height: 0 }, + padding: [2, PLOT_RIGHT_PADDING_PX, 2, 0], + scales: { + x: { time: false, range: () => [axisRef.current.min, axisRef.current.max] }, + cpu: { range: (_u, _min, max) => [0, Math.max(0.5, max ?? 0) * 1.1] }, + memory: { range: (_u, _min, max) => [0, Math.max(1, max ?? 0) * 1.1] }, + }, + axes: [ + { show: false }, + { scale: 'cpu', size: PLOT_LEFT_GUTTER_PX, show: true, values: () => [], grid: { show: false }, ticks: { show: false } }, + ], + series: [ + {}, + { scale: 'cpu', stroke: theme.cpu, width: 1, fill: withAlpha(theme.cpu, 0.22), points: { show: false } }, + { scale: 'memory', stroke: withAlpha(theme.memory, 0.8), width: 1, points: { show: false } }, + ], + hooks: { + drawClear: [ + (u) => { + const { ctx, bbox } = u; + ctx.save(); + ctx.fillStyle = theme.sleep; + for (const sleep of sleeps(axisRef.current)) { + const x0 = u.valToPos(sleep.axisStart, 'x', true); + const x1 = u.valToPos(sleep.axisEnd, 'x', true); + ctx.fillRect(x0, bbox.top, Math.max(1, x1 - x0), bbox.height); + } + ctx.restore(); + }, + ], + }, + }; +} + +export function TimelineNavigator({ + axis, + overview, + view, + sessionStart, + sessionEnd, + onRange, +}: Readonly) { + const theme = useChartTheme(); + const containerRef = useRef(null); + const trackRef = useRef(null); + const axisRef = useRef(axis); + const trackWidth = useElementWidth(trackRef); + const drag = useRef(null); + + useLayoutEffect(() => { + axisRef.current = axis; + }, [axis]); + + const options = useMemo(() => navigatorOptions(theme, axisRef), [theme]); + const data = useMemo((): uPlot.AlignedData => { + const series = buildSeries(overview, axis, axis.min, axis.max, Math.max(1, trackWidth), 0); + return [series.x, series.cpuMax, series.memoryMax]; + }, [overview, axis, trackWidth]); + useUplot(containerRef, options, data); + + const span = Math.max(1, axis.max - axis.min); + const toPx = (x: number) => ((x - axis.min) / span) * trackWidth; + const toAxisX = (clientX: number) => { + const rect = trackRef.current?.getBoundingClientRect(); + if (!rect) return axis.min; + return axis.min + ((clientX - rect.left) / Math.max(1, rect.width)) * span; + }; + const left = toPx(view.min); + const width = Math.max(2, toPx(view.max) - left); + + const onPointerDown = (event: PointerEvent) => { + const x = event.clientX - (trackRef.current?.getBoundingClientRect().left ?? 0); + const nearMin = Math.abs(x - left) <= GRIP_HIT_PX / 2; + const nearMax = Math.abs(x - (left + width)) <= GRIP_HIT_PX / 2; + if (nearMin || nearMax) { + // Prefer the grip the pointer is closer to when the window is narrow. + drag.current = { kind: Math.abs(x - left) < Math.abs(x - left - width) ? 'min' : 'max', view }; + } else if (x > left && x < left + width) { + drag.current = { kind: 'move', startX: event.clientX, view }; + } else { + drag.current = { kind: 'draw', anchor: toAxisX(event.clientX) }; + } + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const onPointerMove = (event: PointerEvent) => { + const current = drag.current; + if (!current) return; + const at = toAxisX(event.clientX); + switch (current.kind) { + case 'move': { + const shift = ((event.clientX - current.startX) / Math.max(1, trackWidth)) * span; + onRange(current.view.min + shift, current.view.max + shift); + break; + } + case 'min': + onRange(Math.min(at, current.view.max - 1), current.view.max); + break; + case 'max': + onRange(current.view.min, Math.max(at, current.view.min + 1)); + break; + case 'draw': + if (Math.abs(toPx(at) - toPx(current.anchor)) > 6) onRange(Math.min(at, current.anchor), Math.max(at, current.anchor)); + break; + } + }; + + const onPointerUp = (event: PointerEvent) => { + const current = drag.current; + drag.current = null; + // A tap outside the window recentres it there. + if (current?.kind === 'draw' && Math.abs(toPx(toAxisX(event.clientX)) - toPx(current.anchor)) <= 6) { + const half = (view.max - view.min) / 2; + onRange(current.anchor - half, current.anchor + half); + } + }; + + return ( +
+
+ @@ -79,7 +79,7 @@ function SessionSummary({ index, activeMs }: Readonly<{ index: ResourceTimelineI {nodes > 1 && ` on ${nodes} nodes`}

- Data until {formatDayTime(last)} · uploaded every {formatElapsed(index.uploadIntervalMs)} + Data until {formatDayTime(last)} · uploaded every {formatMinutes(index.uploadIntervalMs)} {highWater > 0 && ` · kernel memory peak ${formatBytes(highWater)}`}

{index.completeness.kind === 'truncated' && ( @@ -179,7 +179,7 @@ function TimelineBody({ source, index }: Readonly<{ source: ResourceHistorySourc /> { const { ctx, bbox } = u; + const px = uPlot.pxRatio; ctx.save(); - ctx.fillStyle = theme.sleep; + ctx.font = `${10 * px}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; for (const sleep of sleeps(axisRef.current)) { const x0 = u.valToPos(sleep.axisStart, 'x', true); const x1 = u.valToPos(sleep.axisEnd, 'x', true); - ctx.fillRect(x0, bbox.top, Math.max(1, x1 - x0), bbox.height); + ctx.fillStyle = theme.sleep; + ctx.fillRect(x0, bbox.top, Math.max(px, x1 - x0), bbox.height); + const label = formatCompactDuration(sleep.realEnd - sleep.realStart); + if (sleep.realEnd - sleep.realStart >= LABELLED_SLEEP_MS && x1 - x0 >= ctx.measureText(label).width + 4 * px) { + ctx.fillStyle = theme.mutedText; + ctx.fillText(label, (x0 + x1) / 2, bbox.top + 2 * px); + } } ctx.restore(); }, diff --git a/apps/web/src/components/chat/resource-timeline/TimelinePanels.tsx b/apps/web/src/components/chat/resource-timeline/TimelinePanels.tsx index a2616c292b..6dc69d8a6b 100644 --- a/apps/web/src/components/chat/resource-timeline/TimelinePanels.tsx +++ b/apps/web/src/components/chat/resource-timeline/TimelinePanels.tsx @@ -13,6 +13,8 @@ interface PanelSpec { title: string; height: number; value: (readout: Readout) => string; + /** Tool names can be long; this panel lets its value wrap instead of truncating. */ + wrapValue?: boolean; legend: (theme: ChartTheme, hasWorkingSet: boolean) => Array<{ color: string; label: string; dashed?: boolean }>; } @@ -33,7 +35,7 @@ const PANELS: PanelSpec[] = [ hasWorkingSet ? [ { color: theme.memory, label: 'used' }, - { color: theme.memory, label: 'incl. cache', dashed: true }, + { color: theme.memory, label: '+ cache', dashed: true }, ] : [{ color: theme.memory, label: 'incl. cache' }], }, @@ -52,6 +54,7 @@ const PANELS: PanelSpec[] = [ title: 'Tool calls', height: 48, value: (readout) => readout.tools, + wrapValue: true, legend: () => [], }, ]; @@ -84,7 +87,12 @@ export function TimelinePanels({ const plots = useRef(new Map>()); const stateRef = useRef(state); - const callbacksRef = useRef({ onCursor, onSelectRange: onRange, onResetZoom }); + /** True while a mouse (not a finger) is over the panels: only then does uPlot's hover drive the cursor. */ + const mouseOverRef = useRef(false); + const hover = (x: number | null) => { + if (mouseOverRef.current) onCursor(x); + }; + const callbacksRef = useRef({ onHover: hover, onSelectRange: onRange, onResetZoom }); const gestureRef = useRef({ plotRect: () => null, view: () => ({ min: state.viewMin, max: state.viewMax }), @@ -94,7 +102,7 @@ export function TimelinePanels({ }); useLayoutEffect(() => { stateRef.current = state; - callbacksRef.current = { onCursor, onSelectRange: onRange, onResetZoom }; + callbacksRef.current = { onHover: hover, onSelectRange: onRange, onResetZoom }; gestureRef.current = { plotRect: () => plots.current.get('cpu')?.current?.over.getBoundingClientRect() ?? null, view: (): ViewRange => ({ min: state.viewMin, max: state.viewMax }), @@ -160,6 +168,18 @@ export function TimelinePanels({ aria-valuenow={Math.round(cursorX ?? state.viewMin)} aria-valuetext={`${readout.time}. CPU ${readout.cpu}. Memory ${readout.memory}. Disk ${readout.disk}. ${readout.tools}.`} onKeyDown={onKeyDown} + onPointerEnter={(event) => { + if (event.pointerType === 'mouse') mouseOverRef.current = true; + }} + onPointerDown={(event) => { + // A finger takes over from any mouse left resting over the chart (touch laptops). + if (event.pointerType === 'touch') mouseOverRef.current = false; + }} + onPointerLeave={(event) => { + if (event.pointerType !== 'mouse') return; + mouseOverRef.current = false; + onCursor(null); + }} className="touch-pan-y select-none rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-focus-ring [&_.u-cursor-x]:border-r-fg-muted [&_.u-select]:bg-accent/15" > {PANELS.map((panel) => ( @@ -209,7 +229,7 @@ function Panel({ return (
-
+
{spec.title} {spec.legend(theme, state.hasWorkingSet).map((item) => ( @@ -223,7 +243,10 @@ function Panel({ ))} - + {value}
diff --git a/apps/web/src/components/chat/resource-timeline/format.ts b/apps/web/src/components/chat/resource-timeline/format.ts index 8c232f12d1..f64910eaa3 100644 --- a/apps/web/src/components/chat/resource-timeline/format.ts +++ b/apps/web/src/components/chat/resource-timeline/format.ts @@ -31,9 +31,10 @@ export function formatRate(bytesPerSecond: number | null | undefined): string { return `${formatFileSize(Math.round(bytesPerSecond))}/s`; } -/** Elapsed time with seconds precision below two minutes ("35s", "4m 10s", "2h 5m", "3d 4h"). */ +/** Elapsed time, as precise as its size warrants ("12ms", "35s", "4m 10s", "2h 5m", "3d 4h"). */ export function formatElapsed(ms: number): string { - const seconds = Math.max(0, Math.round(ms / 1000)); + if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`; + const seconds = Math.round(ms / 1000); if (seconds < 120) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 10) return seconds % 60 ? `${minutes}m ${seconds % 60}s` : `${minutes}m`; @@ -74,3 +75,21 @@ export function formatRange(start: number, end: number, precisionMs: number): st } return `${formatDayTime(start)} – ${formatDayTime(end)}`; } + +/** "mcp__sam-mcp__update_task_status" reads as "update_task_status (sam-mcp)"; built-in tools keep their name. */ +export function formatToolName(name: string | null): string { + if (!name) return 'Tool call'; + const [prefix, server, ...rest] = name.split('__'); + return prefix === 'mcp' && server && rest.length > 0 ? `${rest.join('__')} (${server})` : name; +} + +/** Tight label for small spaces: whole hours from an hour up ("9h"), else minutes ("37m"). */ +export function formatCompactDuration(ms: number): string { + return ms >= 3_600_000 ? `${Math.round(ms / 3_600_000)}h` : `${Math.max(1, Math.round(ms / 60_000))}m`; +} + +/** Whole minutes for running prose ("every 15 minutes"). */ +export function formatMinutes(ms: number): string { + const minutes = Math.max(1, Math.round(ms / 60_000)); + return `${minutes} minute${minutes === 1 ? '' : 's'}`; +} diff --git a/apps/web/src/components/chat/resource-timeline/panels.ts b/apps/web/src/components/chat/resource-timeline/panels.ts index fc107ac5ed..a2e30c1625 100644 --- a/apps/web/src/components/chat/resource-timeline/panels.ts +++ b/apps/web/src/components/chat/resource-timeline/panels.ts @@ -30,8 +30,12 @@ export interface PanelState { } export interface PanelCallbacks { - /** Axis position under the cursor, or null when the cursor leaves. */ - onCursor: (x: number | null) => void; + /** + * uPlot moved its cursor: the axis position under it, or null when it left the + * plot. uPlot also fires this on redraws with the old pixel position, so the + * receiver must only trust it while a mouse is actually hovering. + */ + onHover: (x: number | null) => void; /** A mouse drag selected `[min, max]` (axis units). */ onSelectRange: (min: number, max: number) => void; onResetZoom: () => void; @@ -40,7 +44,10 @@ export interface PanelCallbacks { /** Left gutter (y-axis labels) and right padding shared by every panel and the navigator. */ export const PLOT_LEFT_GUTTER_PX = 44; export const PLOT_RIGHT_PADDING_PX = 6; -const TICK_MIN_SPACING_PX = 64; +/** Clock labels ("09:55 PM") need about this much room each… */ +const TICK_MIN_SPACING_PX = 58; +/** …and half of it to the right of their tick, which the right edge cannot give. */ +const TICK_RIGHT_EDGE_CLEARANCE_PX = 22; const TOOL_LANE_ROWS = 3; function px(value: number): number { @@ -205,8 +212,14 @@ function xAxis(stateRef: RefObject, theme: ChartTheme, showLabels: b stepMs = tickStepMs((max - min) / Math.max(1, widthPx), TICK_MIN_SPACING_PX); return axisTicks(state.axis, min, max, stepMs).map((tick) => tick.x); }, - values: (_u, splits) => - splits.map((x) => (showLabels ? formatClock(toReal(stateRef.current.axis, x), stepMs) : '')), + values: (u, splits) => { + const widthPx = u.bbox.width / uPlot.pxRatio; + return splits.map((x) => + showLabels && u.valToPos(x, 'x') <= widthPx - TICK_RIGHT_EDGE_CLEARANCE_PX + ? formatClock(toReal(stateRef.current.axis, x), stepMs) + : '' + ); + }, }; } @@ -291,7 +304,7 @@ export function panelOptions( setCursor: [ (u) => { const left = u.cursor.left ?? -1; - callbacks.current.onCursor(left >= 0 ? u.posToVal(left, 'x') : null); + callbacks.current.onHover(left >= 0 ? u.posToVal(left, 'x') : null); }, ], draw: [ diff --git a/apps/web/src/components/chat/resource-timeline/readout.ts b/apps/web/src/components/chat/resource-timeline/readout.ts index f18603588e..da81d3ad17 100644 --- a/apps/web/src/components/chat/resource-timeline/readout.ts +++ b/apps/web/src/components/chat/resource-timeline/readout.ts @@ -11,9 +11,10 @@ import { formatElapsed, formatRange, formatRate, + formatToolName, } from './format'; import { summarizeUsage, type TimelineSeries } from './series'; -import { sleepAt, type TimeAxis, toReal } from './time-axis'; +import { activeMsInView, sleepAt, type TimeAxis, toReal } from './time-axis'; import type { ResourceAggregate, ResourceRun, ResourceToolSpan } from './types'; export interface Readout { @@ -56,10 +57,11 @@ function runLabel(runs: readonly ResourceRun[], t: number): string { return runs.length > 1 ? `Run ${index + 1} of ${runs.length}${node}${problem}` : `Workspace${node}${problem}`; } +/** Kept short enough to sit beside the panel's title and legend on a phone. */ function memoryText(used: number | null, total: number | null): string { - if (used == null) return total == null ? DASH : `${formatBytes(total)} (incl. cache)`; + if (used == null) return total == null ? DASH : `${formatBytes(total)} incl. cache`; const cache = total == null ? null : Math.max(0, total - used); - return cache == null ? `${formatBytes(used)} used` : `${formatBytes(used)} used + ${formatBytes(cache)} cache`; + return cache == null ? `${formatBytes(used)} used` : `${formatBytes(used)} + ${formatBytes(cache)} cache`; } function activeToolsText(spans: readonly ResourceToolSpan[], from: number, to: number, instant: number): string | null { @@ -68,7 +70,7 @@ function activeToolsText(spans: readonly ResourceToolSpan[], from: number, to: n if (inWindow.length === 0) return null; const longest = [...inWindow].sort((a, b) => b.endedAt - b.startedAt - (a.endedAt - a.startedAt))[0]; if (!longest) return null; - const label = longest.name ?? 'tool call'; + const label = formatToolName(longest.name); const more = inWindow.length > 1 ? ` +${inWindow.length - 1} more` : ''; const verb = running.length ? 'running' : 'ran'; return `${label} ${verb} ${formatElapsed(longest.endedAt - longest.startedAt)}${more}`; @@ -110,7 +112,8 @@ export function readoutAtCursor( return { mode: 'cursor', - time: measured ? formatClock(t, 1_000) : `${formatClock(from, series.bucketMs)} · ${formatElapsed(to - from)} avg`, + // The instant under the cursor stays put while zooming; the averaging window says how wide the reading is. + time: measured ? formatClock(t, 1_000) : `${formatClock(t, series.bucketMs)} · ${formatElapsed(to - from)} avg`, context: runLabel(runs, t), cpu: measured || cpuMax == null ? formatCores(cpuMean) : `${formatCores(cpuMean)} · peak ${formatCores(cpuMax)}`, memory: memoryText(used, total), @@ -133,17 +136,20 @@ export function readoutForRange( const usage = summarizeUsage(aggregates, from, to); const whole = viewMin <= axis.min && viewMax >= axis.max; const scope = whole ? 'Whole session' : formatRange(from, to, viewMax - viewMin < 30 * 60_000 ? 1_000 : 60_000); + const activeTotal = formatElapsed(axis.activeMs); return { mode: 'range', time: scope, - context: `${formatElapsed(usage.coveredMs)} of ${formatElapsed(axis.activeMs)} active time in view`, + context: whole + ? `All ${activeTotal} of active time` + : `${formatElapsed(activeMsInView(axis, viewMin, viewMax))} of ${activeTotal} active time in view`, cpu: usage.cpuMaxCores == null ? DASH : `avg ${formatCores(usage.cpuMeanCores)} · peak ${formatCores(usage.cpuMaxCores)}`, memory: usage.workingSetMaxBytes != null - ? `peak ${formatBytes(usage.workingSetMaxBytes)} used · ${formatBytes(usage.memoryMaxBytes)} incl. cache` + ? `peak ${formatBytes(usage.workingSetMaxBytes)} (${formatBytes(usage.memoryMaxBytes)} w/ cache)` : usage.memoryMaxBytes == null ? DASH - : `peak ${formatBytes(usage.memoryMaxBytes)} (incl. cache)`, + : `peak ${formatBytes(usage.memoryMaxBytes)} incl. cache`, disk: `${formatBytes(usage.ioWriteBytes)} written · ${formatBytes(usage.ioReadBytes)} read`, tools: `${usage.toolCallStarts} tool call${usage.toolCallStarts === 1 ? '' : 's'}`, oomKills: usage.oomKills, diff --git a/apps/web/src/components/chat/resource-timeline/time-axis.ts b/apps/web/src/components/chat/resource-timeline/time-axis.ts index de2cf94652..e48c13815c 100644 --- a/apps/web/src/components/chat/resource-timeline/time-axis.ts +++ b/apps/web/src/components/chat/resource-timeline/time-axis.ts @@ -3,9 +3,10 @@ * * A session is a sequence of runs separated by sleeps. Drawn on a plain clock axis, * a session that worked for an hour, slept overnight and worked another hour is two - * slivers either side of a void. In `active` mode each sleep longer than the break - * width is compressed to that width and drawn as an explicit, labelled break, so - * the time that has data gets the pixels. `clock` mode keeps real proportions. + * slivers either side of a void. In `active` mode each sleep is compressed to a + * width that grows with the logarithm of its length and drawn as an explicit, + * labelled break, so the time that has data gets the pixels while a night still + * reads longer than a short pause. `clock` mode keeps real proportions. * * Positions on the axis are "axis milliseconds": inside a run one axis millisecond * is one real millisecond, so durations read off the axis stay true. @@ -33,11 +34,15 @@ export interface TimeAxis { activeMs: number; } -/** A compressed sleep takes this fraction of the session's active time… */ -const SLEEP_BREAK_FRACTION = 0.025; -/** …bounded so it stays visible on short sessions and modest on long ones. */ -const SLEEP_BREAK_MIN_MS = 15_000; -const SLEEP_BREAK_MAX_MS = 10 * 60_000; +/** + * A compressed sleep's width is one "break unit" per doubling of its length, so a + * night reads visibly longer than a coffee break without either swamping the runs. + * The unit is a small fraction of the session's active time… + */ +const SLEEP_BREAK_UNIT_FRACTION = 0.0025; +/** …bounded so breaks stay visible on short sessions and modest on long ones. */ +const SLEEP_BREAK_UNIT_MIN_MS = 5_000; +const SLEEP_BREAK_UNIT_MAX_MS = 5 * 60_000; /** Merges overlapping or touching runs into disjoint active intervals. */ function activeIntervals(runs: readonly ResourceRun[]): Array<[number, number]> { @@ -54,14 +59,19 @@ function activeIntervals(runs: readonly ResourceRun[]): Array<[number, number]> return merged; } -export function sleepBreakWidthMs(activeMs: number): number { - return Math.min(SLEEP_BREAK_MAX_MS, Math.max(SLEEP_BREAK_MIN_MS, activeMs * SLEEP_BREAK_FRACTION)); +/** Axis width of a compressed sleep; a sleep never gets wider than it really was. */ +export function sleepBreakWidthMs(activeMs: number, sleepMs: number): number { + const unit = Math.min( + SLEEP_BREAK_UNIT_MAX_MS, + Math.max(SLEEP_BREAK_UNIT_MIN_MS, activeMs * SLEEP_BREAK_UNIT_FRACTION) + ); + const doublings = Math.log2(Math.max(1, sleepMs / 60_000)); + return Math.min(sleepMs, unit * (1 + doublings)); } export function buildTimeAxis(runs: readonly ResourceRun[], mode: TimeAxisMode): TimeAxis { const intervals = activeIntervals(runs); const activeMs = intervals.reduce((sum, [start, end]) => sum + (end - start), 0); - const breakWidth = sleepBreakWidthMs(activeMs); const spans: AxisSpan[] = []; let cursor = intervals[0]?.[0] ?? 0; @@ -69,7 +79,7 @@ export function buildTimeAxis(runs: readonly ResourceRun[], mode: TimeAxisMode): const previous = spans.at(-1); if (previous) { const sleepMs = start - previous.realEnd; - const width = mode === 'active' ? Math.min(sleepMs, breakWidth) : sleepMs; + const width = mode === 'active' ? sleepBreakWidthMs(activeMs, sleepMs) : sleepMs; spans.push({ kind: 'sleep', realStart: previous.realEnd, @@ -129,14 +139,24 @@ export function sleepAt(axis: TimeAxis, x: number): AxisSpan | null { return span?.kind === 'sleep' && x >= span.axisStart && x < span.axisEnd ? span : null; } +/** Real milliseconds of run time inside `[viewMin, viewMax]` (axis units map 1:1 inside runs). */ +export function activeMsInView(axis: TimeAxis, viewMin: number, viewMax: number): number { + return axis.spans + .filter((span) => span.kind === 'active') + .reduce((sum, span) => sum + Math.max(0, Math.min(span.axisEnd, viewMax) - Math.max(span.axisStart, viewMin)), 0); +} + /** Axis spans (compressed breaks included) that are sleeps. */ export function sleeps(axis: TimeAxis): AxisSpan[] { return axis.spans.filter((span) => span.kind === 'sleep'); } +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; const TICK_STEPS_MS = [ - 1_000, 5_000, 15_000, 30_000, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 3_600_000, - 3 * 3_600_000, 6 * 3_600_000, 12 * 3_600_000, 86_400_000, + 1_000, 5_000, 15_000, 30_000, MINUTE_MS, 2 * MINUTE_MS, 5 * MINUTE_MS, 10 * MINUTE_MS, + 15 * MINUTE_MS, 30 * MINUTE_MS, HOUR_MS, 2 * HOUR_MS, 3 * HOUR_MS, 6 * HOUR_MS, 12 * HOUR_MS, + 24 * HOUR_MS, ]; /** The finest tick step that keeps labels at least `minSpacingPx` apart. */ @@ -148,7 +168,9 @@ export function tickStepMs(msPerPx: number, minSpacingPx: number): number { /** * Tick positions (axis units) for the visible window, aligned to round wall-clock * times in the viewer's zone. Compressed sleeps get no ticks: their scale differs - * from the runs around them, so a tick inside one would lie about the spacing. + * from the runs around them, so a tick inside one would lie about the spacing. For + * the same reason, ticks either side of a break can land close together; the later + * one is dropped when it would sit closer than most of a step to its neighbour. */ export function axisTicks( axis: TimeAxis, @@ -167,7 +189,7 @@ export function axisTicks( for (let t = first; t <= to; t += stepMs) { const x = toAxis(axis, t); const last = ticks.at(-1); - if (!last || x > last.x) ticks.push({ x, t }); + if (!last || x - last.x >= stepMs * 0.75) ticks.push({ x, t }); } } return ticks; diff --git a/apps/web/src/components/chat/resource-timeline/useResourceTimelineData.ts b/apps/web/src/components/chat/resource-timeline/useResourceTimelineData.ts index b9fc94e0ac..d97cc7dccc 100644 --- a/apps/web/src/components/chat/resource-timeline/useResourceTimelineData.ts +++ b/apps/web/src/components/chat/resource-timeline/useResourceTimelineData.ts @@ -22,7 +22,7 @@ const CHUNK_GC_TIME_MS = 30 * 60_000; */ const MAX_DETAIL_CHUNKS = 32; -export interface TimelineWindow { +export interface TimelineViewport { /** Visible range, axis units. */ min: number; max: number; @@ -35,8 +35,10 @@ export interface ResourceTimelineData { axis: TimeAxis; /** Best available data for the window: raw samples where downloaded, overview elsewhere. */ aggregates: ResourceAggregate[]; - /** Whole-session overview, for the navigator, peaks and session totals. */ + /** Whole-session overview (stable while zooming), for peaks and session totals. */ overview: ResourceAggregate[]; + /** The whole session at the best detail loaded so far: raw samples where fetched, overview elsewhere. */ + sessionAggregates: ResourceAggregate[]; /** Tool spans from downloaded chunks overlapping the window. */ toolSpans: ResourceToolSpan[]; /** Chunks the window needs that are still downloading. */ @@ -99,11 +101,11 @@ export function useResourceTimelineData( source: ResourceHistorySource, index: ResourceTimelineIndex, axis: TimeAxis, - window: TimelineWindow + viewport: TimelineViewport ): ResourceTimelineData { - const from = toReal(axis, window.min); - const to = toReal(axis, window.max); - const msPerPx = (window.max - window.min) / Math.max(1, window.widthPx); + const from = toReal(axis, viewport.min); + const to = toReal(axis, viewport.max); + const msPerPx = (viewport.max - viewport.min) / Math.max(1, viewport.widthPx); const visibleChunks = useMemo(() => chunksAround(index.chunks, from, to), [index.chunks, from, to]); const wantDetail = @@ -135,11 +137,17 @@ export function useResourceTimelineData( return { aggregates: merged, toolSpans: spans }; }, [visibleChunks, details]); + const sessionAggregates = useMemo(() => { + const loaded = new Map(visibleChunks.map((chunk, position) => [chunk.id, details[position]])); + return index.chunks.flatMap((chunk) => loaded.get(chunk.id)?.samples ?? chunk.overview); + }, [index.chunks, visibleChunks, details]); + return { index, axis, aggregates, overview, + sessionAggregates, toolSpans, pendingChunks: pending, failedChunks: failed, diff --git a/apps/web/src/pages/resource-timeline-prototype/mock-sessions.ts b/apps/web/src/pages/resource-timeline-prototype/mock-sessions.ts index c6ddfbf048..ef2134ba35 100644 --- a/apps/web/src/pages/resource-timeline-prototype/mock-sessions.ts +++ b/apps/web/src/pages/resource-timeline-prototype/mock-sessions.ts @@ -32,12 +32,16 @@ interface RunPlan { oomAtMin?: number; } +/** A coordinator mostly waits and dispatches; a builder compiles and tests all day. */ +type Workload = 'coordinator' | 'builder'; + export interface ScenarioPlan { id: string; label: string; description: string; startedAt: number; seed: number; + workload: Workload; runs: RunPlan[]; reservation: { cpuCores: number; memoryBytes: number } | null; /** VM agents that predate working-set memory and tool labels report neither. */ @@ -75,19 +79,30 @@ interface Phase { memoryLift: number; } -const PHASE_WEIGHTS: Array<[PhaseKind, number]> = [ - ['idle', 3], - ['agent', 5], - ['build', 2], - ['install', 1], - ['test', 1.5], - ['fetch', 1], -]; +const PHASE_WEIGHTS: Record> = { + coordinator: [ + ['idle', 5], + ['agent', 6], + ['build', 0.7], + ['install', 0.3], + ['test', 0.5], + ['fetch', 1.5], + ], + builder: [ + ['idle', 3], + ['agent', 5], + ['build', 2], + ['install', 1], + ['test', 1.5], + ['fetch', 1], + ], +}; -function pickPhase(random: () => number): PhaseKind { - const total = PHASE_WEIGHTS.reduce((sum, [, weight]) => sum + weight, 0); +function pickPhase(random: () => number, workload: Workload): PhaseKind { + const weights = PHASE_WEIGHTS[workload]; + const total = weights.reduce((sum, [, weight]) => sum + weight, 0); let roll = random() * total; - for (const [kind, weight] of PHASE_WEIGHTS) { + for (const [kind, weight] of weights) { roll -= weight; if (roll <= 0) return kind; } @@ -111,12 +126,12 @@ function phaseShape(kind: PhaseKind, random: () => number): Omit number): Phase[] { +function planPhases(start: number, end: number, random: () => number, workload: Workload): Phase[] { const phases: Phase[] = []; let cursor = start; let previous: PhaseKind = 'idle'; while (cursor < end) { - let kind = pickPhase(random); + let kind = pickPhase(random, workload); if (kind === previous && kind !== 'agent') kind = 'agent'; const shape = phaseShape(kind, random); const phaseEnd = Math.min(end, cursor + shape.minutes * MINUTE); @@ -197,7 +212,7 @@ export function generateRun(plan: RunPlan, scenario: ScenarioPlan, index: number }; if (plan.unsupported) return { run, samples: [], toolSpans: [], samplerGaps: [], memoryHighWater: [] }; - const phases = planPhases(startedAt, endedAt, random); + const phases = planPhases(startedAt, endedAt, random, scenario.workload); let spanCounter = 0; const nextId = () => `span-${scenario.id}-${index}-${(spanCounter += 1)}`; const toolSpans = phases.flatMap((phase) => @@ -309,6 +324,7 @@ id: 'overnight', description: 'Modelled on session 449d73f8: many wake cycles on three nodes, a 9-hour overnight sleep, an OOM kill and a wake whose container never started.', startedAt: EVENING, seed: 7, + workload: 'coordinator', reservation: { cpuCores: 2, memoryBytes: 4 * GB }, agentReportsWorkingSet: true, agentReportsToolNames: true, @@ -334,6 +350,7 @@ export const SCENARIOS: ScenarioPlan[] = [ description: 'Modelled on session 98ac3e40: a single four-hour run with frequent builds and hundreds of tool calls.', startedAt: Date.parse('2026-09-28T09:05:00'), seed: 21, + workload: 'builder', reservation: { cpuCores: 4, memoryBytes: 8 * GB }, agentReportsWorkingSet: true, agentReportsToolNames: true, @@ -345,6 +362,7 @@ export const SCENARIOS: ScenarioPlan[] = [ description: 'One run shorter than a single upload interval: one partial chunk.', startedAt: Date.parse('2026-09-29T07:12:00'), seed: 3, + workload: 'builder', reservation: { cpuCores: 2, memoryBytes: 4 * GB }, agentReportsWorkingSet: true, agentReportsToolNames: true, @@ -356,6 +374,7 @@ export const SCENARIOS: ScenarioPlan[] = [ description: 'The overnight session as reported by agents that predate tool labels and working-set memory.', startedAt: EVENING, seed: 7, + workload: 'coordinator', reservation: null, agentReportsWorkingSet: false, agentReportsToolNames: false, @@ -371,6 +390,7 @@ export const SCENARIOS: ScenarioPlan[] = [ description: 'Samples upload every 15 minutes; a new session has none yet.', startedAt: Date.parse('2026-09-29T07:40:00'), seed: 1, + workload: 'coordinator', reservation: null, agentReportsWorkingSet: true, agentReportsToolNames: true, diff --git a/apps/web/src/pages/resource-timeline-prototype/mock-source.ts b/apps/web/src/pages/resource-timeline-prototype/mock-source.ts index 89fcc671ba..02e8363b1d 100644 --- a/apps/web/src/pages/resource-timeline-prototype/mock-source.ts +++ b/apps/web/src/pages/resource-timeline-prototype/mock-source.ts @@ -88,6 +88,24 @@ function rollup( return buckets; } +/** Each run trimmed to the chunks that were actually returned for it. */ +function runsCoveredBy( + runs: ResourceTimelineIndex['runs'], + chunks: readonly ResourceChunkRef[] +): ResourceTimelineIndex['runs'] { + return runs.flatMap((run) => { + const own = chunks.filter((chunk) => chunk.runId === run.id); + if (own.length === 0) return []; + return [ + { + ...run, + startedAt: Math.min(...own.map((chunk) => chunk.startedAt)), + endedAt: Math.max(...own.map((chunk) => chunk.endedAt)), + }, + ]; + }); +} + interface BuiltScenario { plan: ScenarioPlan; refs: ResourceChunkRef[]; @@ -140,9 +158,9 @@ export function createMockSource( const scenario = buildScenario(plan, backend); const truncated = backend === 'current' && scenario.refs.length > CURRENT_API_CHUNK_LIMIT; const chunks = truncated ? scenario.refs.slice(-CURRENT_API_CHUNK_LIMIT) : scenario.refs; - const listedRuns = new Set(chunks.map((chunk) => chunk.runId)); return { - runs: scenario.runs.filter((run) => backend === 'proposed' || listedRuns.has(run.id)), + // Like the real adapter, the current API only knows the runs its returned chunks cover. + runs: backend === 'proposed' ? scenario.runs : runsCoveredBy(scenario.runs, chunks), chunks, sampleIntervalMs: SAMPLE_INTERVAL_MS, uploadIntervalMs: CHUNK_INTERVAL_MS, From 5432deea008148b48ed398ccf5857bb63c337148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 07:34:36 +0000 Subject: [PATCH 03/22] test(web): cover the resource timeline data layer Unit tests for the pure modules behind the prototype timeline: time-axis compression, round-trips and tick placement; level-of-detail bucketing (spike preservation, time-weighted means, gaps, disk rates, OOM counted once); summaries and peaks; the adapter over today's API (cores from CPU-ms, truncation detection, tool labels); view intents across axis modes; and readout wording at each zoom level. Co-Authored-By: Claude Opus 5.5 --- .../resource-timeline/readout.test.ts | 123 ++++++++++++++ .../resource-timeline/resource-source.test.ts | 154 ++++++++++++++++++ .../resource-timeline/series.test.ts | 146 +++++++++++++++++ .../resource-timeline/time-axis.test.ts | 118 ++++++++++++++ .../resource-timeline/timeline-view.test.ts | 61 +++++++ 5 files changed, 602 insertions(+) create mode 100644 apps/web/tests/unit/components/resource-timeline/readout.test.ts create mode 100644 apps/web/tests/unit/components/resource-timeline/resource-source.test.ts create mode 100644 apps/web/tests/unit/components/resource-timeline/series.test.ts create mode 100644 apps/web/tests/unit/components/resource-timeline/time-axis.test.ts create mode 100644 apps/web/tests/unit/components/resource-timeline/timeline-view.test.ts diff --git a/apps/web/tests/unit/components/resource-timeline/readout.test.ts b/apps/web/tests/unit/components/resource-timeline/readout.test.ts new file mode 100644 index 0000000000..53d340ac8d --- /dev/null +++ b/apps/web/tests/unit/components/resource-timeline/readout.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { formatToolName } from '../../../../src/components/chat/resource-timeline/format'; +import { + nearestBucket, + readoutAtCursor, + readoutForRange, +} from '../../../../src/components/chat/resource-timeline/readout'; +import { buildSeries } from '../../../../src/components/chat/resource-timeline/series'; +import { buildTimeAxis, toAxis } from '../../../../src/components/chat/resource-timeline/time-axis'; +import type { + ResourceAggregate, + ResourceRun, + ResourceToolSpan, +} from '../../../../src/components/chat/resource-timeline/types'; + +const SECOND = 1_000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const GB = 1024 ** 3; +const T0 = new Date(2026, 8, 28, 19, 0, 0).getTime(); + +function run(id: string, startedAt: number, endedAt: number): ResourceRun { + return { id, nodeId: 'node-a', startedAt, endedAt, unsupportedReason: null }; +} + +function sample(end: number, cores: number): ResourceAggregate { + return { + start: end - 5 * SECOND, + end, + cpuMeanCores: cores, + cpuMaxCores: cores, + memoryMeanBytes: 3 * GB, + memoryMaxBytes: 3 * GB, + workingSetMeanBytes: 2 * GB, + workingSetMaxBytes: 2 * GB, + ioReadBytes: 0, + ioWriteBytes: 5 * 1024 ** 2, + oomKills: 0, + toolCallStarts: 0, + exact: true, + }; +} + +const RUNS = [run('ws-1', T0, T0 + HOUR), run('ws-2', T0 + 10 * HOUR, T0 + 11 * HOUR)]; +const SAMPLES = Array.from({ length: 720 }, (_, i) => sample(T0 + (i + 1) * 5 * SECOND, i === 100 ? 3 : 0.5)); +const BASH: ResourceToolSpan = { + id: 'bash', + kind: 'execute', + name: 'Bash', + startedAt: T0 + 8 * MINUTE, + endedAt: T0 + 8 * MINUTE + 35 * SECOND, + approximateEnd: false, +}; + +describe('nearestBucket', () => { + it('picks the bucket whose centre is closest', () => { + const series = { x: [10, 20, 30] } as Parameters[0]; + expect(nearestBucket(series, 24)).toBe(1); + expect(nearestBucket(series, 26)).toBe(2); + expect(nearestBucket(series, -5)).toBe(0); + expect(nearestBucket(series, 99)).toBe(2); + }); +}); + +describe('readoutAtCursor', () => { + const axis = buildTimeAxis(RUNS, 'active'); + + it('reads one 5-second measurement to the second when zoomed all the way in', () => { + const view = { min: toAxis(axis, T0 + 8 * MINUTE), max: toAxis(axis, T0 + 9 * MINUTE) }; + const series = buildSeries(SAMPLES, axis, view.min, view.max, 400, 5 * SECOND); + const at = toAxis(axis, T0 + 8 * MINUTE + 12 * SECOND); + + const readout = readoutAtCursor(at, series, axis, RUNS, [BASH], 5 * SECOND); + + expect(readout.time).not.toContain('avg'); + expect(readout.time).toMatch(/:\d{2}:\d{2}/); + expect(readout.context).toBe('Run 1 of 2 · node-a'); + expect(readout.cpu).toBe('0.50 cores'); + expect(readout.memory).toBe('2.0 GB + 1.0 GB cache'); + expect(readout.tools).toBe('Bash running 35s'); + }); + + it('labels a zoomed-out reading as an average and shows its peak', () => { + const series = buildSeries(SAMPLES, axis, axis.min, axis.max, 30, 5 * SECOND); + const at = toAxis(axis, T0 + 100 * 5 * SECOND); + + const readout = readoutAtCursor(at, series, axis, RUNS, [], 5 * SECOND); + + expect(readout.time).toContain('avg'); + expect(readout.cpu).toContain('peak 3.00 cores'); + }); + + it('says the session was asleep, and for how long, between runs', () => { + const series = buildSeries(SAMPLES, axis, axis.min, axis.max, 30, 5 * SECOND); + const sleep = axis.spans[1]; + const middle = ((sleep?.axisStart ?? 0) + (sleep?.axisEnd ?? 0)) / 2; + + const readout = readoutAtCursor(middle, series, axis, RUNS, [], 5 * SECOND); + + expect(readout.context).toBe('Session asleep for 9h — no workspace was running'); + expect(readout.cpu).toBe('—'); + }); +}); + +describe('readoutForRange', () => { + it('describes the whole session without implying data is missing', () => { + const axis = buildTimeAxis(RUNS, 'active'); + const readout = readoutForRange(axis.min, axis.max, axis, SAMPLES); + + expect(readout.time).toBe('Whole session'); + expect(readout.context).toBe('All 2h of active time'); + expect(readout.memory).toBe('peak 2.0 GB (3.0 GB w/ cache)'); + }); +}); + +describe('formatToolName', () => { + it('names MCP tools by tool, then server', () => { + expect(formatToolName('mcp__sam-mcp__update_task_status')).toBe('update_task_status (sam-mcp)'); + expect(formatToolName('Bash')).toBe('Bash'); + expect(formatToolName(null)).toBe('Tool call'); + }); +}); diff --git a/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts b/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts new file mode 100644 index 0000000000..70e49aafea --- /dev/null +++ b/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; + +import { + chunkDetailFromApi, + indexFromApi, + sampleToAggregate, + withToolStarts, +} from '../../../../src/components/chat/resource-timeline/resource-source'; +import type { ResourceToolSpan } from '../../../../src/components/chat/resource-timeline/types'; +import type { + WorkspaceResourceChunk, + WorkspaceResourceHistoryResponse, + WorkspaceResourceSummary, +} from '../../../../src/lib/api'; + +const T0 = Date.UTC(2026, 8, 28, 19, 0, 0); +const MINUTE = 60_000; + +function chunk(id: string, workspaceId: string, startedAt: number, overrides: Partial = {}): WorkspaceResourceChunk { + return { + id, + workspaceId, + sessionId: 'session-1', + taskId: null, + nodeId: `node-of-${workspaceId}`, + chunkSequence: 1, + sourceVersion: 1, + storageFormat: 'resource-history-gzip-json-v1', + compressedBytes: 1000, + uncompressedBytes: 9000, + sha256: 'abc', + startedAt, + endedAt: startedAt + 15 * MINUTE, + sampleCount: 180, + gapCount: 0, + toolSpanCount: 12, + completeness: { status: 'complete', unsupported: '' }, + summary: { + sampleIntervalMillis: 5000, + cpuMeanMillis: 2500, + cpuPeakMillis: 15000, + memoryMeanBytes: 1e9, + memoryPeakBytes: 2e9, + memoryKernelPeakBytes: 2.2e9, + ioReadBytes: 10, + ioWriteBytes: 20, + oomCount: 0, + }, + expiresAt: startedAt + 90 * 24 * 60 * MINUTE, + ...overrides, + }; +} + +function response(chunks: WorkspaceResourceChunk[], firstChunkId: string | null): WorkspaceResourceHistoryResponse { + const summary = { firstChunkId, summary: { sampleIntervalMillis: 5000 } } as unknown as WorkspaceResourceSummary; + return { summary, chunks }; +} + +describe('sampleToAggregate', () => { + it('turns CPU milliseconds per interval into cores and keeps disk bytes', () => { + const aggregate = sampleToAggregate({ t: T0, intervalMillis: 5000, cpuMillis: 2500, memoryBytes: 1e9, ioReadBytes: 7, ioWriteBytes: 9 }); + + expect(aggregate).toMatchObject({ start: T0 - 5000, end: T0, cpuMeanCores: 0.5, cpuMaxCores: 0.5, ioReadBytes: 7, ioWriteBytes: 9, exact: true }); + }); + + it('has no CPU or disk reading for the first sample of a run, which has no interval yet', () => { + const aggregate = sampleToAggregate({ t: T0, memoryBytes: 1e9 }); + + expect(aggregate.cpuMeanCores).toBeNull(); + expect(aggregate.ioReadBytes).toBeNull(); + expect(aggregate.memoryMeanBytes).toBe(1e9); + }); + + it('drops the memory reading of a sample the collector could not observe', () => { + const aggregate = sampleToAggregate({ t: T0, intervalMillis: 5000, memoryBytes: 0, unsupported: 'no running devcontainer found' }); + expect(aggregate.memoryMeanBytes).toBeNull(); + expect(aggregate.cpuMeanCores).toBeNull(); + }); +}); + +describe('withToolStarts', () => { + const samples = [0, 1, 2].map((i) => sampleToAggregate({ t: T0 + (i + 1) * 5000, intervalMillis: 5000, cpuMillis: 0 })); + const span = (startedAt: number): ResourceToolSpan => ({ id: `s${startedAt}`, kind: 'read', name: 'Read', startedAt, endedAt: startedAt + 10, approximateEnd: false }); + + it('counts each call on the sample whose interval saw it start', () => { + const counted = withToolStarts(samples, [span(T0 + 1000), span(T0 + 6000), span(T0 + 7000)]); + expect(counted.map((sample) => sample.toolCallStarts)).toEqual([1, 2, 0]); + }); + + it('counts a call that started before the chunk on its first sample', () => { + const counted = withToolStarts(samples, [span(T0 - 60_000)]); + expect(counted.map((sample) => sample.toolCallStarts)).toEqual([1, 0, 0]); + }); +}); + +describe('indexFromApi', () => { + it('orders chunks oldest first and groups them into one run per workspace', () => { + const index = indexFromApi( + response([chunk('c3', 'ws-2', T0 + 60 * MINUTE), chunk('c2', 'ws-1', T0 + 15 * MINUTE), chunk('c1', 'ws-1', T0)], 'c3') + ); + + expect(index.chunks.map((ref) => ref.id)).toEqual(['c1', 'c2', 'c3']); + expect(index.runs).toEqual([ + { id: 'ws-1', nodeId: 'node-of-ws-1', startedAt: T0, endedAt: T0 + 30 * MINUTE, unsupportedReason: null }, + { id: 'ws-2', nodeId: 'node-of-ws-2', startedAt: T0 + 60 * MINUTE, endedAt: T0 + 75 * MINUTE, unsupportedReason: null }, + ]); + }); + + it('reads the per-chunk summary as one coarse aggregate in cores', () => { + const index = indexFromApi(response([chunk('c1', 'ws-1', T0)], 'c1')); + const [overview] = index.chunks[0]?.overview ?? []; + + expect(overview).toMatchObject({ cpuMeanCores: 0.5, cpuMaxCores: 3, memoryMaxBytes: 2e9, toolCallStarts: 12, exact: false }); + expect(index.chunks[0]?.memoryHighWaterBytes).toBe(2.2e9); + }); + + it('reports truncated history when the newest workspace lost its own first chunk from the page', () => { + const truncated = indexFromApi(response([chunk('c9', 'ws-1', T0)], 'c1')); + const complete = indexFromApi(response([chunk('c1', 'ws-1', T0)], 'c1')); + + expect(truncated.completeness).toEqual({ kind: 'truncated', omitted: null }); + expect(complete.completeness).toEqual({ kind: 'complete' }); + }); + + it('carries the reason a run could not be observed', () => { + const index = indexFromApi( + response([chunk('c1', 'ws-1', T0, { completeness: { status: 'partial', unsupported: 'no running devcontainer found' } })], 'c1') + ); + expect(index.runs[0]?.unsupportedReason).toBe('no running devcontainer found'); + }); +}); + +describe('chunkDetailFromApi', () => { + it('maps tool labels when present and falls back for spans from older agents', () => { + // Newer VM agents add `toolName`; the web type predates it, which is why the adapter reads it defensively. + const labelled = { id: 'b', kind: 'execute', startedAt: T0 + 3000, endedAt: T0 + 4000, toolName: 'Bash' }; + const detail = chunkDetailFromApi('c1', { + chunkId: 'c1', + samples: [{ t: T0 + 5000, intervalMillis: 5000, cpuMillis: 5000 }], + toolSpans: [{ id: 'a', kind: 'acp_tool_call', startedAt: T0 + 1000, endedAt: T0 + 2000 }, labelled], + gaps: [{ startedAt: T0, endedAt: T0 + 40_000, reason: 'sampler_delay' }], + originalSampleCount: 1, + downsampled: false, + downsampleLimit: 720, + }); + + expect(detail.toolSpans.map((span) => [span.kind, span.name])).toEqual([ + ['other', null], + ['execute', 'Bash'], + ]); + expect(detail.samples[0]?.toolCallStarts).toBe(2); + expect(detail.samplerGaps).toEqual([{ start: T0, end: T0 + 40_000 }]); + }); +}); diff --git a/apps/web/tests/unit/components/resource-timeline/series.test.ts b/apps/web/tests/unit/components/resource-timeline/series.test.ts new file mode 100644 index 0000000000..9242157872 --- /dev/null +++ b/apps/web/tests/unit/components/resource-timeline/series.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSeries, findPeaks, summarizeUsage } from '../../../../src/components/chat/resource-timeline/series'; +import { buildTimeAxis } from '../../../../src/components/chat/resource-timeline/time-axis'; +import type { ResourceAggregate, ResourceRun } from '../../../../src/components/chat/resource-timeline/types'; + +const SECOND = 1_000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const MB = 1024 ** 2; +const T0 = Date.UTC(2026, 8, 28, 19, 0, 0); + +function aggregate(start: number, end: number, overrides: Partial = {}): ResourceAggregate { + return { + start, + end, + cpuMeanCores: 0.1, + cpuMaxCores: 0.1, + memoryMeanBytes: 1000 * MB, + memoryMaxBytes: 1000 * MB, + workingSetMeanBytes: null, + workingSetMaxBytes: null, + ioReadBytes: 0, + ioWriteBytes: 0, + oomKills: 0, + toolCallStarts: 0, + exact: false, + ...overrides, + }; +} + +/** Raw 5-second samples of steady load, as a downloaded chunk would give them. */ +function samples(from: number, count: number, cores = 0.1): ResourceAggregate[] { + return Array.from({ length: count }, (_, i) => + aggregate(from + i * 5 * SECOND, from + (i + 1) * 5 * SECOND, { cpuMeanCores: cores, cpuMaxCores: cores, exact: true }) + ); +} + +function run(startedAt: number, endedAt: number): ResourceRun { + return { id: `run-${startedAt}`, nodeId: null, startedAt, endedAt, unsupportedReason: null }; +} + +describe('buildSeries', () => { + const hour = buildTimeAxis([run(T0, T0 + HOUR)], 'active'); + + it('keeps a single-sample spike visible when many samples share a bucket', () => { + const data = samples(T0, 720); + data[300] = { ...(data[300] as ResourceAggregate), cpuMeanCores: 3.9, cpuMaxCores: 3.9 }; + + const series = buildSeries(data, hour, hour.min, hour.max, 60, 5 * SECOND); + + expect(series.x).toHaveLength(60); + const peaks = series.cpuMax.filter((value) => value === 3.9); + expect(peaks).toHaveLength(1); + const spikeBucket = series.cpuMax.indexOf(3.9); + expect(series.cpuMean[spikeBucket]).toBeCloseTo((11 * 0.1 + 3.9) / 12, 6); + expect(series.cpuMean[0]).toBeCloseTo(0.1, 6); + expect(series.exact.every(Boolean)).toBe(true); + }); + + it('time-weights coarse aggregates that only partly overlap a bucket', () => { + const data = [ + aggregate(T0, T0 + MINUTE, { cpuMeanCores: 1, cpuMaxCores: 1 }), + aggregate(T0 + MINUTE, T0 + 2 * MINUTE, { cpuMeanCores: 3, cpuMaxCores: 5 }), + ]; + const axis = buildTimeAxis([run(T0, T0 + 2 * MINUTE)], 'active'); + + // One bucket straddling the boundary: half of each minute. + const series = buildSeries(data, axis, axis.min + 30 * SECOND, axis.min + 90 * SECOND, 1, 5 * SECOND); + + expect(series.cpuMean[0]).toBeCloseTo(2, 6); + expect(series.cpuMax[0]).toBe(5); + expect(series.exact[0]).toBe(false); + }); + + it('leaves buckets with no data empty so the line breaks across a sleep', () => { + const axis = buildTimeAxis([run(T0, T0 + HOUR), run(T0 + 10 * HOUR, T0 + 11 * HOUR)], 'clock'); + const data = [...samples(T0, 720), ...samples(T0 + 10 * HOUR, 720)]; + + const series = buildSeries(data, axis, axis.min, axis.max, 110, 5 * SECOND); + + const asleep = series.t.map((t, i) => ({ t, value: series.cpuMean[i] })).filter(({ t }) => t > T0 + 2 * HOUR && t < T0 + 9 * HOUR); + expect(asleep.length).toBeGreaterThan(0); + expect(asleep.every(({ value }) => value === null)).toBe(true); + expect(series.cpuMean[0]).not.toBeNull(); + }); + + it('reports disk traffic as a rate over the time that has data', () => { + const data = [aggregate(T0, T0 + 5 * SECOND, { ioWriteBytes: 5 * MB, ioReadBytes: 10 * MB, exact: true })]; + const axis = buildTimeAxis([run(T0, T0 + 5 * SECOND)], 'active'); + + const series = buildSeries(data, axis, axis.min, axis.max, 1, 5 * SECOND); + + expect(series.ioWriteRate[0]).toBeCloseTo(MB, 3); + expect(series.ioReadRate[0]).toBeCloseTo(2 * MB, 3); + }); + + it('counts an OOM kill once even when its aggregate spans many buckets', () => { + const data = [aggregate(T0, T0 + 15 * MINUTE, { oomKills: 1 })]; + const axis = buildTimeAxis([run(T0, T0 + 15 * MINUTE)], 'active'); + + const series = buildSeries(data, axis, axis.min, axis.max, 15, 5 * SECOND); + + expect(series.oomKills.reduce((sum, kills) => sum + kills, 0)).toBe(1); + expect(series.cpuMean.every((value) => value === 0.1)).toBe(true); + }); + + it('never makes buckets finer than the sampling interval', () => { + const series = buildSeries(samples(T0, 12), hour, hour.min, hour.min + MINUTE, 400, 5 * SECOND); + expect(series.bucketMs).toBe(5 * SECOND); + expect(series.x).toHaveLength(12); + }); +}); + +describe('summarizeUsage', () => { + it('totals bytes, takes peaks and counts events inside the window only', () => { + const data = [ + aggregate(T0, T0 + MINUTE, { cpuMaxCores: 2, ioWriteBytes: 60 * MB, oomKills: 1, toolCallStarts: 4 }), + aggregate(T0 + MINUTE, T0 + 2 * MINUTE, { cpuMaxCores: 4, ioWriteBytes: 60 * MB, oomKills: 1 }), + ]; + + const firstMinute = summarizeUsage(data, T0, T0 + MINUTE); + expect(firstMinute.cpuMaxCores).toBe(2); + expect(firstMinute.ioWriteBytes).toBeCloseTo(60 * MB, 0); + expect(firstMinute.oomKills).toBe(1); + expect(firstMinute.toolCallStarts).toBe(4); + + const halfOfSecond = summarizeUsage(data, T0 + MINUTE, T0 + 90 * SECOND); + expect(halfOfSecond.ioWriteBytes).toBeCloseTo(30 * MB, 0); + expect(halfOfSecond.oomKills).toBe(0); + }); +}); + +describe('findPeaks', () => { + it('returns the highest peaks, at least the separation apart, highest first', () => { + const data = [ + aggregate(T0, T0 + MINUTE, { cpuMaxCores: 3 }), + aggregate(T0 + MINUTE, T0 + 2 * MINUTE, { cpuMaxCores: 3.5 }), + aggregate(T0 + HOUR, T0 + HOUR + MINUTE, { cpuMaxCores: 2 }), + ]; + + const peaks = findPeaks(data, 'cpu', 3, 20 * MINUTE); + + expect(peaks.map((peak) => peak.value)).toEqual([3.5, 2]); + }); +}); diff --git a/apps/web/tests/unit/components/resource-timeline/time-axis.test.ts b/apps/web/tests/unit/components/resource-timeline/time-axis.test.ts new file mode 100644 index 0000000000..15509bd6ba --- /dev/null +++ b/apps/web/tests/unit/components/resource-timeline/time-axis.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; + +import { + activeMsInView, + axisTicks, + buildTimeAxis, + sleepAt, + sleepBreakWidthMs, + toAxis, + toReal, +} from '../../../../src/components/chat/resource-timeline/time-axis'; +import type { ResourceRun } from '../../../../src/components/chat/resource-timeline/types'; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const T0 = new Date(2026, 8, 28, 19, 0, 0).getTime(); + +function run(id: string, startedAt: number, endedAt: number): ResourceRun { + return { id, nodeId: null, startedAt, endedAt, unsupportedReason: null }; +} + +/** An hour of work, a nine-hour night, another hour of work. */ +const NIGHT = [run('a', T0, T0 + HOUR), run('b', T0 + 10 * HOUR, T0 + 11 * HOUR)]; + +describe('buildTimeAxis', () => { + it('maps run time one-to-one and compresses the sleep between runs in active mode', () => { + const axis = buildTimeAxis(NIGHT, 'active'); + + expect(axis.spans.map((span) => span.kind)).toEqual(['active', 'sleep', 'active']); + expect(axis.activeMs).toBe(2 * HOUR); + const sleepWidth = (axis.spans[1]?.axisEnd ?? 0) - (axis.spans[1]?.axisStart ?? 0); + expect(sleepWidth).toBeGreaterThan(0); + expect(sleepWidth).toBeLessThan(9 * HOUR / 10); + expect(axis.max - axis.min).toBe(2 * HOUR + sleepWidth); + expect(toAxis(axis, T0 + 30 * MINUTE)).toBe(axis.min + 30 * MINUTE); + }); + + it('keeps real proportions in clock mode', () => { + const axis = buildTimeAxis(NIGHT, 'clock'); + + expect(axis.max - axis.min).toBe(11 * HOUR); + expect(toAxis(axis, T0 + 5 * HOUR)).toBe(axis.min + 5 * HOUR); + }); + + it.each([ + ['inside the first run', T0 + 15 * MINUTE], + ['inside the compressed sleep', T0 + 5 * HOUR], + ['inside the second run', T0 + 10.5 * HOUR], + ])('round-trips wall-clock time %s to within a millisecond', (_label, t) => { + const axis = buildTimeAxis(NIGHT, 'active'); + expect(Math.abs(toReal(axis, toAxis(axis, t)) - t)).toBeLessThan(1); + }); + + it('never draws a short pause wider than it really was', () => { + // Ten active hours make the break unit (90 s) wider than this 30 s handoff between runs. + const axis = buildTimeAxis([run('a', T0, T0 + 5 * HOUR), run('b', T0 + 5 * HOUR + 30_000, T0 + 10 * HOUR)], 'active'); + const sleep = axis.spans[1]; + expect(sleepBreakWidthMs(axis.activeMs, 2 * MINUTE)).toBeGreaterThan(30_000); + expect(sleep?.kind).toBe('sleep'); + expect((sleep?.axisEnd ?? 0) - (sleep?.axisStart ?? 0)).toBe(30_000); + }); + + it('draws a night wider than a coffee break, both compressed', () => { + const activeMs = 10 * HOUR; + const night = sleepBreakWidthMs(activeMs, 9 * HOUR); + const coffee = sleepBreakWidthMs(activeMs, 20 * MINUTE); + expect(night).toBeGreaterThan(coffee); + expect(night).toBeLessThan(9 * HOUR); + expect(coffee).toBeLessThan(20 * MINUTE); + }); + + it('merges overlapping runs into one active span', () => { + const axis = buildTimeAxis([run('a', T0, T0 + HOUR), run('b', T0 + 30 * MINUTE, T0 + 2 * HOUR)], 'active'); + expect(axis.spans).toHaveLength(1); + expect(axis.activeMs).toBe(2 * HOUR); + }); +}); + +describe('sleepAt and activeMsInView', () => { + const axis = buildTimeAxis(NIGHT, 'active'); + + it('finds the sleep under an axis position and nothing inside a run', () => { + const sleep = axis.spans[1]; + const middle = ((sleep?.axisStart ?? 0) + (sleep?.axisEnd ?? 0)) / 2; + expect(sleepAt(axis, middle)?.realEnd).toBe(T0 + 10 * HOUR); + expect(sleepAt(axis, axis.min + 10 * MINUTE)).toBeNull(); + }); + + it('counts only run time inside the view', () => { + expect(activeMsInView(axis, axis.min, axis.max)).toBe(2 * HOUR); + expect(activeMsInView(axis, axis.min, axis.min + 30 * MINUTE)).toBe(30 * MINUTE); + }); +}); + +describe('axisTicks', () => { + it('aligns ticks to round local times and never puts one inside a compressed sleep', () => { + const axis = buildTimeAxis(NIGHT, 'active'); + const ticks = axisTicks(axis, axis.min, axis.max, 15 * MINUTE); + + expect(ticks.length).toBeGreaterThanOrEqual(8); + for (const tick of ticks) { + const date = new Date(tick.t); + expect(date.getMinutes() % 15).toBe(0); + expect(date.getSeconds()).toBe(0); + expect(tick.t <= T0 + HOUR || tick.t >= T0 + 10 * HOUR).toBe(true); + } + }); + + it('drops a tick that would crowd its neighbour across a break', () => { + // Ticks at the end of one run and the start of the next sit a compressed break apart. + const axis = buildTimeAxis([run('a', T0, T0 + HOUR), run('b', T0 + 3 * HOUR, T0 + 4 * HOUR)], 'active'); + const ticks = axisTicks(axis, axis.min, axis.max, HOUR); + const gaps = ticks.slice(1).map((tick, i) => tick.x - (ticks[i]?.x ?? 0)); + + expect(ticks.map((tick) => tick.t)).not.toContain(T0 + 3 * HOUR); + expect(Math.min(...gaps)).toBeGreaterThanOrEqual(0.75 * HOUR); + }); +}); diff --git a/apps/web/tests/unit/components/resource-timeline/timeline-view.test.ts b/apps/web/tests/unit/components/resource-timeline/timeline-view.test.ts new file mode 100644 index 0000000000..d4517e0061 --- /dev/null +++ b/apps/web/tests/unit/components/resource-timeline/timeline-view.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTimeAxis, toReal } from '../../../../src/components/chat/resource-timeline/time-axis'; +import { clampRange, MIN_VIEW_SPAN_MS, resolveView } from '../../../../src/components/chat/resource-timeline/timeline-view'; +import type { ResourceRun } from '../../../../src/components/chat/resource-timeline/types'; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const T0 = Date.UTC(2026, 8, 28, 19, 0, 0); + +function run(startedAt: number, endedAt: number): ResourceRun { + return { id: `run-${startedAt}`, nodeId: null, startedAt, endedAt, unsupportedReason: null }; +} + +const RUNS = [run(T0, T0 + HOUR), run(T0 + 10 * HOUR, T0 + 11 * HOUR)]; + +describe('resolveView', () => { + const axis = buildTimeAxis(RUNS, 'active'); + + it('shows the whole axis for "all"', () => { + expect(resolveView({ kind: 'all' }, axis)).toEqual({ min: axis.min, max: axis.max }); + }); + + it('pins "latest" to the end of the session, so it follows new data', () => { + const view = resolveView({ kind: 'latest', spanMs: 15 * MINUTE }, axis); + expect(view).toEqual({ min: axis.max - 15 * MINUTE, max: axis.max }); + + const grown = buildTimeAxis([...RUNS.slice(0, 1), run(T0 + 10 * HOUR, T0 + 12 * HOUR)], 'active'); + expect(resolveView({ kind: 'latest', spanMs: 15 * MINUTE }, grown).max).toBe(grown.max); + }); + + it('keeps a chosen range on the same wall-clock times when the axis mode changes', () => { + const intent = { kind: 'range' as const, from: T0 + 10 * HOUR + 5 * MINUTE, to: T0 + 10 * HOUR + 20 * MINUTE }; + for (const mode of ['active', 'clock'] as const) { + const modeAxis = buildTimeAxis(RUNS, mode); + const view = resolveView(intent, modeAxis); + expect(toReal(modeAxis, view.min)).toBeCloseTo(intent.from, 3); + expect(toReal(modeAxis, view.max)).toBeCloseTo(intent.to, 3); + } + }); +}); + +describe('clampRange', () => { + const axis = buildTimeAxis(RUNS, 'active'); + + it('slides a window that runs past the end back inside without changing its width', () => { + expect(clampRange(axis.max - 10 * MINUTE, axis.max + 20 * MINUTE, axis)).toEqual({ + min: axis.max - 30 * MINUTE, + max: axis.max, + }); + }); + + it('never zooms in past the minimum span', () => { + const view = clampRange(axis.min + MINUTE, axis.min + MINUTE + 1_000, axis); + expect(view.max - view.min).toBe(MIN_VIEW_SPAN_MS); + }); + + it('never zooms out past the whole session', () => { + expect(clampRange(axis.min - HOUR, axis.max + HOUR, axis)).toEqual({ min: axis.min, max: axis.max }); + }); +}); From 267f4622ef0d0af0f6f72952b82157f7d360b9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 07:42:51 +0000 Subject: [PATCH 04/22] test(web): drive the new resource drawer from the real chat rail - the session tool-rail audit now opens Resources from the real rail button against today's /resource-history contract and asserts the whole-session readout, the OOM call-out, no chunk controls, and a keyboard-driven cursor reading - mock samples carry the collector's 5 s interval, and every chunk the view needs returns detail - adapter: when the server thinned a chunk, each kept sample stands for the time since the previous one (never across a sampler gap) and is no longer reported as an exact 5 s measurement Co-Authored-By: Claude Opus 5.5 --- .../chat/resource-timeline/resource-source.ts | 19 +- .../session-tool-rail-audit.spec.ts | 221 +++++------------- .../resource-timeline/resource-source.test.ts | 31 +++ 3 files changed, 101 insertions(+), 170 deletions(-) diff --git a/apps/web/src/components/chat/resource-timeline/resource-source.ts b/apps/web/src/components/chat/resource-timeline/resource-source.ts index 1e0442a081..71e852f5b9 100644 --- a/apps/web/src/components/chat/resource-timeline/resource-source.ts +++ b/apps/web/src/components/chat/resource-timeline/resource-source.ts @@ -195,14 +195,31 @@ export function indexFromApi(response: WorkspaceResourceHistoryResponse): Resour }; } +/** + * When the server thinned a chunk (`downsampled`), the kept samples are far apart + * but each still spans only its own 5-second interval. Stretch each back to the + * previous kept sample so the line does not break between them — except across a + * sampler gap, which must stay visible. + */ +function coverThinnedSamples(samples: ResourceAggregate[], raw: readonly WorkspaceResourceSample[]): ResourceAggregate[] { + return samples.map((sample, i) => { + const previous = samples[i - 1]; + if (!previous || raw[i]?.gap || previous.end >= sample.start) return sample; + // One kept sample now stands for a wider window: a reading, not a full-resolution measurement of it. + return { ...sample, start: previous.end, exact: false }; + }); +} + export function chunkDetailFromApi( chunkId: string, detail: NonNullable ): ResourceChunkDetail { const toolSpans = detail.toolSpans.map(toolSpanFromApi); + const raw = [...detail.samples].sort((a, b) => a.t - b.t); + const samples = raw.map(sampleToAggregate); return { chunkId, - samples: withToolStarts(detail.samples.map(sampleToAggregate), toolSpans), + samples: withToolStarts(detail.downsampled ? coverThinnedSamples(samples, raw) : samples, toolSpans), toolSpans, samplerGaps: detail.gaps .map((gap) => ({ start: numberField(gap, 'startedAt'), end: numberField(gap, 'endedAt') })) diff --git a/apps/web/tests/playwright/session-tool-rail-audit.spec.ts b/apps/web/tests/playwright/session-tool-rail-audit.spec.ts index e2b5f58128..b5ae38e31d 100644 --- a/apps/web/tests/playwright/session-tool-rail-audit.spec.ts +++ b/apps/web/tests/playwright/session-tool-rail-audit.spec.ts @@ -123,8 +123,6 @@ const RESOURCE_HISTORY_SUMMARY = { memoryMeanBytes: 524_288_000, memoryPeakBytes: 1_342_177_280, memoryKernelPeakBytes: 1_610_612_736, - memoryWorkingSetMeanBytes: 346_030_080, - memoryWorkingSetPeakBytes: 671_088_640, ioReadBytes: 18_874_368, ioWriteBytes: 94_371_840, oomCount: 1, @@ -192,11 +190,10 @@ const RESOURCE_HISTORY_DETAIL = { downsampleLimit: 720, samples: Array.from({ length: 36 }, (_, i) => ({ t: NOW - 1_800_000 + i * 25_000, + intervalMillis: 5_000, cpuMillis: resourceHistoryCpuMillis(i), memoryBytes: i === 24 ? 1_342_177_280 : 410_000_000 + i * 12_000_000, memoryPeakBytes: i >= 24 ? 1_342_177_280 : 610_000_000 + i * 8_000_000, - memoryWorkingSetBytes: - i === 12 ? undefined : i === 24 ? 671_088_640 : 275_000_000 + i * 5_000_000, ioReadBytes: i % 8 === 0 ? 1_048_576 : 16_384, ioWriteBytes: i % 9 === 0 ? 4_194_304 : 65_536, oom: i === 24 ? 1 : 0, @@ -205,46 +202,54 @@ const RESOURCE_HISTORY_DETAIL = { toolSpans: [ { id: 'tool-compile', - kind: 'execute', - toolName: 'Bash', + kind: 'acp_tool_call', startedAt: NOW - 1_650_000, endedAt: NOW - 1_520_000, concurrency: 1, }, { id: 'tool-tests', - kind: 'search', + kind: 'acp_tool_call', startedAt: NOW - 1_470_000, endedAt: NOW - 1_240_000, concurrency: 2, }, { id: 'tool-review', + kind: 'acp_tool_call', startedAt: NOW - 1_210_000, endedAt: NOW - 980_000, concurrency: 1, approximate: true, }, - { - id: 'tool-long-name', - kind: 'other', - toolName: `mcp__sam-mcp__${'dispatch_task_with_a_deliberately_long_name_'.repeat(6)}`, - startedAt: NOW - 930_000, - endedAt: NOW - 900_000, - concurrency: 1, - }, - { - id: 'tool-special-name', - kind: 'fetch', - toolName: 'fetch & "quotes" 🔥', - startedAt: NOW - 880_000, - endedAt: NOW - 850_000, - concurrency: 1, - }, ], gaps: [{ startedAt: NOW - 1_300_000, endedAt: NOW - 1_250_000, reason: 'sampler_delay' }], }; +/** The older, quieter chunk: the timeline loads every chunk its view needs, not only the newest. */ +const RESOURCE_HISTORY_QUIET_DETAIL = { + chunkId: 'wrchunk-rail-1', + originalSampleCount: 180, + downsampled: false, + downsampleLimit: 720, + samples: Array.from({ length: 180 }, (_, i) => ({ + t: NOW - 2_700_000 + (i + 1) * 5_000, + intervalMillis: 5_000, + cpuMillis: 20 + (i % 3) * 5, + memoryBytes: 400_000_000, + memoryPeakBytes: 420_000_000, + ioReadBytes: 4_096, + ioWriteBytes: 8_192, + })), + toolSpans: [], + gaps: [], +}; + +const RESOURCE_HISTORY_DETAILS: Record = { + 'wrchunk-rail-2': RESOURCE_HISTORY_DETAIL, + 'wrchunk-rail-1': RESOURCE_HISTORY_QUIET_DETAIL, +}; + /** Enough messages to overflow any test viewport, so the scroll-to-bottom button appears. */ function makeManyMessages(count = 60) { return { @@ -311,8 +316,6 @@ interface MockOptions extends SessionOptions { empty?: boolean; /** Seeds a long conversation so the scroll-to-bottom button can actually appear. */ manyMessages?: boolean; - legacyResourceHistory?: boolean; - resourceHistoryScenario?: 'normal' | 'empty' | 'error' | 'many'; } async function setupMocks(page: Page, options: MockOptions = {}) { @@ -322,8 +325,6 @@ async function setupMocks(page: Page, options: MockOptions = {}) { messagesLong = false, empty = false, manyMessages = false, - legacyResourceHistory = false, - resourceHistoryScenario = 'normal', } = options; await page.addInitScript( @@ -376,47 +377,12 @@ async function setupMocks(page: Page, options: MockOptions = {}) { } if (pathname === `/api/projects/${PROJECT_ID}/sessions/${SESSION_ID}/resource-history`) { - if (resourceHistoryScenario === 'error') { - await route.fulfill({ status: 500, json: { error: 'resource_history_unavailable' } }); - return; - } - if (resourceHistoryScenario === 'empty') { - await route.fulfill({ json: { summary: null, chunks: [] } }); - return; - } - const includeDetail = new URL(url).searchParams.get('chunkId') === 'wrchunk-rail-2'; - const chunks = - resourceHistoryScenario === 'many' - ? Array.from({ length: 30 }, (_, index) => ({ - ...RESOURCE_HISTORY_CHUNKS[0], - id: `wrchunk-many-${index}`, - chunkSequence: 30 - index, - startedAt: NOW - (index + 2) * 900_000, - endedAt: NOW - (index + 1) * 900_000, - })) - : RESOURCE_HISTORY_CHUNKS; + const detail = RESOURCE_HISTORY_DETAILS[new URL(url).searchParams.get('chunkId') ?? '']; await route.fulfill({ json: { - summary: legacyResourceHistory - ? { - ...RESOURCE_HISTORY_SUMMARY, - memoryWorkingSetMeanBytes: null, - memoryWorkingSetPeakBytes: null, - } - : RESOURCE_HISTORY_SUMMARY, - chunks, - ...(includeDetail - ? { - detail: legacyResourceHistory - ? { - ...RESOURCE_HISTORY_DETAIL, - samples: RESOURCE_HISTORY_DETAIL.samples.map( - ({ memoryWorkingSetBytes: _memoryWorkingSetBytes, ...sample }) => sample - ), - } - : RESOURCE_HISTORY_DETAIL, - } - : {}), + summary: RESOURCE_HISTORY_SUMMARY, + chunks: RESOURCE_HISTORY_CHUNKS, + ...(detail ? { detail } : {}), }, }); return; @@ -489,7 +455,9 @@ async function setupMocks(page: Page, options: MockOptions = {}) { await route.fulfill({ json: { email: false, push: false } }); return; } - if (pathname === `/api/projects/${PROJECT_ID}/credential-attribution-health`) { + if ( + pathname === `/api/projects/${PROJECT_ID}/credential-attribution-health` + ) { await route.fulfill({ json: { healthy: true } }); return; } @@ -1094,7 +1062,7 @@ test.describe('Session Details — desktop', () => { }); test.describe('Session resource history drawer', () => { - test('opens contextual summary and auto-loads detail from the real rail action', async ({ + test('opens the whole session from the real rail action and reads values under the cursor', async ({ page, }) => { await openChat(page, { state: 'active', messagesLong: true }); @@ -1104,114 +1072,29 @@ test.describe('Session resource history drawer', () => { await expect(dialog).toBeVisible(); await expect(page.getByRole('heading', { name: 'Resources' })).toBeVisible(); - // Stat cards — use exact match to avoid ambiguity with chart legend text - await expect(page.getByText('CPU peak', { exact: true })).toBeVisible(); - await expect(page.getByText('Memory needed (peak)', { exact: true })).toBeVisible(); - await expect(page.getByText('640 MB', { exact: true })).toBeVisible(); - await expect(page.getByText('Total RAM (incl. cache)', { exact: true })).toBeVisible(); - await expect(page.getByText('1 OOM event observed in retained samples.')).toBeVisible(); - - // Chart auto-loads via useEffect selecting newest chunk — wait for it - await expect(page.getByRole('img', { name: 'CPU and memory resource timeline' })).toBeVisible({ - timeout: 10_000, - }); - await expect( - page.getByText('CPU: green solid line, normalized to the CPU peak for this chunk.') - ).toBeVisible(); - await expect( - page.getByText('Memory needed: purple solid line (working set, when reported).') - ).toBeVisible(); - await expect( - page.getByText('Total RAM: faint purple dashed line, including reclaimable file cache.') - ).toBeVisible(); - await expect( - page - .getByRole('img', { name: 'CPU and memory resource timeline' }) - .locator('polyline[data-series="working-set"]') - ).toHaveCount(2); - await expect(page.getByText(/Blue bands: concurrent tool windows/)).toBeVisible(); - await expect(page.getByText('Tool windows', { exact: true })).toBeVisible(); - await expect(page.getByText(/Bash ·/)).toBeVisible(); - await expect(page.getByText(/search ·/)).toBeVisible(); - await expect(page.getByText(/tool ·/)).toBeVisible(); - await expect( - page.getByText(/fetch & "quotes" 🔥 ·/) - ).toBeVisible(); - await expect(dialog.locator('img[src="x"]')).toHaveCount(0); - await expect(dialog.locator('[title^="mcp__sam-mcp__"]')).toHaveCount(1); - const toolWindows = page.getByText('Tool windows', { exact: true }).locator('..'); - expect( - await toolWindows.evaluate((element) => element.scrollWidth <= element.clientWidth) - ).toBe(true); - - // Correlation disclaimer is contextual — only visible after chart loads - await expect(page.getByText('Correlation is based on concurrent tool windows')).toBeVisible(); + // The whole session is on screen, and the OOM kill recorded in the detail samples is called out. + await expect(dialog.getByText('Whole session', { exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByText('1 out-of-memory kill')).toBeVisible({ timeout: 10_000 }); + const timeline = dialog.getByRole('slider', { name: /Session timeline/ }); + await expect(timeline).toBeVisible(); + await expect(dialog.getByText('Busiest moments', { exact: true })).toBeVisible(); + // Chunks are a storage detail: nothing in the drawer offers them. + await expect(dialog.getByRole('button', { name: /chunk/i })).toHaveCount(0); await capture(page, `resource-history-summary-${page.viewportSize()?.width ?? 'viewport'}`); - // Scroll to bottom for detail screenshot - await dialog.locator('.overflow-y-auto').evaluate((el) => { - el.scrollTop = el.scrollHeight; - }); - await capture(page, `resource-history-detail-${page.viewportSize()?.width ?? 'viewport'}`); + // Keyboard reaches the same readout a finger or pointer does. + await timeline.focus(); + await page.keyboard.press('ArrowRight'); + await expect(dialog.getByRole('button', { name: 'Clear the selected instant' })).toBeVisible(); + await expect(timeline).toHaveAttribute('aria-valuetext', /CPU { el.scrollTop = el.scrollHeight; }); - await capture(page, `resource-history-chunks-${page.viewportSize()?.width ?? 'viewport'}`); - }); - - test('shows unknown working set for history from an older VM agent', async ({ page }) => { - await openChat(page, { state: 'active', legacyResourceHistory: true }); - await page.getByTestId('session-tool-resources').click(); - - for (const label of ['Memory needed (peak)', 'Memory needed (mean)']) { - const neededCard = page.getByText(label, { exact: true }).locator('..'); - await expect(neededCard).toContainText('—'); - await expect(neededCard).not.toContainText('0 B'); - } - await expect( - page - .getByRole('img', { name: 'CPU and memory resource timeline' }) - .locator('polyline[data-series="working-set"]') - ).toHaveCount(0); - await capture(page, `resource-history-legacy-${page.viewportSize()?.width ?? 'viewport'}`); - }); - - test('shows the empty resource-history state', async ({ page }) => { - await openChat(page, { state: 'active', resourceHistoryScenario: 'empty' }); - await page.getByTestId('session-tool-resources').click(); - - await expect( - page.getByText('No retained resource history is available for this session yet.') - ).toBeVisible(); - await capture(page, `resource-history-empty-${page.viewportSize()?.width ?? 'viewport'}`); - }); - - test('shows the resource-history error state', async ({ page }) => { - await openChat(page, { state: 'active', resourceHistoryScenario: 'error' }); - await page.getByTestId('session-tool-resources').click(); - - await expect(page.getByText('Resource history could not be loaded.')).toBeVisible(); - await capture(page, `resource-history-error-${page.viewportSize()?.width ?? 'viewport'}`); - }); - - test('keeps a long chunk list usable', async ({ page }) => { - await openChat(page, { state: 'active', resourceHistoryScenario: 'many' }); - await page.getByTestId('session-tool-resources').click(); - - const chunksToggle = page.getByRole('button', { name: '30 chunks' }); - await chunksToggle.click(); - await expect(page.getByRole('button', { name: /samples/ })).toHaveCount(30); - await capture(page, `resource-history-many-${page.viewportSize()?.width ?? 'viewport'}`); + await capture(page, `resource-history-detail-${page.viewportSize()?.width ?? 'viewport'}`); }); }); diff --git a/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts b/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts index 70e49aafea..c797e8ef09 100644 --- a/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts +++ b/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts @@ -152,3 +152,34 @@ describe('chunkDetailFromApi', () => { expect(detail.samplerGaps).toEqual([{ start: T0, end: T0 + 40_000 }]); }); }); + +describe('chunkDetailFromApi with server-thinned samples', () => { + const detail = (downsampled: boolean) => + chunkDetailFromApi('c1', { + chunkId: 'c1', + samples: [ + { t: T0 + 5_000, intervalMillis: 5000, cpuMillis: 5000 }, + { t: T0 + 30_000, intervalMillis: 5000, cpuMillis: 5000 }, + { t: T0 + 90_000, intervalMillis: 5000, cpuMillis: 5000, gap: true }, + ], + toolSpans: [], + gaps: [], + originalSampleCount: 18, + downsampled, + downsampleLimit: 3, + }); + + it('stretches each kept sample back to the previous one, but never across a sampler gap', () => { + const samples = detail(true).samples; + expect(samples.map((sample) => [sample.start - T0, sample.end - T0])).toEqual([ + [0, 5_000], + [5_000, 30_000], + [85_000, 90_000], + ]); + expect(samples.map((sample) => sample.exact)).toEqual([true, false, true]); + }); + + it('leaves full-resolution samples on their own intervals', () => { + expect(detail(false).samples.map((sample) => sample.start - T0)).toEqual([0, 25_000, 85_000]); + }); +}); From 6e50164d988419d8fb7698cc16c62a2fbd76a73a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 08:00:27 +0000 Subject: [PATCH 05/22] task: start whole-session resource timeline Co-Authored-By: Claude Opus 5.5 --- ...6-09-29-whole-session-resource-timeline.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tasks/active/2026-09-29-whole-session-resource-timeline.md diff --git a/tasks/active/2026-09-29-whole-session-resource-timeline.md b/tasks/active/2026-09-29-whole-session-resource-timeline.md new file mode 100644 index 0000000000..6f77bf3993 --- /dev/null +++ b/tasks/active/2026-09-29-whole-session-resource-timeline.md @@ -0,0 +1,78 @@ +# Whole-session resource timeline (ship the prototype) + +## Problem + +The session Resources drawer shows one 15-minute storage chunk at a time and gives no information on hover or tap. Raphaël said it left him "a bit misled": he did not realise he was looking at a thin slice of time. He approved the whole-session timeline prototype for production ("let's get this ui into prod", 2026-09-29). + +Idea: `01M3P13H0W6EG1FS47PCG0N2EG`. Prototype branch: `sam/looks-resources-details-timeline-51112h` (commits af95d2969..75420e910). + +## Research findings (verified on main af1544127/c17508d51 and prod, 2026-09-29) + +1. **Only the newest chunk is drawn.** `SessionResourceHistoryDrawer` auto-loads the newest chunk. Chunks are 15 minutes (`collector.go` `DefaultChunkInterval`) of 5 s samples. +2. **Older history is unreachable.** `getWorkspaceResourceHistory` (`services/workspace-resource-history.ts`) lists only the newest 24 chunks (`DEFAULT_LIST_LIMIT`) with no disclosure. Prod session 449d73f8 ran about 27 h; its first ~20 h cannot be reached. +3. **Stat cards describe the last wake only.** The summary query returns the newest `(workspace, session)` row, and each wake is a new workspace. 449d73f8 spans 8 workspaces on 3 nodes and reports 258 samples. +4. **Lines have no absolute scale.** The sparkline normalises each line to its chunk peak, and CPU is shown as "ms/sample". +5. **The upload path already decodes each chunk** (`validateWorkspaceResourceChunkBytes` → `readBoundedGzipJson`). A per-minute rollup can be computed there at negligible CPU and stored per chunk, so the whole session draws from D1 alone. +6. **Workspace reservations exist** in `workspaces.resolved_reservation_json`. The parser is `parseStoredResolvedReservationJson` (`services/resource-requirements-input.ts`); it throws on malformed rows, so wrap it per row. +7. **Instant sessions collect nothing yet** (idea 01M3NV89R0CKFNMX6AQSK9FY5Q). `nodes.runtime = 'cf-container'` identifies them, and the empty state must say so rather than promise data in 15 minutes. +8. **Sibling tasks change the data** (coordinated by durable messages 01M3P2NE…, 01M3P2NJ…): + - Tool labels (task 01M3NV760JFXQD4Y53VCW5TTC5): spans carry `kind` (ACP kind) and `toolName`. + - Working set (task 01M3NV7MBCSASWHAT0W0JPJ3NT, migration 0176): sample `memoryWorkingSetBytes`; summary `memoryWorkingSetMeanBytes` / `memoryWorkingSetPeakBytes`. + - Attribution (PR #2181, migration 0175). + - All three touch the old drawer, `workspace-resource-history.ts` and the tool-rail audit spec. Keep shared-file edits small and put new logic in new modules. +9. **Library: uPlot 1.6.32** (MIT, ~23 KB gz). ECharts 6 is the runner-up. Highcharts and AG Charts Enterprise are rejected on licence (SAM is AGPL). Recharts is SVG without zoom. No library caches fetched data, so level-of-detail loading is app code in every option. +10. **Bundle.** The drawer is statically imported into the project-chat route, so the timeline (and uPlot) must load through a dynamic import. Measured: uPlot ends up only in the lazy `ResourceTimeline` chunk (36 KB gz). +11. **Rules that apply.** + - Prototype route and mock data must be removed before merge (rule 37). + - Request budget: index ≤ 8 round-trips (rule 60). + - Scope predicates are tested on a real SQL engine with an attack case plus an owner control (rules 11, 28). + - Per-row fault isolation (rule 50). + - Additive migration only (rule 31). + - Capped selection must disclose what it dropped (rule 65). + - Every limit is configurable (constitution XI). + +## Implementation checklist + +### Backend +- [ ] Migration `0177_workspace_resource_chunk_rollups.sql`: `ALTER TABLE workspace_resource_chunks ADD COLUMN rollup_json TEXT` (+ schema.ts) +- [ ] `services/workspace-resource-rollup.ts`: pure per-minute rollup from a decoded chunk payload. It holds CPU mean/max in cores, memory mean/max, working set mean/max, disk bytes, OOM kills, tool-call starts and sample count. Bucket count is bounded and configurable, and the result is compact and columnar. +- [ ] Upload hook: compute and store `rollup_json` in `storeWorkspaceResourceChunk`. A rollup failure must never fail the upload. +- [ ] `services/workspace-resource-timeline.ts`: the session index lists every chunk for `(project, session)`, newest first, capped by `WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS` with `omittedChunkCount` disclosure. It groups chunks into runs with each workspace's reservation, and tolerates a malformed row. With no chunks, it reports whether the session's runtime collects at all (cf-container → unsupported). +- [ ] Chunk read by id scoped to project + session, reusing `readChunkPayload`. +- [ ] Routes `GET /api/projects/:id/sessions/:sessionId/resource-timeline` and `.../resource-timeline/chunks/:chunkId`, gated by project access. +- [ ] Env var documented: `.env.example`, env.ts, env-reference skill, configuration.md. + +### Web +- [ ] API client + types for the two endpoints; the API source adapter reads the index (rollups → overview, summary fallback for older chunks) and the chunk endpoint +- [ ] Per-run reservation lines +- [ ] Empty state distinguishes "not uploaded yet" from "not recorded for Instant sessions" +- [ ] ToolKind covers the full ACP set (read, edit, delete, move, search, execute, think, fetch, switch_mode, other) +- [ ] Working-set fields read by the sibling's names (`memoryWorkingSetBytes`, `memoryWorkingSetMeanBytes`, `memoryWorkingSetPeakBytes`) +- [ ] Remove the prototype route, page, mock data and the DEV_ONLY entry +- [ ] Remove dead code: the old-API index adapter, and `getSessionResourceHistory` if unused +- [ ] Playwright visual audit with stress data (27 h multi-wake, empty, Instant, older agent) at 375 and 1280, plus the rail audit + +### Docs +- [ ] `apps/www/.../guides/session-resources.md` rewritten for the timeline +- [ ] `reference/api.md` lists the new endpoints + +### Tests +- [ ] Rollup unit tests (buckets, gaps, working set present and absent, tool starts, bounded bucket count) +- [ ] Upload stores `rollup_json` (real SQL engine + fake R2) +- [ ] Index on a real SQL engine: all chunks across workspaces; a foreign project/session is excluded (attack) while the owner is served (control); cap + disclosure; malformed row tolerated; reservation join; Instant detection +- [ ] Chunk read: a foreign session's chunk id → 404 (attack) + owner control +- [ ] Routes through the Hono app with project-access gating +- [ ] Web data-layer tests updated for the new adapter + +## Acceptance criteria +- [ ] Opening Resources on a multi-wake session shows the whole session, with no chunk UI and no silent cap (disclosed only past the configured cap) +- [ ] Hover, tap or keyboard shows exact values and says whether each is a 5 s sample or an average +- [ ] Zoom (pinch, drag, chips, busiest moments) loads 5 s detail for the visible window only; chunks are fetched once +- [ ] Reservation lines and OOM kills are visible; Instant sessions explain that no data is recorded +- [ ] Newly uploaded chunks carry a per-minute rollup; older chunks still render from their summary +- [ ] uPlot is not in the initial or chat-route bundle +- [ ] Staging: a real VM session shows its resource timeline end to end, including a chunk uploaded after deploy with a rollup + +## References +- `.claude/rules/37` (prototypes), `60` (request budgets), `11` and `28` (scope tests), `50` (row isolation), `31` (migrations), `65` (capped selection), `62` (real trigger), `17` and `56` (visual audit) +- Idea 01KZP2972NHBXVRM4695E3DCB3 (telemetry), 01M3P13H0W6EG1FS47PCG0N2EG (this work) From 0e2c69df49f02f45230f3ed0e12a2a46248ed58f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 09:10:01 +0000 Subject: [PATCH 06/22] feat(api): add resource chunk rollup column and timeline env knobs Co-Authored-By: Claude Opus 5.5 --- apps/api/.env.example | 3 +++ .../migrations/0177_workspace_resource_chunk_rollups.sql | 8 ++++++++ apps/api/src/db/schema.ts | 2 ++ apps/api/src/env.ts | 3 +++ 4 files changed, 16 insertions(+) create mode 100644 apps/api/src/db/migrations/0177_workspace_resource_chunk_rollups.sql diff --git a/apps/api/.env.example b/apps/api/.env.example index adfed65e90..c52125b4c9 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1026,6 +1026,9 @@ INFOMANIAK_IP_POLL_INTERVAL_MS=3000 # WORKSPACE_RESOURCE_LIST_LIMIT=24 # WORKSPACE_RESOURCE_CLEANUP_BATCH_SIZE=50 # WORKSPACE_RESOURCE_OBJECT_CLEANUP_LIMIT=5000 +# WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS=1000 +# WORKSPACE_RESOURCE_ROLLUP_BUCKET_MS=60000 +# WORKSPACE_RESOURCE_ROLLUP_MAX_BUCKETS=60 # PROJECT_DATA_ARCHIVE_GLOBAL_SWEEP_ENABLED=false # Separate fail-closed switch for the unscoped scheduled archive-sharding sweep # PROJECT_DATA_ARCHIVE_GLOBAL_SWEEP_INTERVAL_MS=86400000 # Persisted cadence gate for unscoped scheduled archive-sharding sweeps (code fallback daily; wrangler.toml ships 1080000: one claim per 20 min on the 5-min cron) # PROJECT_DATA_ARCHIVE_SHARD_COUNT=128 # Deterministic ProjectData archive shard fanout diff --git a/apps/api/src/db/migrations/0177_workspace_resource_chunk_rollups.sql b/apps/api/src/db/migrations/0177_workspace_resource_chunk_rollups.sql new file mode 100644 index 0000000000..17c57b6078 --- /dev/null +++ b/apps/api/src/db/migrations/0177_workspace_resource_chunk_rollups.sql @@ -0,0 +1,8 @@ +-- Per-minute resource rollup for each stored chunk, computed on upload from the +-- decoded chunk payload. It lets the session resource timeline draw a whole +-- multi-day session from D1 alone, without downloading every R2 chunk. +-- +-- Existing rows stay NULL. They remain fully readable: the timeline falls back +-- to the chunk's own summary_json (one aggregate spanning the chunk), and the +-- full-resolution detail is still read from R2 on zoom. +ALTER TABLE workspace_resource_chunks ADD COLUMN rollup_json TEXT; diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index d470b26bae..604bf9076c 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1647,6 +1647,8 @@ export const workspaceResourceChunks = sqliteTable( toolSpanCount: integer('tool_span_count').notNull().default(0), completenessJson: text('completeness_json').notNull(), summaryJson: text('summary_json').notNull(), + /** Per-minute rollup computed on upload; NULL for chunks uploaded before migration 0177. */ + rollupJson: text('rollup_json'), createdAt: integer('created_at').notNull(), expiresAt: integer('expires_at').notNull(), uploadedByNodeId: text('uploaded_by_node_id'), diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 5121d6e5f9..1176d69a94 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -643,6 +643,9 @@ export interface Env extends WebhookTriggerEnv, TaskRecoveryEnv { WORKSPACE_RESOURCE_LIST_LIMIT?: string; // Max chunk indexes returned by detail list (default: 24) WORKSPACE_RESOURCE_CLEANUP_BATCH_SIZE?: string; // Max expired chunks/summaries cleaned per sweep (default: 50) WORKSPACE_RESOURCE_OBJECT_CLEANUP_LIMIT?: string; // Max R2 objects deleted for one project/workspace resource-history prefix cleanup (default: 5000) + WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS?: string; // Max chunks listed by the whole-session resource timeline index; older ones are disclosed as omitted (default: 1000) + WORKSPACE_RESOURCE_ROLLUP_BUCKET_MS?: string; // Width of each per-chunk rollup bucket computed on upload (default: 60000) + WORKSPACE_RESOURCE_ROLLUP_MAX_BUCKETS?: string; // Max rollup buckets stored per chunk; the bucket width widens to fit (default: 60) PROJECT_DATA_STORAGE_TELEMETRY_LIST_LIMIT_DEFAULT?: string; PROJECT_DATA_STORAGE_TELEMETRY_LIST_LIMIT_MAX?: string; PROJECT_DATA_TOOL_PAYLOAD_CLEANUP_ENABLED?: string; From 5dae57db794b4afd5ca6b3351ae4001702fba0cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 09:14:39 +0000 Subject: [PATCH 07/22] feat(api): whole-session resource timeline index, per-minute chunk rollups Co-Authored-By: Claude Opus 5.5 --- .../projects/workspace-resource-history.ts | 23 ++ .../services/workspace-resource-history.ts | 33 ++- .../src/services/workspace-resource-rollup.ts | 244 ++++++++++++++++ .../services/workspace-resource-timeline.ts | 273 ++++++++++++++++++ 4 files changed, 571 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/services/workspace-resource-rollup.ts create mode 100644 apps/api/src/services/workspace-resource-timeline.ts diff --git a/apps/api/src/routes/projects/workspace-resource-history.ts b/apps/api/src/routes/projects/workspace-resource-history.ts index 38e4dff61e..d300aeb240 100644 --- a/apps/api/src/routes/projects/workspace-resource-history.ts +++ b/apps/api/src/routes/projects/workspace-resource-history.ts @@ -7,6 +7,10 @@ import { getUserId } from '../../middleware/auth'; import { errors } from '../../middleware/error'; import { requireProjectAccess } from '../../middleware/project-auth'; import { getWorkspaceResourceHistory } from '../../services/workspace-resource-history'; +import { + getSessionResourceTimeline, + getSessionResourceTimelineChunk, +} from '../../services/workspace-resource-timeline'; const projectResourceHistoryRoutes = new Hono<{ Bindings: Env }>(); @@ -38,6 +42,25 @@ projectResourceHistoryRoutes.get('/:id/sessions/:sessionId/resource-history', as ); }); +projectResourceHistoryRoutes.get('/:id/sessions/:sessionId/resource-timeline', async (c) => { + const projectId = c.req.param('id'); + const sessionId = c.req.param('sessionId'); + await requireAccess(c.env, projectId, getUserId(c)); + return c.json(await getSessionResourceTimeline(c.env, { projectId, sessionId })); +}); + +projectResourceHistoryRoutes.get( + '/:id/sessions/:sessionId/resource-timeline/chunks/:chunkId', + async (c) => { + const projectId = c.req.param('id'); + const sessionId = c.req.param('sessionId'); + const chunkId = optionalDetailChunkId(c.req.param('chunkId')); + if (!chunkId) throw errors.badRequest('chunkId is required'); + await requireAccess(c.env, projectId, getUserId(c)); + return c.json(await getSessionResourceTimelineChunk(c.env, { projectId, sessionId, chunkId })); + } +); + projectResourceHistoryRoutes.get('/:id/tasks/:taskId/resource-history', async (c) => { const projectId = c.req.param('id'); const taskId = c.req.param('taskId'); diff --git a/apps/api/src/services/workspace-resource-history.ts b/apps/api/src/services/workspace-resource-history.ts index e48a6cb8d2..0696310018 100644 --- a/apps/api/src/services/workspace-resource-history.ts +++ b/apps/api/src/services/workspace-resource-history.ts @@ -8,6 +8,7 @@ import { log } from '../lib/logger'; import { parsePositiveInt } from '../lib/route-helpers'; import { parseJsonRecord } from '../lib/runtime-validation'; import { AppError, errors } from '../middleware/error'; +import { computeWorkspaceResourceRollup, getRollupConfig } from './workspace-resource-rollup'; export const WORKSPACE_RESOURCE_STORAGE_FORMAT = 'resource-history-gzip-json-v1'; const R2_PREFIX = 'resource-history/v1'; @@ -629,6 +630,7 @@ async function validateWorkspaceResourceChunkBytes( actualSha: string; compressedBytes: number; uncompressedBytes: number; + payload: WorkspaceResourceChunkPayload; }> { const bytes = decodeBase64(body.compressedBase64); if (bytes.byteLength !== body.compressedBytes) { @@ -671,9 +673,35 @@ async function validateWorkspaceResourceChunkBytes( actualSha: await sha256Hex(sanitized.bytes), compressedBytes: sanitized.bytes.byteLength, uncompressedBytes: sanitized.uncompressedBytes, + payload: decodedChunk.value, }; } +/** The rollup is an optimisation for the timeline; failing to build one must not reject the chunk. */ +function buildRollupJson( + env: Env, + payload: WorkspaceResourceChunkPayload, + body: WorkspaceResourceUploadBody, + context: { projectId: string; workspaceId: string } +): string | null { + try { + return JSON.stringify( + computeWorkspaceResourceRollup( + payload, + { startedAt: body.startedAt, endedAt: body.endedAt }, + getRollupConfig(env) + ) + ); + } catch (error) { + log.warn('workspace_resource_history.rollup_failed', { + ...context, + chunkSequence: body.chunkSequence, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + export async function storeWorkspaceResourceChunk( env: Env, projectId: string, @@ -694,7 +722,7 @@ export async function storeWorkspaceResourceChunk( ); const nodeId = validateWorkspaceUploadIdentity(workspace, body, uploadedByNodeId); assertWorkspaceResourceUploadMetadata(body); - const { bytes, actualSha, compressedBytes, uncompressedBytes } = + const { bytes, actualSha, compressedBytes, uncompressedBytes, payload } = await validateWorkspaceResourceChunkBytes(env, body); const db = drizzle(env.DATABASE, { schema }); @@ -911,6 +939,7 @@ export async function storeWorkspaceResourceChunk( toolSpanCount: body.toolSpanCount ?? 0, completenessJson, summaryJson, + rollupJson: buildRollupJson(env, payload, body, { projectId, workspaceId }), createdAt: now, expiresAt, uploadedByNodeId, @@ -1031,7 +1060,7 @@ export function downsamplePreservingSpikes( return { samples: selected, downsampled: true }; } -async function readChunkPayload(env: Env, chunk: schema.WorkspaceResourceChunkRow) { +export async function readChunkPayload(env: Env, chunk: schema.WorkspaceResourceChunkRow) { const object = await env.PROJECT_DATA_ARCHIVE_R2.get(chunk.r2Key); if (!object) throw errors.notFound('Resource history chunk'); const compressedBytes = new Uint8Array(await object.arrayBuffer()); diff --git a/apps/api/src/services/workspace-resource-rollup.ts b/apps/api/src/services/workspace-resource-rollup.ts new file mode 100644 index 0000000000..a04ae357a1 --- /dev/null +++ b/apps/api/src/services/workspace-resource-rollup.ts @@ -0,0 +1,244 @@ +/** + * Per-minute rollup of one resource-history chunk. + * + * Computed once on upload from the already-decoded chunk payload and stored in + * `workspace_resource_chunks.rollup_json`, so the session timeline can draw a + * whole multi-day session from D1 without downloading every R2 chunk. The + * layout is columnar (one array per metric) to keep a 15-minute chunk's rollup + * around a kilobyte. + */ +import type { Env } from '../env'; +import { parsePositiveInt } from '../lib/route-helpers'; +import type { ResourceSamplePoint, ResourceToolSpan } from './workspace-resource-history'; + +export const WORKSPACE_RESOURCE_ROLLUP_VERSION = 1; +const DEFAULT_ROLLUP_BUCKET_MS = 60_000; +const DEFAULT_ROLLUP_MAX_BUCKETS = 60; + +/** Columnar rollup. Every array has one entry per bucket, ascending by `start`. */ +export interface WorkspaceResourceRollup { + v: typeof WORKSPACE_RESOURCE_ROLLUP_VERSION; + bucketMs: number; + start: number[]; + end: number[]; + /** Measured samples in the bucket (gaps and unsupported samples excluded). */ + samples: number[]; + /** CPU in cores (1 = one core fully busy). */ + cpuMeanCores: Array; + cpuMaxCores: Array; + memoryMeanBytes: Array; + memoryMaxBytes: Array; + workingSetMeanBytes: Array; + workingSetMaxBytes: Array; + ioReadBytes: Array; + ioWriteBytes: Array; + oomKills: number[]; + toolCallStarts: number[]; +} + +export interface RollupConfig { + bucketMs: number; + maxBuckets: number; +} + +export function getRollupConfig(env: Env): RollupConfig { + return { + bucketMs: parsePositiveInt(env.WORKSPACE_RESOURCE_ROLLUP_BUCKET_MS, DEFAULT_ROLLUP_BUCKET_MS), + maxBuckets: parsePositiveInt( + env.WORKSPACE_RESOURCE_ROLLUP_MAX_BUCKETS, + DEFAULT_ROLLUP_MAX_BUCKETS + ), + }; +} + +interface Accumulator { + start: number; + samples: number; + cpuSum: number; + cpuCount: number; + cpuMax: number | null; + memorySum: number; + memoryCount: number; + memoryMax: number | null; + workingSetSum: number; + workingSetCount: number; + workingSetMax: number | null; + ioRead: number | null; + ioWrite: number | null; + oomKills: number; + toolCallStarts: number; +} + +function finiteNonNegative(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; +} + +function emptyAccumulator(start: number): Accumulator { + return { + start, + samples: 0, + cpuSum: 0, + cpuCount: 0, + cpuMax: null, + memorySum: 0, + memoryCount: 0, + memoryMax: null, + workingSetSum: 0, + workingSetCount: 0, + workingSetMax: null, + ioRead: null, + ioWrite: null, + oomKills: 0, + toolCallStarts: 0, + }; +} + +function max(current: number | null, value: number): number { + return current == null ? value : Math.max(current, value); +} + +/** + * Bucket width: the configured width, widened in whole multiples until the + * chunk's span fits in `maxBuckets`. A normal 15-minute chunk keeps one-minute + * buckets; a malformed or unusually long one cannot produce an unbounded rollup. + */ +function bucketWidth(spanMs: number, config: RollupConfig): number { + const needed = Math.ceil(Math.max(spanMs, 1) / config.maxBuckets); + return Math.max(1, Math.ceil(needed / config.bucketMs)) * config.bucketMs; +} + +export function computeWorkspaceResourceRollup( + payload: { samples?: ResourceSamplePoint[]; toolSpans?: ResourceToolSpan[] }, + window: { startedAt: number; endedAt: number }, + config: RollupConfig +): WorkspaceResourceRollup { + const width = bucketWidth(window.endedAt - window.startedAt, config); + const buckets = new Map(); + const bucketFor = (t: number): Accumulator => { + // Clamp into the chunk window so a stray timestamp cannot add buckets beyond the bound. + const clamped = Math.min(Math.max(t, window.startedAt), window.endedAt); + const key = Math.floor(clamped / width) * width; + let bucket = buckets.get(key); + if (!bucket) { + bucket = emptyAccumulator(key); + buckets.set(key, bucket); + } + return bucket; + }; + + for (const sample of payload.samples ?? []) { + if (sample.gap || sample.unsupported) continue; + const bucket = bucketFor(sample.t); + bucket.samples += 1; + + const intervalMs = finiteNonNegative(sample.intervalMillis); + const cpuMillis = finiteNonNegative(sample.cpuMillis); + if (intervalMs && cpuMillis != null) { + const cores = cpuMillis / intervalMs; + bucket.cpuSum += cores; + bucket.cpuCount += 1; + bucket.cpuMax = max(bucket.cpuMax, cores); + } + + const memory = finiteNonNegative(sample.memoryBytes); + if (memory != null) { + bucket.memorySum += memory; + bucket.memoryCount += 1; + bucket.memoryMax = max(bucket.memoryMax, memory); + } + // The kernel's memory.peak catches spikes between two samples. + const memoryPeak = finiteNonNegative(sample.memoryPeakBytes); + if (memoryPeak != null) bucket.memoryMax = max(bucket.memoryMax, memoryPeak); + + const workingSet = finiteNonNegative(sample.memoryWorkingSetBytes); + if (workingSet != null) { + bucket.workingSetSum += workingSet; + bucket.workingSetCount += 1; + bucket.workingSetMax = max(bucket.workingSetMax, workingSet); + } + + // I/O counters are per-sample deltas, so the bucket total is their sum. + const ioRead = finiteNonNegative(sample.ioReadBytes); + if (ioRead != null) bucket.ioRead = (bucket.ioRead ?? 0) + ioRead; + const ioWrite = finiteNonNegative(sample.ioWriteBytes); + if (ioWrite != null) bucket.ioWrite = (bucket.ioWrite ?? 0) + ioWrite; + + // Same accounting as the collector's chunk summary: oom + oom_kill deltas. + bucket.oomKills += (finiteNonNegative(sample.oom) ?? 0) + (finiteNonNegative(sample.oomKill) ?? 0); + } + + for (const span of payload.toolSpans ?? []) { + bucketFor(span.startedAt).toolCallStarts += 1; + } + + const ordered = [...buckets.values()].sort((a, b) => a.start - b.start); + const mean = (sum: number, count: number) => (count > 0 ? sum / count : null); + return { + v: WORKSPACE_RESOURCE_ROLLUP_VERSION, + bucketMs: width, + start: ordered.map((b) => Math.max(b.start, window.startedAt)), + end: ordered.map((b) => Math.min(b.start + width, window.endedAt)), + samples: ordered.map((b) => b.samples), + cpuMeanCores: ordered.map((b) => mean(b.cpuSum, b.cpuCount)), + cpuMaxCores: ordered.map((b) => b.cpuMax), + memoryMeanBytes: ordered.map((b) => { + const value = mean(b.memorySum, b.memoryCount); + return value == null ? null : Math.round(value); + }), + memoryMaxBytes: ordered.map((b) => b.memoryMax), + workingSetMeanBytes: ordered.map((b) => { + const value = mean(b.workingSetSum, b.workingSetCount); + return value == null ? null : Math.round(value); + }), + workingSetMaxBytes: ordered.map((b) => b.workingSetMax), + ioReadBytes: ordered.map((b) => b.ioRead), + ioWriteBytes: ordered.map((b) => b.ioWrite), + oomKills: ordered.map((b) => b.oomKills), + toolCallStarts: ordered.map((b) => b.toolCallStarts), + }; +} + +const ROLLUP_COLUMNS = [ + 'start', + 'end', + 'samples', + 'cpuMeanCores', + 'cpuMaxCores', + 'memoryMeanBytes', + 'memoryMaxBytes', + 'workingSetMeanBytes', + 'workingSetMaxBytes', + 'ioReadBytes', + 'ioWriteBytes', + 'oomKills', + 'toolCallStarts', +] as const; + +/** + * Parses a stored rollup, or returns null when it is absent or malformed. A + * malformed rollup must never break the timeline: the caller falls back to the + * chunk summary. + */ +export function parseWorkspaceResourceRollup(raw: string | null): WorkspaceResourceRollup | null { + if (!raw) return null; + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return null; + } + if (typeof value !== 'object' || value === null) return null; + const record = value as Record; + if (record.v !== WORKSPACE_RESOURCE_ROLLUP_VERSION) return null; + if (typeof record.bucketMs !== 'number' || !(record.bucketMs > 0)) return null; + const starts = record.start; + if (!Array.isArray(starts)) return null; + for (const column of ROLLUP_COLUMNS) { + const entries = record[column]; + if (!Array.isArray(entries) || entries.length !== starts.length) return null; + if (!entries.every((entry) => entry === null || (typeof entry === 'number' && Number.isFinite(entry)))) { + return null; + } + } + return value as WorkspaceResourceRollup; +} diff --git a/apps/api/src/services/workspace-resource-timeline.ts b/apps/api/src/services/workspace-resource-timeline.ts new file mode 100644 index 0000000000..3fa1fe68df --- /dev/null +++ b/apps/api/src/services/workspace-resource-timeline.ts @@ -0,0 +1,273 @@ +/** + * Whole-session resource timeline. + * + * A session's resource history is stored as immutable ~15-minute chunks, one + * series per workspace lifetime (every wake provisions a fresh workspace). The + * index returns every chunk of the session with its per-minute rollup, so the + * client can draw the whole session from D1 alone and fetch full-resolution + * chunks only for the window the user zooms into. + */ +import { and, count, desc, eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/d1'; + +import * as schema from '../db/schema'; +import type { Env } from '../env'; +import { log } from '../lib/logger'; +import { parsePositiveInt } from '../lib/route-helpers'; +import { errors } from '../middleware/error'; +import { parseStoredResolvedReservationJson } from './resource-requirements-input'; +import { readChunkPayload } from './workspace-resource-history'; +import { + parseWorkspaceResourceRollup, + type WorkspaceResourceRollup, +} from './workspace-resource-rollup'; + +const DEFAULT_TIMELINE_MAX_CHUNKS = 1000; +/** `nodes.runtime` for Instant sessions, whose container runtime records no resource history yet. */ +const UNSUPPORTED_RUNTIMES: ReadonlySet = new Set(['cf-container']); + +export interface ResourceTimelineChunk { + id: string; + workspaceId: string; + startedAt: number; + endedAt: number; + sampleCount: number; + gapCount: number; + toolSpanCount: number; + summary: unknown; + completeness: unknown; + /** Per-minute rollup; null for chunks uploaded before rollups existed. */ + rollup: WorkspaceResourceRollup | null; +} + +export interface ResourceTimelineRun { + workspaceId: string; + nodeId: string | null; + runtime: string | null; + startedAt: number; + endedAt: number; + /** What the workspace reserved; null when unknown or unparseable. */ + reservation: { cpuMillis: number; memoryMb: number } | null; +} + +export interface ResourceTimelineIndexResponse { + sessionId: string; + runs: ResourceTimelineRun[]; + /** Ascending by `startedAt`. */ + chunks: ResourceTimelineChunk[]; + totalChunkCount: number; + /** Oldest chunks left out because the session exceeds `maxChunks`. */ + omittedChunkCount: number; + maxChunks: number; + /** + * `unsupported` when nothing is recorded and the session's runtime does not + * collect resource history; `pending` when nothing has been uploaded yet. + */ + collection: 'collected' | 'pending' | 'unsupported'; + runtime: string | null; +} + +export function getTimelineMaxChunks(env: Env): number { + return parsePositiveInt(env.WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS, DEFAULT_TIMELINE_MAX_CHUNKS); +} + +function jsonOrNull(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function timelineChunk(row: schema.WorkspaceResourceChunkRow): ResourceTimelineChunk { + return { + id: row.id, + workspaceId: row.workspaceId, + startedAt: row.startedAt, + endedAt: row.endedAt, + sampleCount: row.sampleCount, + gapCount: row.gapCount, + toolSpanCount: row.toolSpanCount, + summary: jsonOrNull(row.summaryJson), + completeness: jsonOrNull(row.completenessJson), + rollup: parseWorkspaceResourceRollup(row.rollupJson), + }; +} + +interface RunWorkspaceRow { + id: string; + node_id: string | null; + resolved_reservation_json: string | null; + runtime: string | null; +} + +function reservationOf(row: RunWorkspaceRow): ResourceTimelineRun['reservation'] { + try { + const reservation = parseStoredResolvedReservationJson(row.resolved_reservation_json); + return reservation ? { cpuMillis: reservation.cpuMillis, memoryMb: reservation.memoryMb } : null; + } catch (error) { + // A malformed reservation hides the reservation line for that run; it must not fail the index. + log.warn('workspace_resource_timeline.reservation_skipped', { + workspaceId: row.id, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +/** Workspaces behind the session's chunks, scoped to the project on both sides of the join. */ +async function loadRunWorkspaces( + env: Env, + projectId: string, + sessionId: string +): Promise> { + const { results } = await env.DATABASE.prepare( + `SELECT w.id, w.node_id, w.resolved_reservation_json, n.runtime + FROM workspaces w + LEFT JOIN nodes n ON n.id = w.node_id + WHERE w.project_id = ? + AND w.id IN ( + SELECT DISTINCT workspace_id + FROM workspace_resource_chunks + WHERE project_id = ? AND session_id = ? + )` + ) + .bind(projectId, projectId, sessionId) + .all(); + return new Map(results.map((row) => [row.id, row])); +} + +/** Runtime of the session's current workspace, used only to explain an empty timeline. */ +async function loadSessionRuntime( + env: Env, + projectId: string, + sessionId: string +): Promise { + const row = await env.DATABASE.prepare( + `SELECT n.runtime + FROM workspaces w + JOIN nodes n ON n.id = w.node_id + WHERE w.project_id = ? AND w.chat_session_id = ? + ORDER BY w.created_at DESC + LIMIT 1` + ) + .bind(projectId, sessionId) + .first<{ runtime: string | null }>(); + return row?.runtime ?? null; +} + +function groupRuns( + chunks: readonly ResourceTimelineChunk[], + workspaces: ReadonlyMap +): ResourceTimelineRun[] { + const runs = new Map(); + for (const chunk of chunks) { + const existing = runs.get(chunk.workspaceId); + if (existing) { + existing.startedAt = Math.min(existing.startedAt, chunk.startedAt); + existing.endedAt = Math.max(existing.endedAt, chunk.endedAt); + continue; + } + const workspace = workspaces.get(chunk.workspaceId); + runs.set(chunk.workspaceId, { + workspaceId: chunk.workspaceId, + nodeId: workspace?.node_id ?? null, + runtime: workspace?.runtime ?? null, + startedAt: chunk.startedAt, + endedAt: chunk.endedAt, + reservation: workspace ? reservationOf(workspace) : null, + }); + } + return [...runs.values()].sort((a, b) => a.startedAt - b.startedAt); +} + +export async function getSessionResourceTimeline( + env: Env, + input: { projectId: string; sessionId: string } +): Promise { + const { projectId, sessionId } = input; + const db = drizzle(env.DATABASE, { schema }); + const maxChunks = getTimelineMaxChunks(env); + const scope = and( + eq(schema.workspaceResourceChunks.projectId, projectId), + eq(schema.workspaceResourceChunks.sessionId, sessionId) + ); + + // Newest first so that, past the cap, it is the oldest chunks that are left out (and disclosed). + const rows = await db + .select() + .from(schema.workspaceResourceChunks) + .where(scope) + .orderBy(desc(schema.workspaceResourceChunks.startedAt), desc(schema.workspaceResourceChunks.id)) + .limit(maxChunks); + + let totalChunkCount = rows.length; + if (rows.length >= maxChunks) { + const [total] = await db.select({ value: count() }).from(schema.workspaceResourceChunks).where(scope); + totalChunkCount = total?.value ?? rows.length; + } + + const chunks: ResourceTimelineChunk[] = []; + for (const row of rows) { + try { + chunks.push(timelineChunk(row)); + } catch (error) { + log.warn('workspace_resource_timeline.chunk_skipped', { + projectId, + sessionId, + chunkId: row.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + chunks.reverse(); + + if (chunks.length === 0) { + const runtime = await loadSessionRuntime(env, projectId, sessionId); + return { + sessionId, + runs: [], + chunks, + totalChunkCount, + omittedChunkCount: 0, + maxChunks, + collection: runtime && UNSUPPORTED_RUNTIMES.has(runtime) ? 'unsupported' : 'pending', + runtime, + }; + } + + const runs = groupRuns(chunks, await loadRunWorkspaces(env, projectId, sessionId)); + return { + sessionId, + runs, + chunks, + totalChunkCount, + omittedChunkCount: Math.max(0, totalChunkCount - rows.length), + maxChunks, + collection: 'collected', + runtime: runs.at(-1)?.runtime ?? null, + }; +} + +export async function getSessionResourceTimelineChunk( + env: Env, + input: { projectId: string; sessionId: string; chunkId: string } +) { + const db = drizzle(env.DATABASE, { schema }); + const chunk = await db + .select() + .from(schema.workspaceResourceChunks) + .where( + and( + eq(schema.workspaceResourceChunks.id, input.chunkId), + eq(schema.workspaceResourceChunks.projectId, input.projectId), + eq(schema.workspaceResourceChunks.sessionId, input.sessionId) + ) + ) + .get(); + // Defence in depth: the predicate already scopes, but a missing or foreign chunk must look identical. + if (!chunk || chunk.projectId !== input.projectId || chunk.sessionId !== input.sessionId) { + throw errors.notFound('Resource history chunk'); + } + return readChunkPayload(env, chunk); +} From efd59921b5fe101ddc45ccb9127a1794caeb9d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 09:16:59 +0000 Subject: [PATCH 08/22] test(api): cover resource rollups and the session timeline on a real SQL engine Co-Authored-By: Claude Opus 5.5 --- .../src/services/workspace-resource-rollup.ts | 13 +- .../services/workspace-resource-timeline.ts | 14 +- .../workspace-resource-history-routes.test.ts | 67 +++ .../unit/workspace-resource-timeline.test.ts | 465 ++++++++++++++++++ 4 files changed, 553 insertions(+), 6 deletions(-) create mode 100644 apps/api/tests/unit/workspace-resource-timeline.test.ts diff --git a/apps/api/src/services/workspace-resource-rollup.ts b/apps/api/src/services/workspace-resource-rollup.ts index a04ae357a1..9d942ed159 100644 --- a/apps/api/src/services/workspace-resource-rollup.ts +++ b/apps/api/src/services/workspace-resource-rollup.ts @@ -128,7 +128,9 @@ export function computeWorkspaceResourceRollup( for (const sample of payload.samples ?? []) { if (sample.gap || sample.unsupported) continue; - const bucket = bucketFor(sample.t); + // A sample's timestamp closes the interval it measures, (t - interval, t]; a + // sample stamped exactly on a minute boundary belongs to the minute it ends. + const bucket = bucketFor(sample.t - 1); bucket.samples += 1; const intervalMs = finiteNonNegative(sample.intervalMillis); @@ -164,7 +166,8 @@ export function computeWorkspaceResourceRollup( if (ioWrite != null) bucket.ioWrite = (bucket.ioWrite ?? 0) + ioWrite; // Same accounting as the collector's chunk summary: oom + oom_kill deltas. - bucket.oomKills += (finiteNonNegative(sample.oom) ?? 0) + (finiteNonNegative(sample.oomKill) ?? 0); + bucket.oomKills += + (finiteNonNegative(sample.oom) ?? 0) + (finiteNonNegative(sample.oomKill) ?? 0); } for (const span of payload.toolSpans ?? []) { @@ -236,7 +239,11 @@ export function parseWorkspaceResourceRollup(raw: string | null): WorkspaceResou for (const column of ROLLUP_COLUMNS) { const entries = record[column]; if (!Array.isArray(entries) || entries.length !== starts.length) return null; - if (!entries.every((entry) => entry === null || (typeof entry === 'number' && Number.isFinite(entry)))) { + if ( + !entries.every( + (entry) => entry === null || (typeof entry === 'number' && Number.isFinite(entry)) + ) + ) { return null; } } diff --git a/apps/api/src/services/workspace-resource-timeline.ts b/apps/api/src/services/workspace-resource-timeline.ts index 3fa1fe68df..83fe9d04fc 100644 --- a/apps/api/src/services/workspace-resource-timeline.ts +++ b/apps/api/src/services/workspace-resource-timeline.ts @@ -104,7 +104,9 @@ interface RunWorkspaceRow { function reservationOf(row: RunWorkspaceRow): ResourceTimelineRun['reservation'] { try { const reservation = parseStoredResolvedReservationJson(row.resolved_reservation_json); - return reservation ? { cpuMillis: reservation.cpuMillis, memoryMb: reservation.memoryMb } : null; + return reservation + ? { cpuMillis: reservation.cpuMillis, memoryMb: reservation.memoryMb } + : null; } catch (error) { // A malformed reservation hides the reservation line for that run; it must not fail the index. log.warn('workspace_resource_timeline.reservation_skipped', { @@ -198,12 +200,18 @@ export async function getSessionResourceTimeline( .select() .from(schema.workspaceResourceChunks) .where(scope) - .orderBy(desc(schema.workspaceResourceChunks.startedAt), desc(schema.workspaceResourceChunks.id)) + .orderBy( + desc(schema.workspaceResourceChunks.startedAt), + desc(schema.workspaceResourceChunks.id) + ) .limit(maxChunks); let totalChunkCount = rows.length; if (rows.length >= maxChunks) { - const [total] = await db.select({ value: count() }).from(schema.workspaceResourceChunks).where(scope); + const [total] = await db + .select({ value: count() }) + .from(schema.workspaceResourceChunks) + .where(scope); totalChunkCount = total?.value ?? rows.length; } diff --git a/apps/api/tests/unit/routes/workspace-resource-history-routes.test.ts b/apps/api/tests/unit/routes/workspace-resource-history-routes.test.ts index a4a0614cc7..7cee0056ae 100644 --- a/apps/api/tests/unit/routes/workspace-resource-history-routes.test.ts +++ b/apps/api/tests/unit/routes/workspace-resource-history-routes.test.ts @@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({ db: { id: 'mock-db' }, requireProjectAccess: vi.fn(), getWorkspaceResourceHistory: vi.fn(), + getSessionResourceTimeline: vi.fn(), + getSessionResourceTimelineChunk: vi.fn(), })); vi.mock('drizzle-orm/d1', () => ({ @@ -28,6 +30,11 @@ vi.mock('../../../src/services/workspace-resource-history', () => ({ getWorkspaceResourceHistory: mocks.getWorkspaceResourceHistory, })); +vi.mock('../../../src/services/workspace-resource-timeline', () => ({ + getSessionResourceTimeline: mocks.getSessionResourceTimeline, + getSessionResourceTimelineChunk: mocks.getSessionResourceTimelineChunk, +})); + describe('workspace resource history project routes', () => { let app: Hono<{ Bindings: Env }>; const env = { DATABASE: {} as Env['DATABASE'] } as Env; @@ -108,4 +115,64 @@ describe('workspace resource history project routes', () => { expect(res.status).toBe(404); expect(mocks.getWorkspaceResourceHistory).not.toHaveBeenCalled(); }); + + it('serves the whole-session timeline index after project authorization', async () => { + mocks.getSessionResourceTimeline.mockResolvedValueOnce({ sessionId: 'sess-1', chunks: [] }); + + const res = await app.request( + '/api/projects/proj-1/sessions/sess-1/resource-timeline', + {}, + env + ); + + expect(res.status).toBe(200); + expect(mocks.requireProjectAccess).toHaveBeenCalledWith(mocks.db, 'proj-1', 'member-1'); + expect(mocks.getSessionResourceTimeline).toHaveBeenCalledWith(env, { + projectId: 'proj-1', + sessionId: 'sess-1', + }); + expect(await res.json()).toEqual({ sessionId: 'sess-1', chunks: [] }); + }); + + it('serves one timeline chunk scoped to the project and session in the path', async () => { + mocks.getSessionResourceTimelineChunk.mockResolvedValueOnce({ + chunkId: 'wrchunk:1', + samples: [], + }); + + const res = await app.request( + '/api/projects/proj-1/sessions/sess-1/resource-timeline/chunks/wrchunk%3A1', + {}, + env + ); + + expect(res.status).toBe(200); + expect(mocks.getSessionResourceTimelineChunk).toHaveBeenCalledWith(env, { + projectId: 'proj-1', + sessionId: 'sess-1', + chunkId: 'wrchunk:1', + }); + }); + + it('reads no timeline data when project access is denied', async () => { + mocks.requireProjectAccess.mockRejectedValue( + Object.assign(new Error('Project not found'), { statusCode: 404, error: 'NOT_FOUND' }) + ); + + const index = await app.request( + '/api/projects/proj-1/sessions/sess-1/resource-timeline', + {}, + env + ); + const chunk = await app.request( + '/api/projects/proj-1/sessions/sess-1/resource-timeline/chunks/wrchunk%3A1', + {}, + env + ); + + expect(index.status).toBe(404); + expect(chunk.status).toBe(404); + expect(mocks.getSessionResourceTimeline).not.toHaveBeenCalled(); + expect(mocks.getSessionResourceTimelineChunk).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/tests/unit/workspace-resource-timeline.test.ts b/apps/api/tests/unit/workspace-resource-timeline.test.ts new file mode 100644 index 0000000000..9da5df377c --- /dev/null +++ b/apps/api/tests/unit/workspace-resource-timeline.test.ts @@ -0,0 +1,465 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +import * as schema from '../../src/db/schema'; +import type { Env } from '../../src/env'; +import { + storeWorkspaceResourceChunk, + type WorkspaceResourceUploadBody, +} from '../../src/services/workspace-resource-history'; +import { + computeWorkspaceResourceRollup, + parseWorkspaceResourceRollup, +} from '../../src/services/workspace-resource-rollup'; +import { + getSessionResourceTimeline, + getSessionResourceTimelineChunk, +} from '../../src/services/workspace-resource-timeline'; +import { createSchemaTables, createSqliteD1 } from '../helpers/sqlite-d1'; + +const MINUTE = 60_000; +const T0 = 1_790_000_000_000 - (1_790_000_000_000 % (15 * MINUTE)); +const CONFIG = { bucketMs: MINUTE, maxBuckets: 60 }; +const RESERVATION = { + cpuMillis: 2_000, + memoryMb: 4_096, + diskMb: 40_960, + exclusiveNode: false, + source: 'platform', + sourceId: 'platform', + version: 1, +}; + +function base64(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +async function gzipJson(value: unknown): Promise { + const stream = new Blob([JSON.stringify(value)]) + .stream() + .pipeThrough(new CompressionStream('gzip')); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +function makeR2(): R2Bucket { + const objects = new Map(); + return { + put: async (key: string, value: Uint8Array) => { + objects.set(key, value); + return null; + }, + delete: async (key: string) => { + objects.delete(key); + }, + get: async (key: string) => { + const value = objects.get(key); + if (!value) return null; + return { + arrayBuffer: async () => + value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength), + }; + }, + } as unknown as R2Bucket; +} + +/** Twelve 5-second samples per minute across a 15-minute chunk. */ +function chunkPayload(start: number, cpuMillis = 2_500) { + const samples = Array.from({ length: 180 }, (_, index) => ({ + t: start + (index + 1) * 5_000, + intervalMillis: 5_000, + cpuMillis, + memoryBytes: 1_000_000 + index, + ioReadBytes: 10, + ioWriteBytes: 20, + })); + return { + samples, + toolSpans: [{ id: 'span-1', kind: 'execute', toolName: 'Bash', startedAt: start + 90_000 }], + }; +} + +async function upload( + env: Env, + input: { + projectId: string; + workspaceId: string; + sessionId: string; + sequence: number; + start: number; + } +) { + const payload = chunkPayload(input.start); + const json = JSON.stringify(payload); + const compressed = await gzipJson(payload); + const body: WorkspaceResourceUploadBody = { + workspaceId: input.workspaceId, + sessionId: input.sessionId, + sourceVersion: 1, + chunkSequence: input.sequence, + startedAt: input.start, + endedAt: input.start + 15 * MINUTE, + sampleCount: payload.samples.length, + toolSpanCount: 1, + compressedBase64: base64(compressed), + compressedBytes: compressed.byteLength, + uncompressedBytes: new TextEncoder().encode(json).byteLength, + sha256: await sha256Hex(compressed), + completeness: { status: 'complete' }, + summary: { cpuMeanMillis: 2_500, cpuPeakMillis: 2_500, sampleIntervalMillis: 5_000 }, + }; + return storeWorkspaceResourceChunk(env, input.projectId, body, null); +} + +function setup(envOverrides: Record = {}) { + const sqlite = new Database(':memory:'); + createSchemaTables(sqlite, [ + schema.nodes, + schema.workspaces, + schema.workspaceResourceSummaries, + schema.workspaceResourceChunks, + ]); + const env = { + DATABASE: createSqliteD1(sqlite), + PROJECT_DATA_ARCHIVE_R2: makeR2(), + ...envOverrides, + } as unknown as Env; + const addNode = (id: string, runtime: string) => + sqlite.prepare(`INSERT INTO nodes (id, runtime) VALUES (?, ?)`).run(id, runtime); + const addWorkspace = ( + id: string, + projectId: string, + nodeId: string, + sessionId: string | null, + reservation: string | null = null, + createdAt = '2026-09-29T00:00:00Z' + ) => + sqlite + .prepare( + `INSERT INTO workspaces (id, project_id, node_id, chat_session_id, resolved_reservation_json, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .run(id, projectId, nodeId, sessionId, reservation, createdAt); + return { sqlite, env, addNode, addWorkspace }; +} + +describe('computeWorkspaceResourceRollup', () => { + it('buckets samples per minute with CPU in cores, memory mean/max and summed I/O', () => { + const rollup = computeWorkspaceResourceRollup( + chunkPayload(T0), + { startedAt: T0, endedAt: T0 + 15 * MINUTE }, + CONFIG + ); + + expect(rollup.bucketMs).toBe(MINUTE); + expect(rollup.start).toHaveLength(15); + expect(rollup.start[0]).toBe(T0); + expect(rollup.end.at(-1)).toBe(T0 + 15 * MINUTE); + // 2 500 ms of CPU in a 5 000 ms interval is half a core. + expect(rollup.cpuMeanCores[0]).toBeCloseTo(0.5); + expect(rollup.cpuMaxCores[0]).toBeCloseTo(0.5); + expect(rollup.memoryMaxBytes[0]).toBeGreaterThanOrEqual(rollup.memoryMeanBytes[0] ?? Infinity); + // A sample stamped T0+60s closes the first minute, so every minute holds exactly twelve. + expect(rollup.samples).toEqual(Array.from({ length: 15 }, () => 12)); + expect(rollup.ioReadBytes.reduce((a, b) => a + (b ?? 0), 0)).toBe(1_800); + expect(rollup.toolCallStarts[1]).toBe(1); + expect(rollup.workingSetMeanBytes.every((value) => value === null)).toBe(true); + }); + + it('skips gap and unsupported samples, reads working set and memory.peak, and counts OOM kills', () => { + const rollup = computeWorkspaceResourceRollup( + { + samples: [ + { + t: T0 + 5_000, + intervalMillis: 5_000, + cpuMillis: 5_000, + memoryBytes: 100, + memoryWorkingSetBytes: 60, + }, + { t: T0 + 10_000, gap: true, cpuMillis: 99_999, memoryBytes: 99_999 }, + { t: T0 + 15_000, unsupported: 'no cgroup', memoryBytes: 99_999 }, + { + t: T0 + 20_000, + intervalMillis: 5_000, + cpuMillis: 0, + memoryBytes: 300, + memoryPeakBytes: 900, + memoryWorkingSetBytes: 80, + oom: 1, + oomKill: 1, + }, + ], + }, + { startedAt: T0, endedAt: T0 + MINUTE }, + CONFIG + ); + + expect(rollup.samples).toEqual([2]); + expect(rollup.cpuMeanCores[0]).toBeCloseTo(0.5); + expect(rollup.cpuMaxCores[0]).toBeCloseTo(1); + expect(rollup.memoryMeanBytes).toEqual([200]); + expect(rollup.memoryMaxBytes).toEqual([900]); + expect(rollup.workingSetMeanBytes).toEqual([70]); + expect(rollup.workingSetMaxBytes).toEqual([80]); + expect(rollup.oomKills).toEqual([2]); + }); + + it('widens the bucket so a long or malformed window stays within the configured bucket count', () => { + const day = 24 * 60 * MINUTE; + const samples = Array.from({ length: 2_000 }, (_, index) => ({ + t: T0 + index * 43_000, + intervalMillis: 5_000, + cpuMillis: 100, + })); + const rollup = computeWorkspaceResourceRollup( + { samples: [...samples, { t: T0 + 50 * day, intervalMillis: 5_000, cpuMillis: 1 }] }, + { startedAt: T0, endedAt: T0 + day }, + { bucketMs: MINUTE, maxBuckets: 10 } + ); + + expect(rollup.start.length).toBeLessThanOrEqual(11); + expect(rollup.bucketMs % MINUTE).toBe(0); + expect(rollup.end.at(-1)).toBeLessThanOrEqual(T0 + day); + }); + + it('round-trips through JSON and rejects malformed stored rollups', () => { + const rollup = computeWorkspaceResourceRollup( + chunkPayload(T0), + { startedAt: T0, endedAt: T0 + 15 * MINUTE }, + CONFIG + ); + expect(parseWorkspaceResourceRollup(JSON.stringify(rollup))).toEqual(rollup); + expect(parseWorkspaceResourceRollup(null)).toBeNull(); + expect(parseWorkspaceResourceRollup('{not json')).toBeNull(); + expect(parseWorkspaceResourceRollup(JSON.stringify({ ...rollup, v: 99 }))).toBeNull(); + expect( + parseWorkspaceResourceRollup(JSON.stringify({ ...rollup, cpuMeanCores: [1] })) + ).toBeNull(); + expect( + parseWorkspaceResourceRollup( + JSON.stringify({ ...rollup, samples: rollup.samples.map(() => 'x') }) + ) + ).toBeNull(); + }); +}); + +describe('session resource timeline', () => { + it('stores a rollup on upload and serves every chunk of a multi-wake session in order', async () => { + const { env, addNode, addWorkspace } = setup(); + addNode('node-a', 'vm'); + addNode('node-b', 'vm'); + addWorkspace('ws-wake-1', 'proj-1', 'node-a', null, JSON.stringify(RESERVATION)); + addWorkspace('ws-wake-2', 'proj-1', 'node-b', 'sess-1', null); + await upload(env, { + projectId: 'proj-1', + workspaceId: 'ws-wake-1', + sessionId: 'sess-1', + sequence: 0, + start: T0, + }); + await upload(env, { + projectId: 'proj-1', + workspaceId: 'ws-wake-1', + sessionId: 'sess-1', + sequence: 1, + start: T0 + 15 * MINUTE, + }); + await upload(env, { + projectId: 'proj-1', + workspaceId: 'ws-wake-2', + sessionId: 'sess-1', + sequence: 0, + start: T0 + 10 * 60 * MINUTE, + }); + + const timeline = await getSessionResourceTimeline(env, { + projectId: 'proj-1', + sessionId: 'sess-1', + }); + + expect(timeline.collection).toBe('collected'); + expect(timeline.chunks.map((chunk) => chunk.startedAt)).toEqual([ + T0, + T0 + 15 * MINUTE, + T0 + 10 * 60 * MINUTE, + ]); + expect(timeline.totalChunkCount).toBe(3); + expect(timeline.omittedChunkCount).toBe(0); + expect(timeline.chunks.every((chunk) => chunk.rollup?.start.length === 15)).toBe(true); + expect(timeline.runs.map((run) => [run.workspaceId, run.startedAt, run.endedAt])).toEqual([ + ['ws-wake-1', T0, T0 + 30 * MINUTE], + ['ws-wake-2', T0 + 10 * 60 * MINUTE, T0 + 10 * 60 * MINUTE + 15 * MINUTE], + ]); + expect(timeline.runs[0]?.reservation).toEqual({ cpuMillis: 2_000, memoryMb: 4_096 }); + expect(timeline.runs[1]?.reservation).toBeNull(); + expect(timeline.runtime).toBe('vm'); + }); + + it('excludes another project and another session (attack) while serving the owner (control)', async () => { + const { sqlite, env, addNode, addWorkspace } = setup(); + addNode('node-a', 'vm'); + addWorkspace('ws-owner', 'proj-1', 'node-a', 'sess-1'); + addWorkspace('ws-foreign', 'proj-2', 'node-a', 'sess-1'); + addWorkspace('ws-other-session', 'proj-1', 'node-a', 'sess-2'); + await upload(env, { + projectId: 'proj-1', + workspaceId: 'ws-owner', + sessionId: 'sess-1', + sequence: 0, + start: T0, + }); + await upload(env, { + projectId: 'proj-2', + workspaceId: 'ws-foreign', + sessionId: 'sess-1', + sequence: 0, + start: T0, + }); + await upload(env, { + projectId: 'proj-1', + workspaceId: 'ws-other-session', + sessionId: 'sess-2', + sequence: 0, + start: T0, + }); + expect(sqlite.prepare('SELECT COUNT(*) AS n FROM workspace_resource_chunks').get()).toEqual({ + n: 3, + }); + + const timeline = await getSessionResourceTimeline(env, { + projectId: 'proj-1', + sessionId: 'sess-1', + }); + + expect(timeline.chunks.map((chunk) => chunk.workspaceId)).toEqual(['ws-owner']); + expect(timeline.runs.map((run) => run.workspaceId)).toEqual(['ws-owner']); + }); + + it('keeps the newest chunks past the cap and discloses how many older ones were left out', async () => { + const { env, addNode, addWorkspace } = setup({ WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS: '2' }); + addNode('node-a', 'vm'); + addWorkspace('ws-1', 'proj-1', 'node-a', 'sess-1'); + for (let sequence = 0; sequence < 3; sequence += 1) { + await upload(env, { + projectId: 'proj-1', + workspaceId: 'ws-1', + sessionId: 'sess-1', + sequence, + start: T0 + sequence * 15 * MINUTE, + }); + } + + const timeline = await getSessionResourceTimeline(env, { + projectId: 'proj-1', + sessionId: 'sess-1', + }); + + expect(timeline.maxChunks).toBe(2); + expect(timeline.totalChunkCount).toBe(3); + expect(timeline.omittedChunkCount).toBe(1); + expect(timeline.chunks.map((chunk) => chunk.startedAt)).toEqual([ + T0 + 15 * MINUTE, + T0 + 30 * MINUTE, + ]); + }); + + it('tolerates a malformed rollup and a malformed reservation without failing the index', async () => { + const { sqlite, env, addNode, addWorkspace } = setup(); + addNode('node-a', 'vm'); + addWorkspace('ws-1', 'proj-1', 'node-a', 'sess-1', '{"source":"nope"}'); + await upload(env, { + projectId: 'proj-1', + workspaceId: 'ws-1', + sessionId: 'sess-1', + sequence: 0, + start: T0, + }); + await upload(env, { + projectId: 'proj-1', + workspaceId: 'ws-1', + sessionId: 'sess-1', + sequence: 1, + start: T0 + 15 * MINUTE, + }); + sqlite + .prepare( + `UPDATE workspace_resource_chunks SET rollup_json = '{broken' WHERE chunk_sequence = 0` + ) + .run(); + + const timeline = await getSessionResourceTimeline(env, { + projectId: 'proj-1', + sessionId: 'sess-1', + }); + + expect(timeline.chunks).toHaveLength(2); + expect(timeline.chunks[0]?.rollup).toBeNull(); + expect(timeline.chunks[0]?.summary).toMatchObject({ cpuMeanMillis: 2_500 }); + expect(timeline.chunks[1]?.rollup).not.toBeNull(); + expect(timeline.runs[0]?.reservation).toBeNull(); + }); + + it('explains an empty timeline: Instant sessions are unsupported, VM sessions are pending', async () => { + const { env, addNode, addWorkspace } = setup(); + addNode('node-container', 'cf-container'); + addNode('node-vm', 'vm'); + addWorkspace('ws-instant', 'proj-1', 'node-container', 'sess-instant'); + addWorkspace('ws-vm', 'proj-1', 'node-vm', 'sess-vm'); + // Same session id in another project must not decide this project's answer. + addWorkspace('ws-foreign', 'proj-2', 'node-container', 'sess-vm', null, '2026-09-30T00:00:00Z'); + + const instant = await getSessionResourceTimeline(env, { + projectId: 'proj-1', + sessionId: 'sess-instant', + }); + const vm = await getSessionResourceTimeline(env, { projectId: 'proj-1', sessionId: 'sess-vm' }); + const unknown = await getSessionResourceTimeline(env, { + projectId: 'proj-1', + sessionId: 'sess-none', + }); + + expect(instant).toMatchObject({ + collection: 'unsupported', + runtime: 'cf-container', + chunks: [], + }); + expect(vm).toMatchObject({ collection: 'pending', runtime: 'vm', chunks: [] }); + expect(unknown).toMatchObject({ collection: 'pending', runtime: null }); + }); + + it('reads a chunk only through its own project and session', async () => { + const { env, addNode, addWorkspace } = setup(); + addNode('node-a', 'vm'); + addWorkspace('ws-1', 'proj-1', 'node-a', 'sess-1'); + const { chunkId } = await upload(env, { + projectId: 'proj-1', + workspaceId: 'ws-1', + sessionId: 'sess-1', + sequence: 0, + start: T0, + }); + + const owner = await getSessionResourceTimelineChunk(env, { + projectId: 'proj-1', + sessionId: 'sess-1', + chunkId, + }); + expect(owner.chunkId).toBe(chunkId); + expect(owner.samples).toHaveLength(180); + expect(owner.toolSpans[0]).toMatchObject({ toolName: 'Bash' }); + + await expect( + getSessionResourceTimelineChunk(env, { projectId: 'proj-1', sessionId: 'sess-2', chunkId }) + ).rejects.toMatchObject({ statusCode: 404 }); + await expect( + getSessionResourceTimelineChunk(env, { projectId: 'proj-2', sessionId: 'sess-1', chunkId }) + ).rejects.toMatchObject({ statusCode: 404 }); + }); +}); From dc63915cd880eb73efdee274ba623f6b8fb63355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 09:24:24 +0000 Subject: [PATCH 09/22] feat(web): read the whole-session resource timeline, drop the prototype route The drawer now reads /resource-timeline (every chunk, per-minute rollups, per-run reservations, collection state) and fetches 5 s chunks on zoom from /resource-timeline/chunks/:id. The prototype page and its mock data are gone; the stress scenarios live on as a Playwright fixture that serves real API shapes to the real chat rail. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/App.tsx | 10 - .../resource-timeline/ResourceTimeline.tsx | 28 +- .../chat/resource-timeline/chart-theme.ts | 3 + .../chat/resource-timeline/panels.ts | 77 ++- .../chat/resource-timeline/resource-source.ts | 154 ++--- .../chat/resource-timeline/types.ts | 25 +- apps/web/src/lib/api/index.ts | 16 +- apps/web/src/lib/api/resource-timeline.ts | 114 ++++ apps/web/src/lib/api/sessions.ts | 111 ---- .../resource-timeline-prototype/index.tsx | 132 ----- .../mock-sessions.ts | 404 ------------- .../mock-source.ts | 179 ------ .../playwright/resource-timeline-scenarios.ts | 549 ++++++++++++++++++ .../session-tool-rail-audit.spec.ts | 233 +++----- apps/web/tests/unit/app-routes.test.tsx | 2 - .../resource-timeline/readout.test.ts | 2 +- .../resource-timeline/resource-source.test.ts | 241 ++++++-- .../resource-timeline/series.test.ts | 2 +- .../resource-timeline/time-axis.test.ts | 2 +- .../resource-timeline/timeline-view.test.ts | 2 +- 20 files changed, 1130 insertions(+), 1156 deletions(-) create mode 100644 apps/web/src/lib/api/resource-timeline.ts delete mode 100644 apps/web/src/pages/resource-timeline-prototype/index.tsx delete mode 100644 apps/web/src/pages/resource-timeline-prototype/mock-sessions.ts delete mode 100644 apps/web/src/pages/resource-timeline-prototype/mock-source.ts create mode 100644 apps/web/tests/playwright/resource-timeline-scenarios.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e94792a374..7eadf3b938 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -145,10 +145,6 @@ const ProjectTriggerDetail = lazyNamed( ); const ProjectTriggers = lazyNamed(() => import('./pages/ProjectTriggers'), 'ProjectTriggers'); const ProjectEvents = lazyNamed(() => import('./pages/ProjectEvents'), 'ProjectEvents'); -const ResourceTimelinePrototype = lazyNamed( - () => import('./pages/resource-timeline-prototype'), - 'ResourceTimelinePrototype' -); const SamPrototype = lazyNamed(() => import('./pages/SamPrototype'), 'SamPrototype'); const Settings = lazyNamed(() => import('./pages/Settings'), 'Settings'); const SettingsAgents = lazyNamed(() => import('./pages/SettingsAgents'), 'SettingsAgents'); @@ -233,7 +229,6 @@ function SuperadminRoute({ children }: { children: ReactNode }) { export const DEV_ONLY_ROUTE_PATHS = [ '/sam', - '/prototype/resource-timeline', '/__test/trial-chat-gate', '/__test/error-boundary', '/ui-standards', @@ -274,11 +269,6 @@ export default function App() { <> {/* SAM prototype — local/test only, no auth */} )} /> - {/* Resource timeline prototype — mock sessions, never shipped */} - )} - /> {/* Harness for Playwright audits — mounts trial components with mock data */} ; + return indexQuery.data.collection === 'unsupported' ? ( + + ) : ( + + ); } return ; } @@ -65,6 +69,18 @@ function EmptyTimeline({ uploadIntervalMs }: Readonly<{ uploadIntervalMs: number ); } +function UnsupportedTimeline() { + return ( +
+

Not recorded for Instant sessions

+

+ Instant sessions run in a lightweight container that does not record CPU, memory or disk usage yet. Sessions on + a VM workspace record the full timeline. +

+
+ ); +} + function SessionSummary({ index, activeMs }: Readonly<{ index: ResourceTimelineIndex; activeMs: number }>) { const first = index.runs[0]?.startedAt ?? index.chunks[0]?.startedAt ?? 0; const last = index.chunks.at(-1)?.endedAt ?? first; @@ -85,7 +101,11 @@ function SessionSummary({ index, activeMs }: Readonly<{ index: ResourceTimelineI {index.completeness.kind === 'truncated' && (

)}
@@ -118,11 +138,11 @@ function TimelineBody({ source, index }: Readonly<{ source: ResourceHistorySourc viewMin: view.min, viewMax: view.max, series, - reservation: index.reservation, + runs: index.runs, toolSpans: data.toolSpans, hasWorkingSet, }), - [axis, view.min, view.max, series, index.reservation, data.toolSpans, hasWorkingSet] + [axis, view.min, view.max, series, index.runs, data.toolSpans, hasWorkingSet] ); const cursorX = cursorT == null ? null : toAxis(axis, cursorT); diff --git a/apps/web/src/components/chat/resource-timeline/chart-theme.ts b/apps/web/src/components/chat/resource-timeline/chart-theme.ts index 20dbf34656..392db608fe 100644 --- a/apps/web/src/components/chat/resource-timeline/chart-theme.ts +++ b/apps/web/src/components/chat/resource-timeline/chart-theme.ts @@ -81,10 +81,13 @@ function resolveChartTheme(theme: ResolvedTheme): ChartTheme { tools: { execute: ioWrite, edit: cpu, + delete: readToken(TOKENS.danger, theme), + move: withAlpha(cpu, 0.7), read: ioRead, search: memory, fetch: readToken(TOKENS.cyan, theme), think: withAlpha(muted, 0.7), + switch_mode: withAlpha(muted, 0.5), other: withAlpha(muted, 0.9), }, }; diff --git a/apps/web/src/components/chat/resource-timeline/panels.ts b/apps/web/src/components/chat/resource-timeline/panels.ts index a2e30c1625..57d4d5c3cf 100644 --- a/apps/web/src/components/chat/resource-timeline/panels.ts +++ b/apps/web/src/components/chat/resource-timeline/panels.ts @@ -14,7 +14,7 @@ import { type ChartTheme, withAlpha } from './chart-theme'; import { formatBytes, formatClock, formatCoresAxis, formatElapsed, formatRate } from './format'; import type { TimelineSeries } from './series'; import { axisTicks, sleeps, tickStepMs, type TimeAxis, toAxis, toReal } from './time-axis'; -import type { ResourceReservation, ResourceToolSpan } from './types'; +import type { ResourceRun, ResourceToolSpan } from './types'; export type PanelKind = 'cpu' | 'memory' | 'disk' | 'tools'; @@ -23,7 +23,8 @@ export interface PanelState { viewMin: number; viewMax: number; series: TimelineSeries; - reservation: ResourceReservation | null; + /** Each run carries its own reservation: a wake can land on a different size. */ + runs: readonly ResourceRun[]; toolSpans: readonly ResourceToolSpan[]; /** Whether the agent reports working-set memory (otherwise memory includes cache). */ hasWorkingSet: boolean; @@ -105,26 +106,48 @@ function drawSleeps(u: uPlot, state: PanelState, theme: ChartTheme, label: boole } } -function drawReservation(u: uPlot, value: number | null, theme: ChartTheme, label: string) { - if (value == null) return; - const y = u.valToPos(value, 'y', true); +type ReservationMetric = 'cpuCores' | 'memoryBytes'; + +function highestReservation(runs: readonly ResourceRun[], metric: ReservationMetric): number { + return Math.max(0, ...runs.map((run) => run.reservation?.[metric] ?? 0)); +} + +/** A dashed line at each run's reservation, spanning only that run. */ +function drawReservations( + u: uPlot, + state: PanelState, + metric: ReservationMetric, + theme: ChartTheme, + label: (value: number) => string +) { const { ctx, bbox } = u; - if (y < bbox.top || y > bbox.top + bbox.height) return; - ctx.save(); - ctx.strokeStyle = theme.reservation; - ctx.lineWidth = px(1); - ctx.setLineDash([px(4), px(3)]); - ctx.beginPath(); - ctx.moveTo(bbox.left, y); - ctx.lineTo(bbox.left + bbox.width, y); - ctx.stroke(); - ctx.setLineDash([]); - ctx.fillStyle = theme.mutedText; - ctx.font = `${px(10)}px system-ui, sans-serif`; - ctx.textAlign = 'right'; - ctx.textBaseline = 'bottom'; - ctx.fillText(label, bbox.left + bbox.width - px(4), y - px(2)); - ctx.restore(); + const right = bbox.left + bbox.width; + for (const run of state.runs) { + const value = run.reservation?.[metric]; + if (value == null) continue; + const x0 = Math.max(bbox.left, canvasX(u, toAxis(state.axis, run.startedAt))); + const x1 = Math.min(right, canvasX(u, toAxis(state.axis, run.endedAt))); + const y = u.valToPos(value, 'y', true); + if (x1 - x0 < 1 || y < bbox.top || y > bbox.top + bbox.height) continue; + ctx.save(); + ctx.strokeStyle = theme.reservation; + ctx.lineWidth = px(1); + ctx.setLineDash([px(4), px(3)]); + ctx.beginPath(); + ctx.moveTo(x0, y); + ctx.lineTo(x1, y); + ctx.stroke(); + ctx.setLineDash([]); + const text = `reserved ${label(value)}`; + ctx.font = `${px(10)}px system-ui, sans-serif`; + if (ctx.measureText(text).width + px(8) <= x1 - x0) { + ctx.fillStyle = theme.mutedText; + ctx.textAlign = 'right'; + ctx.textBaseline = 'bottom'; + ctx.fillText(text, x1 - px(4), y - px(2)); + } + ctx.restore(); + } } function drawOomMarkers(u: uPlot, state: PanelState, theme: ChartTheme) { @@ -308,10 +331,7 @@ export function panelOptions( }, ], draw: [ - (u) => { - const cores = stateRef.current.reservation?.cpuCores ?? null; - drawReservation(u, cores, theme, cores == null ? '' : `reserved ${formatCoresAxis(cores)}`); - }, + (u) => drawReservations(u, stateRef.current, 'cpuCores', theme, formatCoresAxis), ], }, scales: { @@ -319,7 +339,7 @@ export function panelOptions( y: { range: () => { const state = stateRef.current; - const top = Math.max(0.5, peak(state.series.cpuMax), state.reservation?.cpuCores ?? 0); + const top = Math.max(0.5, peak(state.series.cpuMax), highestReservation(state.runs, 'cpuCores')); return [0, top * 1.12]; }, }, @@ -340,8 +360,7 @@ export function panelOptions( draw: [ (u) => { const state = stateRef.current; - const bytes = state.reservation?.memoryBytes ?? null; - drawReservation(u, bytes, theme, bytes == null ? '' : `reserved ${formatBytes(bytes)}`); + drawReservations(u, state, 'memoryBytes', theme, formatBytes); drawOomMarkers(u, state, theme); }, ], @@ -354,7 +373,7 @@ export function panelOptions( const top = Math.max( 256 * 1024 ** 2, peak(state.series.memoryMax), - state.reservation?.memoryBytes ?? 0 + highestReservation(state.runs, 'memoryBytes') ); return [0, top * 1.1]; }, diff --git a/apps/web/src/components/chat/resource-timeline/resource-source.ts b/apps/web/src/components/chat/resource-timeline/resource-source.ts index 71e852f5b9..ac811a34c7 100644 --- a/apps/web/src/components/chat/resource-timeline/resource-source.ts +++ b/apps/web/src/components/chat/resource-timeline/resource-source.ts @@ -8,21 +8,26 @@ */ import { - getSessionResourceHistory, - type WorkspaceResourceChunk, - type WorkspaceResourceHistoryResponse, + getSessionResourceTimeline, + getSessionResourceTimelineChunk, + type ResourceTimelineChunkEntry, + type ResourceTimelineChunkResponse, + type ResourceTimelineIndexResponse, + type ResourceTimelineRollup, + type ResourceTimelineRunEntry, type WorkspaceResourceSample, type WorkspaceResourceToolSpan, } from '../../../lib/api'; -import type { - HistoryCompleteness, - ResourceAggregate, - ResourceChunkDetail, - ResourceChunkRef, - ResourceRun, - ResourceTimelineIndex, - ResourceToolSpan, - ToolKind, +import { + type ResourceAggregate, + type ResourceChunkDetail, + type ResourceChunkRef, + type ResourceReservation, + type ResourceRun, + type ResourceTimelineIndex, + type ResourceToolSpan, + TOOL_KINDS, + type ToolKind, } from './types'; export interface ResourceHistorySource { @@ -35,17 +40,10 @@ export interface ResourceHistorySource { const DEFAULT_SAMPLE_INTERVAL_MS = 5_000; const DEFAULT_UPLOAD_INTERVAL_MS = 15 * 60_000; -const TOOL_KINDS: ReadonlySet = new Set([ - 'execute', - 'edit', - 'read', - 'search', - 'fetch', - 'think', -]); +const KNOWN_TOOL_KINDS: ReadonlySet = new Set(TOOL_KINDS); function toolKind(raw: string | undefined): ToolKind { - return raw && TOOL_KINDS.has(raw) ? (raw as ToolKind) : 'other'; + return raw && KNOWN_TOOL_KINDS.has(raw) ? (raw as ToolKind) : 'other'; } function numberField(record: unknown, key: string): number | null { @@ -66,7 +64,7 @@ export function sampleToAggregate(sample: WorkspaceResourceSample): ResourceAggr const measured = intervalMs > 0 && !sample.unsupported; const cores = measured && sample.cpuMillis != null ? sample.cpuMillis / intervalMs : null; const memory = sample.unsupported ? null : (sample.memoryBytes ?? null); - const workingSet = numberField(sample, 'workingSetBytes'); + const workingSet = sample.unsupported ? null : (sample.memoryWorkingSetBytes ?? null); return { start: sample.t - (intervalMs || DEFAULT_SAMPLE_INTERVAL_MS), end: sample.t, @@ -106,7 +104,7 @@ export function withToolStarts( function toolSpanFromApi(span: WorkspaceResourceToolSpan): ResourceToolSpan { return { id: span.id, - kind: toolKind(stringField(span, 'toolKind') ?? span.kind), + kind: toolKind(span.kind), name: stringField(span, 'toolName'), startedAt: span.startedAt, endedAt: span.endedAt ?? span.startedAt, @@ -114,8 +112,8 @@ function toolSpanFromApi(span: WorkspaceResourceToolSpan): ResourceToolSpan { }; } -/** The per-chunk summary the current API stores, as one aggregate spanning the chunk. */ -function chunkOverview(chunk: WorkspaceResourceChunk): ResourceAggregate { +/** Older chunks carry no rollup: their stored summary becomes one aggregate spanning the chunk. */ +function summaryOverview(chunk: ResourceTimelineChunkEntry): ResourceAggregate { const summary = chunk.summary; const intervalMs = numberField(summary, 'sampleIntervalMillis') ?? DEFAULT_SAMPLE_INTERVAL_MS; const cpuMean = numberField(summary, 'cpuMeanMillis'); @@ -127,8 +125,8 @@ function chunkOverview(chunk: WorkspaceResourceChunk): ResourceAggregate { cpuMaxCores: cpuPeak == null ? null : cpuPeak / intervalMs, memoryMeanBytes: numberField(summary, 'memoryMeanBytes'), memoryMaxBytes: numberField(summary, 'memoryPeakBytes'), - workingSetMeanBytes: numberField(summary, 'workingSetMeanBytes'), - workingSetMaxBytes: numberField(summary, 'workingSetPeakBytes'), + workingSetMeanBytes: numberField(summary, 'memoryWorkingSetMeanBytes'), + workingSetMaxBytes: numberField(summary, 'memoryWorkingSetPeakBytes'), ioReadBytes: numberField(summary, 'ioReadBytes'), ioWriteBytes: numberField(summary, 'ioWriteBytes'), oomKills: numberField(summary, 'oomCount') ?? 0, @@ -137,42 +135,50 @@ function chunkOverview(chunk: WorkspaceResourceChunk): ResourceAggregate { }; } -function runsFromChunks(chunks: readonly WorkspaceResourceChunk[]): ResourceRun[] { - const runs = new Map(); - for (const chunk of chunks) { - const unsupported = stringField(chunk.completeness, 'unsupported'); - const existing = runs.get(chunk.workspaceId); - if (existing) { - existing.startedAt = Math.min(existing.startedAt, chunk.startedAt); - existing.endedAt = Math.max(existing.endedAt, chunk.endedAt); - existing.unsupportedReason ??= unsupported; - } else { - runs.set(chunk.workspaceId, { - id: chunk.workspaceId, - nodeId: chunk.nodeId, - startedAt: chunk.startedAt, - endedAt: chunk.endedAt, - unsupportedReason: unsupported, - }); - } - } - return [...runs.values()].sort((a, b) => a.startedAt - b.startedAt); +export function rollupOverview(rollup: ResourceTimelineRollup): ResourceAggregate[] { + return rollup.start.map((start, i) => ({ + start, + end: rollup.end[i] ?? start + rollup.bucketMs, + cpuMeanCores: rollup.cpuMeanCores[i] ?? null, + cpuMaxCores: rollup.cpuMaxCores[i] ?? null, + memoryMeanBytes: rollup.memoryMeanBytes[i] ?? null, + memoryMaxBytes: rollup.memoryMaxBytes[i] ?? null, + workingSetMeanBytes: rollup.workingSetMeanBytes[i] ?? null, + workingSetMaxBytes: rollup.workingSetMaxBytes[i] ?? null, + ioReadBytes: rollup.ioReadBytes[i] ?? null, + ioWriteBytes: rollup.ioWriteBytes[i] ?? null, + oomKills: rollup.oomKills[i] ?? 0, + toolCallStarts: rollup.toolCallStarts[i] ?? 0, + exact: false, + })); } -/** - * The current list endpoint returns only the newest chunks, without a total. The - * one certain signal is the latest workspace's own first chunk missing from the - * page: then older history exists and was not returned. - */ -function completenessOf(response: WorkspaceResourceHistoryResponse): HistoryCompleteness { - const firstChunkId = response.summary?.firstChunkId; - if (firstChunkId && !response.chunks.some((chunk) => chunk.id === firstChunkId)) { - return { kind: 'truncated', omitted: null }; - } - return { kind: 'complete' }; +function reservationFromApi(run: ResourceTimelineRunEntry): ResourceReservation | null { + if (!run.reservation) return null; + return { + cpuCores: run.reservation.cpuMillis > 0 ? run.reservation.cpuMillis / 1000 : null, + memoryBytes: run.reservation.memoryMb > 0 ? run.reservation.memoryMb * 1024 * 1024 : null, + }; +} + +function runsFromApi(response: ResourceTimelineIndexResponse): ResourceRun[] { + return response.runs + .map((run) => ({ + id: run.workspaceId, + nodeId: run.nodeId, + startedAt: run.startedAt, + endedAt: run.endedAt, + unsupportedReason: + response.chunks + .filter((chunk) => chunk.workspaceId === run.workspaceId) + .map((chunk) => stringField(chunk.completeness, 'unsupported')) + .find((reason) => reason != null) ?? null, + reservation: reservationFromApi(run), + })) + .sort((a, b) => a.startedAt - b.startedAt); } -export function indexFromApi(response: WorkspaceResourceHistoryResponse): ResourceTimelineIndex { +export function indexFromApi(response: ResourceTimelineIndexResponse): ResourceTimelineIndex { const chunks: ResourceChunkRef[] = [...response.chunks] .sort((a, b) => a.startedAt - b.startedAt) .map((chunk) => ({ @@ -181,17 +187,20 @@ export function indexFromApi(response: WorkspaceResourceHistoryResponse): Resour startedAt: chunk.startedAt, endedAt: chunk.endedAt, sampleCount: chunk.sampleCount, - overview: [chunkOverview(chunk)], + overview: chunk.rollup && chunk.rollup.start.length > 0 ? rollupOverview(chunk.rollup) : [summaryOverview(chunk)], memoryHighWaterBytes: numberField(chunk.summary, 'memoryKernelPeakBytes'), })); return { - runs: runsFromChunks(response.chunks), + runs: runsFromApi(response), chunks, sampleIntervalMs: - numberField(response.summary?.summary, 'sampleIntervalMillis') ?? DEFAULT_SAMPLE_INTERVAL_MS, + numberField(response.chunks[0]?.summary, 'sampleIntervalMillis') ?? DEFAULT_SAMPLE_INTERVAL_MS, uploadIntervalMs: DEFAULT_UPLOAD_INTERVAL_MS, - completeness: completenessOf(response), - reservation: null, + completeness: + response.omittedChunkCount > 0 + ? { kind: 'truncated', omitted: response.omittedChunkCount } + : { kind: 'complete' }, + collection: response.collection, }; } @@ -210,10 +219,7 @@ function coverThinnedSamples(samples: ResourceAggregate[], raw: readonly Workspa }); } -export function chunkDetailFromApi( - chunkId: string, - detail: NonNullable -): ResourceChunkDetail { +export function chunkDetailFromApi(chunkId: string, detail: ResourceTimelineChunkResponse): ResourceChunkDetail { const toolSpans = detail.toolSpans.map(toolSpanFromApi); const raw = [...detail.samples].sort((a, b) => a.t - b.t); const samples = raw.map(sampleToAggregate); @@ -227,19 +233,19 @@ export function chunkDetailFromApi( }; } -/** Reads a session's history through today's `/resource-history` endpoint. */ +/** Reads a session's whole resource timeline. */ export function apiResourceHistorySource(projectId: string, sessionId: string): ResourceHistorySource { return { - cacheKey: ['session-resource-history', projectId, sessionId], + cacheKey: ['session-resource-timeline', projectId, sessionId], async loadIndex() { - return indexFromApi(await getSessionResourceHistory(projectId, sessionId)); + return indexFromApi(await getSessionResourceTimeline(projectId, sessionId)); }, async loadChunk(chunkId) { - const response = await getSessionResourceHistory(projectId, sessionId, { chunkId }); - if (!response.detail || response.detail.chunkId !== chunkId) { + const response = await getSessionResourceTimelineChunk(projectId, sessionId, chunkId); + if (response.chunkId !== chunkId) { throw new Error(`Resource history chunk ${chunkId} was not returned`); } - return chunkDetailFromApi(chunkId, response.detail); + return chunkDetailFromApi(chunkId, response); }, }; } diff --git a/apps/web/src/components/chat/resource-timeline/types.ts b/apps/web/src/components/chat/resource-timeline/types.ts index 54eaa4ff65..65badef84f 100644 --- a/apps/web/src/components/chat/resource-timeline/types.ts +++ b/apps/web/src/components/chat/resource-timeline/types.ts @@ -7,8 +7,20 @@ * provisions a fresh workspace) separated by the gaps where the session slept. */ -/** ACP tool-call kinds, plus `other` for anything an agent reports that we do not know. */ -export type ToolKind = 'execute' | 'edit' | 'read' | 'search' | 'fetch' | 'think' | 'other'; +/** The ACP tool-call kinds (`other` also covers anything an agent reports that we do not know). */ +export const TOOL_KINDS = [ + 'read', + 'edit', + 'delete', + 'move', + 'search', + 'execute', + 'think', + 'fetch', + 'switch_mode', + 'other', +] as const; +export type ToolKind = (typeof TOOL_KINDS)[number]; /** * Resource usage over a closed time window `[start, end)`. @@ -48,6 +60,8 @@ export interface ResourceRun { endedAt: number; /** Why the collector could not observe this run (for example the container never started). */ unsupportedReason: string | null; + /** What this workspace reserved; each wake can land on a different size. */ + reservation: ResourceReservation | null; } /** A stored chunk, described well enough to draw an overview without downloading it. */ @@ -85,8 +99,11 @@ export interface ResourceTimelineIndex { /** How often the VM uploads a chunk — data is never newer than one upload interval. */ uploadIntervalMs: number; completeness: HistoryCompleteness; - /** What the workspace reserved, when known; usage is judged against this. */ - reservation: ResourceReservation | null; + /** + * Whether history is recorded at all: `unsupported` for runtimes that collect + * none (Instant sessions), `pending` before the first upload. + */ + collection: 'collected' | 'pending' | 'unsupported'; } export interface ResourceToolSpan { diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index 13f1c684e0..5011f99901 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -465,11 +465,6 @@ export type { SessionSummaryResponse, StartInstantChatSessionRequest, StartInstantChatSessionResponse, - WorkspaceResourceChunk, - WorkspaceResourceHistoryResponse, - WorkspaceResourceSample, - WorkspaceResourceSummary, - WorkspaceResourceToolSpan, } from './sessions'; export { cancelAgentPrompt, @@ -478,7 +473,6 @@ export { getChatSession, getChatSessionState, getRecentChats, - getSessionResourceHistory, listActivityEvents, listChatMessages, listChatSessions, @@ -565,3 +559,13 @@ export { updateWorkspace, updateWorkspacePortsPublic, } from './workspaces'; +export type { + ResourceTimelineChunkEntry, + ResourceTimelineChunkResponse, + ResourceTimelineIndexResponse, + ResourceTimelineRollup, + ResourceTimelineRunEntry, + WorkspaceResourceSample, + WorkspaceResourceToolSpan, +} from './resource-timeline'; +export { getSessionResourceTimeline, getSessionResourceTimelineChunk } from './resource-timeline'; diff --git a/apps/web/src/lib/api/resource-timeline.ts b/apps/web/src/lib/api/resource-timeline.ts new file mode 100644 index 0000000000..022d5ddb5b --- /dev/null +++ b/apps/web/src/lib/api/resource-timeline.ts @@ -0,0 +1,114 @@ +import { request } from './client'; + +/** One raw 5-second cgroup sample, as the VM agent records it. */ +export interface WorkspaceResourceSample { + t: number; + intervalMillis?: number; + cpuMillis?: number; + memoryBytes?: number; + memoryPeakBytes?: number; + /** Non-reclaimable working set; absent on VM agents that predate it. */ + memoryWorkingSetBytes?: number; + ioReadBytes?: number; + ioWriteBytes?: number; + oom?: number; + oomKill?: number; + pidsCurrent?: number; + counterReset?: boolean; + unsupported?: string; + gap?: boolean; +} + +export interface WorkspaceResourceToolSpan { + id: string; + /** ACP tool-call kind (`execute`, `edit`, ...); `acp_tool_call` on VM agents that predate tool labels. */ + kind: string; + toolName?: string; + startedAt: number; + endedAt?: number; + concurrency?: number; + approximate?: boolean; +} + +/** Columnar per-minute rollup stored for each chunk; every array has one entry per bucket. */ +export interface ResourceTimelineRollup { + v: 1; + bucketMs: number; + start: number[]; + end: number[]; + samples: number[]; + cpuMeanCores: Array; + cpuMaxCores: Array; + memoryMeanBytes: Array; + memoryMaxBytes: Array; + workingSetMeanBytes: Array; + workingSetMaxBytes: Array; + ioReadBytes: Array; + ioWriteBytes: Array; + oomKills: number[]; + toolCallStarts: number[]; +} + +export interface ResourceTimelineChunkEntry { + id: string; + workspaceId: string; + startedAt: number; + endedAt: number; + sampleCount: number; + gapCount: number; + toolSpanCount: number; + summary: unknown; + completeness: unknown; + /** Null for chunks uploaded before rollups existed; draw them from `summary`. */ + rollup: ResourceTimelineRollup | null; +} + +export interface ResourceTimelineRunEntry { + workspaceId: string; + nodeId: string | null; + runtime: string | null; + startedAt: number; + endedAt: number; + reservation: { cpuMillis: number; memoryMb: number } | null; +} + +export interface ResourceTimelineIndexResponse { + sessionId: string; + runs: ResourceTimelineRunEntry[]; + /** Ascending by `startedAt`. */ + chunks: ResourceTimelineChunkEntry[]; + totalChunkCount: number; + omittedChunkCount: number; + maxChunks: number; + collection: 'collected' | 'pending' | 'unsupported'; + runtime: string | null; +} + +export interface ResourceTimelineChunkResponse { + chunkId: string; + samples: WorkspaceResourceSample[]; + toolSpans: WorkspaceResourceToolSpan[]; + gaps: Array>; + originalSampleCount: number; + downsampled: boolean; + downsampleLimit: number; +} + +export async function getSessionResourceTimeline( + projectId: string, + sessionId: string +): Promise { + return request( + `/api/projects/${projectId}/sessions/${sessionId}/resource-timeline` + ); +} + +export async function getSessionResourceTimelineChunk( + projectId: string, + sessionId: string, + chunkId: string +): Promise { + return request( + `/api/projects/${projectId}/sessions/${sessionId}/resource-timeline/chunks/${encodeURIComponent(chunkId)}` + ); +} diff --git a/apps/web/src/lib/api/sessions.ts b/apps/web/src/lib/api/sessions.ts index c8b75e27b4..1a1dcd8505 100644 --- a/apps/web/src/lib/api/sessions.ts +++ b/apps/web/src/lib/api/sessions.ts @@ -142,102 +142,6 @@ export interface ChatSessionStateResponse { agentType: string | null; } -export interface WorkspaceResourceSummary { - id: string; - projectId: string; - workspaceId: string; - sessionId: string | null; - taskId: string | null; - nodeId: string | null; - agentProfileId: string | null; - skillId: string | null; - agentType: string | null; - runtime: string; - sourceVersion: number; - startedAt: number; - endedAt: number; - sampleCount: number; - gapCount: number; - toolSpanCount: number; - cpuMeanMillis: number | null; - cpuPeakMillis: number | null; - memoryMeanBytes: number | null; - memoryPeakBytes: number | null; - memoryKernelPeakBytes: number | null; - memoryWorkingSetMeanBytes: number | null; - memoryWorkingSetPeakBytes: number | null; - ioReadBytes: number | null; - ioWriteBytes: number | null; - oomCount: number; - completeness: unknown; - summary: unknown; - firstChunkId: string | null; - latestChunkId: string | null; -} - -export interface WorkspaceResourceChunk { - id: string; - workspaceId: string; - sessionId: string | null; - taskId: string | null; - nodeId: string | null; - chunkSequence: number; - sourceVersion: number; - storageFormat: string; - compressedBytes: number; - uncompressedBytes: number; - sha256: string; - startedAt: number; - endedAt: number; - sampleCount: number; - gapCount: number; - toolSpanCount: number; - completeness: unknown; - summary: unknown; - expiresAt: number; -} - -export interface WorkspaceResourceSample { - t: number; - intervalMillis?: number; - cpuMillis?: number; - memoryBytes?: number; - memoryPeakBytes?: number; - memoryWorkingSetBytes?: number; - ioReadBytes?: number; - ioWriteBytes?: number; - oom?: number; - oomKill?: number; - pidsCurrent?: number; - counterReset?: boolean; - unsupported?: string; - gap?: boolean; -} - -export interface WorkspaceResourceToolSpan { - id: string; - kind?: string; - toolName?: string; - startedAt: number; - endedAt?: number; - concurrency?: number; - approximate?: boolean; -} - -export interface WorkspaceResourceHistoryResponse { - summary: WorkspaceResourceSummary | null; - chunks: WorkspaceResourceChunk[]; - detail?: { - chunkId: string; - samples: WorkspaceResourceSample[]; - toolSpans: WorkspaceResourceToolSpan[]; - gaps: Array>; - originalSampleCount: number; - downsampled: boolean; - downsampleLimit: number; - }; -} - export interface ChatMessagesListResponse { messages: ChatMessageResponse[]; hasMore: boolean; @@ -305,21 +209,6 @@ export async function getRecentChats( return request(qs ? `/api/chats/recent?${qs}` : '/api/chats/recent'); } -export async function getSessionResourceHistory( - projectId: string, - sessionId: string, - params: { chunkId?: string | null } = {} -): Promise { - const searchParams = new URLSearchParams(); - if (params.chunkId) searchParams.set('chunkId', params.chunkId); - const qs = searchParams.toString(); - return request( - qs - ? `/api/projects/${projectId}/sessions/${sessionId}/resource-history?${qs}` - : `/api/projects/${projectId}/sessions/${sessionId}/resource-history` - ); -} - /** Fetch all sessions across all projects with pagination (single D1 query). */ export async function getAllChats( params: { limit?: number; offset?: number; status?: string } = {} diff --git a/apps/web/src/pages/resource-timeline-prototype/index.tsx b/apps/web/src/pages/resource-timeline-prototype/index.tsx deleted file mode 100644 index 5b680a677b..0000000000 --- a/apps/web/src/pages/resource-timeline-prototype/index.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/** - * PROTOTYPE ONLY — dev-only route `/prototype/resource-timeline`. - * - * Renders the real Resources drawer against synthetic sessions. Everything under - * `components/chat/resource-timeline/` is the production candidate; only this - * page and its mock data are throwaway. Scenario, backend contract and network - * latency are URL parameters so a link reproduces exactly what was seen. - */ - -import { Button } from '@simple-agent-manager/ui'; -import { Activity } from 'lucide-react'; -import { useMemo } from 'react'; -import { useSearchParams } from 'react-router'; - -import { SessionResourceHistoryDrawer } from '../../components/chat/SessionResourceHistoryDrawer'; -import { scenarioById, SCENARIOS } from './mock-sessions'; -import { type BackendMode, createMockSource, type LatencyProfile } from './mock-source'; - -const BACKENDS: Array<{ id: BackendMode; label: string; detail: string }> = [ - { - id: 'proposed', - label: 'Proposed backend', - detail: 'Every chunk listed, with per-minute rollups stored at upload time.', - }, - { - id: 'current', - label: 'Current backend', - detail: 'One summary per chunk, and only the newest 24 chunks.', - }, -]; - -const LATENCIES: Array<{ id: LatencyProfile; label: string }> = [ - { id: 'fast', label: 'Fast network' }, - { id: 'realistic', label: 'Realistic' }, - { id: 'slow', label: 'Slow 3G' }, -]; - -function pick(value: string | null, allowed: readonly T[], fallback: T): T { - return allowed.includes(value as T) ? (value as T) : fallback; -} - -const selectClass = - 'w-full rounded-md border border-border-default bg-surface px-2 py-2 text-sm text-fg-primary'; - -export function ResourceTimelinePrototype() { - const [params, setParams] = useSearchParams(); - const scenario = scenarioById(params.get('scenario')); - const backend = pick(params.get('backend'), ['proposed', 'current'] as const, 'proposed'); - const latency = pick(params.get('latency'), ['fast', 'realistic', 'slow'] as const, 'realistic'); - const open = params.get('open') !== '0'; - const source = useMemo(() => createMockSource(scenario.id, backend, latency), [scenario.id, backend, latency]); - - const update = (key: string, value: string) => - setParams( - (current) => { - const next = new URLSearchParams(current); - next.set(key, value); - return next; - }, - { replace: true } - ); - - return ( -
-
-
-

Prototype

-

Session resources timeline

-

- The real Resources drawer, fed by synthetic sessions shaped like production ones. Scrub with a finger, - pinch to zoom, drag the strip under the charts, or tap a busiest moment. -

-
- - - -
- Backend contract - {BACKENDS.map((option) => ( - - ))} -
- - - - -
- - {open && ( - update('open', '0')} - /> - )} -
- ); -} diff --git a/apps/web/src/pages/resource-timeline-prototype/mock-sessions.ts b/apps/web/src/pages/resource-timeline-prototype/mock-sessions.ts deleted file mode 100644 index ef2134ba35..0000000000 --- a/apps/web/src/pages/resource-timeline-prototype/mock-sessions.ts +++ /dev/null @@ -1,404 +0,0 @@ -/** - * PROTOTYPE ONLY — synthetic session resource histories. - * - * Shapes are calibrated against production sessions (2026-09-28/29): idle at - * ~0.01-0.03 cores with flat memory, builds at 1.5-3.8 cores for minutes with - * memory swinging by gigabytes, most tool calls lasting milliseconds and a few - * lasting minutes, and long sessions made of many wake cycles on several nodes. - * Everything is seeded, so screenshots are reproducible. - */ - -import { withToolStarts } from '../../components/chat/resource-timeline/resource-source'; -import type { - ResourceAggregate, - ResourceChunkDetail, - ResourceRun, - ResourceToolSpan, - ToolKind, -} from '../../components/chat/resource-timeline/types'; - -const GB = 1024 ** 3; -const MB = 1024 ** 2; -const MINUTE = 60_000; -export const SAMPLE_INTERVAL_MS = 5_000; -export const CHUNK_INTERVAL_MS = 15 * MINUTE; - -interface RunPlan { - startMin: number; - durationMin: number; - node: string; - unsupported?: string; - /** Force an out-of-memory kill this many minutes into the run. */ - oomAtMin?: number; -} - -/** A coordinator mostly waits and dispatches; a builder compiles and tests all day. */ -type Workload = 'coordinator' | 'builder'; - -export interface ScenarioPlan { - id: string; - label: string; - description: string; - startedAt: number; - seed: number; - workload: Workload; - runs: RunPlan[]; - reservation: { cpuCores: number; memoryBytes: number } | null; - /** VM agents that predate working-set memory and tool labels report neither. */ - agentReportsWorkingSet: boolean; - agentReportsToolNames: boolean; -} - -export interface GeneratedRun { - run: ResourceRun; - samples: ResourceAggregate[]; - toolSpans: ResourceToolSpan[]; - samplerGaps: Array<{ start: number; end: number }>; - memoryHighWater: number[]; -} - -/** mulberry32 — tiny, fast, good enough for mock data. */ -function createRandom(seed: number): () => number { - let state = seed >>> 0; - return () => { - state = (state + 0x6d2b79f5) >>> 0; - let t = state; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -type PhaseKind = 'idle' | 'agent' | 'build' | 'install' | 'test' | 'fetch'; - -interface Phase { - kind: PhaseKind; - start: number; - end: number; - cpu: number; - memoryLift: number; -} - -const PHASE_WEIGHTS: Record> = { - coordinator: [ - ['idle', 5], - ['agent', 6], - ['build', 0.7], - ['install', 0.3], - ['test', 0.5], - ['fetch', 1.5], - ], - builder: [ - ['idle', 3], - ['agent', 5], - ['build', 2], - ['install', 1], - ['test', 1.5], - ['fetch', 1], - ], -}; - -function pickPhase(random: () => number, workload: Workload): PhaseKind { - const weights = PHASE_WEIGHTS[workload]; - const total = weights.reduce((sum, [, weight]) => sum + weight, 0); - let roll = random() * total; - for (const [kind, weight] of weights) { - roll -= weight; - if (roll <= 0) return kind; - } - return 'agent'; -} - -function phaseShape(kind: PhaseKind, random: () => number): Omit & { minutes: number } { - switch (kind) { - case 'idle': - return { kind, minutes: 1 + random() * 7, cpu: 0.015, memoryLift: 0 }; - case 'agent': - return { kind, minutes: 1 + random() * 5, cpu: 0.05 + random() * 0.12, memoryLift: 0.05 * GB }; - case 'build': - return { kind, minutes: 2 + random() * 8, cpu: 1.5 + random() * 2.2, memoryLift: (0.8 + random() * 1.4) * GB }; - case 'install': - return { kind, minutes: 1 + random() * 2, cpu: 0.6 + random() * 0.6, memoryLift: 0.3 * GB }; - case 'test': - return { kind, minutes: 2 + random() * 6, cpu: 1.2 + random() * 2.4, memoryLift: (1.2 + random() * 1.6) * GB }; - case 'fetch': - return { kind, minutes: 0.5 + random() * 1.5, cpu: 0.08, memoryLift: 0.02 * GB }; - } -} - -function planPhases(start: number, end: number, random: () => number, workload: Workload): Phase[] { - const phases: Phase[] = []; - let cursor = start; - let previous: PhaseKind = 'idle'; - while (cursor < end) { - let kind = pickPhase(random, workload); - if (kind === previous && kind !== 'agent') kind = 'agent'; - const shape = phaseShape(kind, random); - const phaseEnd = Math.min(end, cursor + shape.minutes * MINUTE); - phases.push({ kind, start: cursor, end: phaseEnd, cpu: shape.cpu, memoryLift: shape.memoryLift }); - cursor = phaseEnd; - previous = kind; - } - return phases; -} - -type AgentTool = [name: string, kind: ToolKind, minMs: number, maxMs: number]; - -const READ_TOOL: AgentTool = ['Read', 'read', 5, 400]; -const AGENT_TOOLS: AgentTool[] = [ - READ_TOOL, - ['Grep', 'search', 20, 900], - ['Glob', 'search', 5, 200], - ['Edit', 'edit', 10, 600], - ['Write', 'edit', 10, 300], - ['TodoWrite', 'think', 5, 50], - ['mcp__sam-mcp__update_task_status', 'other', 150, 2_500], - ['Bash', 'execute', 200, 6_000], -]; - -function spansForPhase( - phase: Phase, - random: () => number, - nextId: () => string, - labelled: boolean -): ResourceToolSpan[] { - const span = (name: string, kind: ToolKind, startedAt: number, endedAt: number): ResourceToolSpan => ({ - id: nextId(), - kind: labelled ? kind : 'other', - name: labelled ? name : null, - startedAt, - endedAt, - approximateEnd: false, - }); - const duration = phase.end - phase.start; - switch (phase.kind) { - case 'build': - case 'test': - case 'install': { - const lead = Math.min(8_000, duration * 0.05); - return [span('Bash', 'execute', phase.start + lead, phase.end - 1_000)]; - } - case 'fetch': { - const spans: ResourceToolSpan[] = []; - for (let t = phase.start + 2_000; t < phase.end - 6_000; t += 8_000 + random() * 12_000) { - spans.push(span('WebFetch', 'fetch', t, t + 900 + random() * 5_000)); - } - return spans; - } - case 'agent': { - const spans: ResourceToolSpan[] = []; - for (let t = phase.start + random() * 6_000; t < phase.end - 3_000; t += 4_000 + random() * 22_000) { - const [name, kind, minMs, maxMs] = AGENT_TOOLS[Math.floor(random() * AGENT_TOOLS.length)] ?? READ_TOOL; - spans.push(span(name, kind, t, t + minMs + random() * (maxMs - minMs))); - } - return spans; - } - case 'idle': - return []; - } -} - -/** Generates one run's 5-second samples, tool spans and kernel memory high-water marks. */ -export function generateRun(plan: RunPlan, scenario: ScenarioPlan, index: number): GeneratedRun { - const random = createRandom(scenario.seed * 97 + index * 7919); - const startedAt = scenario.startedAt + plan.startMin * MINUTE; - const endedAt = startedAt + plan.durationMin * MINUTE; - const run: ResourceRun = { - id: `ws-${scenario.id}-${index + 1}`, - nodeId: plan.node, - startedAt, - endedAt, - unsupportedReason: plan.unsupported ?? null, - }; - if (plan.unsupported) return { run, samples: [], toolSpans: [], samplerGaps: [], memoryHighWater: [] }; - - const phases = planPhases(startedAt, endedAt, random, scenario.workload); - let spanCounter = 0; - const nextId = () => `span-${scenario.id}-${index}-${(spanCounter += 1)}`; - const toolSpans = phases.flatMap((phase) => - spansForPhase(phase, random, nextId, scenario.agentReportsToolNames) - ); - - const baseMemory = (0.75 + random() * 0.45) * GB; - const limit = scenario.reservation?.memoryBytes ?? Infinity; - const oomAt = plan.oomAtMin == null ? null : startedAt + plan.oomAtMin * MINUTE; - const samplerGapAt = plan.durationMin > 90 ? startedAt + plan.durationMin * 0.4 * MINUTE : null; - const samples: ResourceAggregate[] = []; - const samplerGaps: Array<{ start: number; end: number }> = []; - const memoryHighWater: number[] = []; - let cache = 0.1 * GB; - let memoryLift = 0; - let highWater = 0; - let oomFired = false; - let phaseIndex = 0; - - for (let t = startedAt + SAMPLE_INTERVAL_MS; t <= endedAt; t += SAMPLE_INTERVAL_MS) { - if (samplerGapAt != null && t >= samplerGapAt && t < samplerGapAt + 40_000) { - if (samplerGaps.length === 0) samplerGaps.push({ start: samplerGapAt, end: samplerGapAt + 40_000 }); - continue; - } - while (phaseIndex < phases.length - 1 && (phases[phaseIndex]?.end ?? Infinity) < t) phaseIndex += 1; - const phase = phases[phaseIndex]; - if (!phase) break; - const progress = (t - phase.start) / Math.max(1, phase.end - phase.start); - const ramp = Math.min(1, progress * 6) * Math.min(1, (1 - progress) * 8); - const jitter = 0.75 + random() * 0.5; - let cores = Math.max(0.005, phase.cpu * (phase.kind === 'idle' ? 1 : ramp) * jitter); - - const targetLift = phase.memoryLift * ramp * (0.8 + random() * 0.4); - memoryLift += (targetLift - memoryLift) * 0.35; - let ioRead = phase.kind === 'build' ? random() * 3 * MB : random() * 0.2 * MB; - const ioWrite = phase.kind === 'install' ? (4 + random() * 16) * MB * 5 : random() * 0.4 * MB; - if (progress < 0.08 && phase.kind !== 'idle' && phase.kind !== 'agent') ioRead += (10 + random() * 60) * MB; - cache = Math.min(cache + (ioRead + ioWrite) * 0.6, (baseMemory + memoryLift) * 0.45); - cache *= 0.998; - - let memory = baseMemory + memoryLift + cache; - let oomKills = 0; - if (!oomFired && oomAt != null && t >= oomAt && phase.kind !== 'idle') { - memory = limit; - oomKills = 1; - oomFired = true; - memoryLift = 0; - cores = Math.max(cores, 3.4); - } else if (memory > limit * 0.98) { - memory = limit * 0.98; - } - const spike = (phase.kind === 'build' || phase.kind === 'test') && random() < 0.03 ? (0.05 + random() * 0.3) * GB : 0; - highWater = Math.max(highWater, memory + spike); - memoryHighWater.push(highWater); - const workingSet = scenario.agentReportsWorkingSet ? Math.max(0, memory - cache) : null; - samples.push({ - start: t - SAMPLE_INTERVAL_MS, - end: t, - cpuMeanCores: cores, - cpuMaxCores: cores, - memoryMeanBytes: memory, - memoryMaxBytes: memory, - workingSetMeanBytes: workingSet, - workingSetMaxBytes: workingSet, - ioReadBytes: Math.round(ioRead), - ioWriteBytes: Math.round(ioWrite), - oomKills, - toolCallStarts: 0, - exact: true, - }); - } - return { run, samples, toolSpans, samplerGaps, memoryHighWater }; -} - -/** Splits a run into 15-minute chunks the way the VM collector uploads them. */ -export function chunkRun(generated: GeneratedRun): Array<{ id: string; startedAt: number; endedAt: number; detail: ResourceChunkDetail; highWater: number | null }> { - const { run } = generated; - const chunks = []; - for (let start = run.startedAt, sequence = 0; start < run.endedAt; start += CHUNK_INTERVAL_MS, sequence += 1) { - const end = Math.min(run.endedAt, start + CHUNK_INTERVAL_MS); - const inChunk = (t: number) => t > start && t <= end; - const members = generated.samples.flatMap((sample, i) => - inChunk(sample.end) ? [{ sample, highWater: generated.memoryHighWater[i] ?? null }] : [] - ); - const id = `wrchunk:${run.id}:${sequence}`; - // The collector records a span when it completes, so it lands in the chunk where it ends. - const toolSpans = generated.toolSpans.filter((span) => inChunk(span.endedAt)); - chunks.push({ - id, - startedAt: start, - endedAt: end, - highWater: members.at(-1)?.highWater ?? null, - detail: { - chunkId: id, - samples: withToolStarts(members.map((member) => member.sample), toolSpans), - toolSpans, - samplerGaps: generated.samplerGaps.filter((gap) => inChunk(gap.start)), - }, - }); - } - return chunks; -} - -const EVENING = Date.parse('2026-09-28T19:40:00'); - -const OVERNIGHT: ScenarioPlan = { -id: 'overnight', - label: 'Overnight coordinator · 27h · 10 wakes', - description: 'Modelled on session 449d73f8: many wake cycles on three nodes, a 9-hour overnight sleep, an OOM kill and a wake whose container never started.', - startedAt: EVENING, - seed: 7, - workload: 'coordinator', - reservation: { cpuCores: 2, memoryBytes: 4 * GB }, - agentReportsWorkingSet: true, - agentReportsToolNames: true, - runs: [ - { startMin: 0, durationMin: 52, node: 'node-hel1-a' }, - { startMin: 60, durationMin: 34, node: 'node-hel1-a' }, - { startMin: 131, durationMin: 18, node: 'node-fsn1-b' }, - { startMin: 689, durationMin: 115, node: 'node-fsn1-b' }, - { startMin: 845, durationMin: 47, node: 'node-nbg1-c' }, - { startMin: 904, durationMin: 131, node: 'node-nbg1-c', oomAtMin: 88 }, - { startMin: 1041, durationMin: 0.35, node: 'node-hel1-a', unsupported: 'no running devcontainer found' }, - { startMin: 1041.7, durationMin: 21, node: 'node-hel1-a' }, - { startMin: 1242, durationMin: 64, node: 'node-hel1-a' }, - { startMin: 1398, durationMin: 222, node: 'node-hel1-a' }, - ], -}; - -export const SCENARIOS: ScenarioPlan[] = [ - OVERNIGHT, - { - id: 'build-heavy', - label: 'One long run · 4h · heavy builds', - description: 'Modelled on session 98ac3e40: a single four-hour run with frequent builds and hundreds of tool calls.', - startedAt: Date.parse('2026-09-28T09:05:00'), - seed: 21, - workload: 'builder', - reservation: { cpuCores: 4, memoryBytes: 8 * GB }, - agentReportsWorkingSet: true, - agentReportsToolNames: true, - runs: [{ startMin: 0, durationMin: 245, node: 'node-hel1-a' }], - }, - { - id: 'short', - label: 'Short task · 11 min', - description: 'One run shorter than a single upload interval: one partial chunk.', - startedAt: Date.parse('2026-09-29T07:12:00'), - seed: 3, - workload: 'builder', - reservation: { cpuCores: 2, memoryBytes: 4 * GB }, - agentReportsWorkingSet: true, - agentReportsToolNames: true, - runs: [{ startMin: 0, durationMin: 11, node: 'node-hel1-a' }], - }, - { - id: 'legacy-agent', - label: 'Older VM agent · no labels or working set', - description: 'The overnight session as reported by agents that predate tool labels and working-set memory.', - startedAt: EVENING, - seed: 7, - workload: 'coordinator', - reservation: null, - agentReportsWorkingSet: false, - agentReportsToolNames: false, - runs: [ - { startMin: 0, durationMin: 52, node: 'node-hel1-a' }, - { startMin: 60, durationMin: 34, node: 'node-hel1-a' }, - { startMin: 689, durationMin: 115, node: 'node-fsn1-b' }, - ], - }, - { - id: 'empty', - label: 'Just started · nothing uploaded yet', - description: 'Samples upload every 15 minutes; a new session has none yet.', - startedAt: Date.parse('2026-09-29T07:40:00'), - seed: 1, - workload: 'coordinator', - reservation: null, - agentReportsWorkingSet: true, - agentReportsToolNames: true, - runs: [], - }, -]; - -/** The scenario with `id`, or the overnight session when the id is unknown. */ -export function scenarioById(id: string | null): ScenarioPlan { - return SCENARIOS.find((scenario) => scenario.id === id) ?? OVERNIGHT; -} diff --git a/apps/web/src/pages/resource-timeline-prototype/mock-source.ts b/apps/web/src/pages/resource-timeline-prototype/mock-source.ts deleted file mode 100644 index 02e8363b1d..0000000000 --- a/apps/web/src/pages/resource-timeline-prototype/mock-source.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * PROTOTYPE ONLY — a ResourceHistorySource over the synthetic sessions. - * - * `proposed` models the backend this prototype argues for: every chunk listed - * (no cap) with per-minute rollups computed at upload time, so the whole session - * draws from the index alone. `current` models today's API: one summary per - * chunk and only the newest 24 chunks. - */ - -import type { ResourceHistorySource } from '../../components/chat/resource-timeline/resource-source'; -import type { - ResourceAggregate, - ResourceChunkDetail, - ResourceChunkRef, - ResourceTimelineIndex, - ResourceToolSpan, -} from '../../components/chat/resource-timeline/types'; -import { - CHUNK_INTERVAL_MS, - chunkRun, - generateRun, - SAMPLE_INTERVAL_MS, - scenarioById, - type ScenarioPlan, -} from './mock-sessions'; - -export type BackendMode = 'proposed' | 'current'; -export type LatencyProfile = 'fast' | 'realistic' | 'slow'; - -const CURRENT_API_CHUNK_LIMIT = 24; -const ROLLUP_MS = 60_000; - -const LATENCY: Record = { - fast: { index: 40, chunkMin: 20, chunkMax: 60 }, - realistic: { index: 350, chunkMin: 180, chunkMax: 650 }, - slow: { index: 1_400, chunkMin: 1_200, chunkMax: 3_200 }, -}; - -function hash(text: string): number { - let value = 2166136261; - for (let i = 0; i < text.length; i += 1) value = Math.imul(value ^ text.charCodeAt(i), 16777619); - return (value >>> 0) / 4294967296; -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function maxOrNull(values: Array): number | null { - const present = values.filter((value): value is number => value != null); - return present.length ? Math.max(...present) : null; -} - -function meanOrNull(values: Array): number | null { - const present = values.filter((value): value is number => value != null); - return present.length ? present.reduce((sum, value) => sum + value, 0) / present.length : null; -} - -/** What the proposed backend would store per chunk: one aggregate per minute. */ -function rollup( - samples: readonly ResourceAggregate[], - spans: readonly ResourceToolSpan[], - start: number, - end: number, - bucketMs: number -): ResourceAggregate[] { - const buckets: ResourceAggregate[] = []; - for (let from = start; from < end; from += bucketMs) { - const to = Math.min(end, from + bucketMs); - const inside = samples.filter((sample) => sample.end > from && sample.end <= to); - if (inside.length === 0) continue; - buckets.push({ - start: from, - end: to, - cpuMeanCores: meanOrNull(inside.map((sample) => sample.cpuMeanCores)), - cpuMaxCores: maxOrNull(inside.map((sample) => sample.cpuMaxCores)), - memoryMeanBytes: meanOrNull(inside.map((sample) => sample.memoryMeanBytes)), - memoryMaxBytes: maxOrNull(inside.map((sample) => sample.memoryMaxBytes)), - workingSetMeanBytes: meanOrNull(inside.map((sample) => sample.workingSetMeanBytes)), - workingSetMaxBytes: maxOrNull(inside.map((sample) => sample.workingSetMaxBytes)), - ioReadBytes: inside.reduce((sum, sample) => sum + (sample.ioReadBytes ?? 0), 0), - ioWriteBytes: inside.reduce((sum, sample) => sum + (sample.ioWriteBytes ?? 0), 0), - oomKills: inside.reduce((sum, sample) => sum + sample.oomKills, 0), - toolCallStarts: spans.filter((span) => span.startedAt >= from && span.startedAt < to).length, - exact: false, - }); - } - return buckets; -} - -/** Each run trimmed to the chunks that were actually returned for it. */ -function runsCoveredBy( - runs: ResourceTimelineIndex['runs'], - chunks: readonly ResourceChunkRef[] -): ResourceTimelineIndex['runs'] { - return runs.flatMap((run) => { - const own = chunks.filter((chunk) => chunk.runId === run.id); - if (own.length === 0) return []; - return [ - { - ...run, - startedAt: Math.min(...own.map((chunk) => chunk.startedAt)), - endedAt: Math.max(...own.map((chunk) => chunk.endedAt)), - }, - ]; - }); -} - -interface BuiltScenario { - plan: ScenarioPlan; - refs: ResourceChunkRef[]; - details: Map; - runs: ResourceTimelineIndex['runs']; -} - -const built = new Map(); - -function buildScenario(plan: ScenarioPlan, backend: BackendMode): BuiltScenario { - const key = `${plan.id}:${backend}`; - const cached = built.get(key); - if (cached) return cached; - - const generated = plan.runs.map((run, index) => generateRun(run, plan, index)); - const refs: ResourceChunkRef[] = []; - const details = new Map(); - for (const run of generated) { - for (const chunk of chunkRun(run)) { - details.set(chunk.id, chunk.detail); - const minuteRollup = rollup(chunk.detail.samples, run.toolSpans, chunk.startedAt, chunk.endedAt, ROLLUP_MS); - const wholeChunk = rollup(chunk.detail.samples, run.toolSpans, chunk.startedAt, chunk.endedAt, CHUNK_INTERVAL_MS); - refs.push({ - id: chunk.id, - runId: run.run.id, - startedAt: chunk.startedAt, - endedAt: chunk.endedAt, - sampleCount: chunk.detail.samples.length, - overview: backend === 'proposed' ? minuteRollup : wholeChunk, - memoryHighWaterBytes: chunk.highWater, - }); - } - } - const result = { plan, refs, details, runs: generated.map((run) => run.run) }; - built.set(key, result); - return result; -} - -export function createMockSource( - scenarioId: string, - backend: BackendMode, - latency: LatencyProfile -): ResourceHistorySource { - const plan = scenarioById(scenarioId); - const timing = LATENCY[latency]; - return { - cacheKey: ['prototype-resource-history', plan.id, backend, latency], - async loadIndex() { - await delay(timing.index); - const scenario = buildScenario(plan, backend); - const truncated = backend === 'current' && scenario.refs.length > CURRENT_API_CHUNK_LIMIT; - const chunks = truncated ? scenario.refs.slice(-CURRENT_API_CHUNK_LIMIT) : scenario.refs; - return { - // Like the real adapter, the current API only knows the runs its returned chunks cover. - runs: backend === 'proposed' ? scenario.runs : runsCoveredBy(scenario.runs, chunks), - chunks, - sampleIntervalMs: SAMPLE_INTERVAL_MS, - uploadIntervalMs: CHUNK_INTERVAL_MS, - completeness: truncated ? { kind: 'truncated', omitted: null } : { kind: 'complete' }, - reservation: backend === 'proposed' ? plan.reservation : null, - }; - }, - async loadChunk(chunkId) { - const jitter = hash(chunkId); - await delay(timing.chunkMin + jitter * (timing.chunkMax - timing.chunkMin)); - const detail = buildScenario(plan, backend).details.get(chunkId); - if (!detail) throw new Error(`Unknown chunk ${chunkId}`); - return detail; - }, - }; -} diff --git a/apps/web/tests/playwright/resource-timeline-scenarios.ts b/apps/web/tests/playwright/resource-timeline-scenarios.ts new file mode 100644 index 0000000000..e4f1177fe1 --- /dev/null +++ b/apps/web/tests/playwright/resource-timeline-scenarios.ts @@ -0,0 +1,549 @@ +/** + * Synthetic session resource histories for the Resources drawer audits, served + * exactly as the API serves them: a `/resource-timeline` index with per-minute + * rollups and raw 5-second chunks behind `/resource-timeline/chunks/:id`. + * + * Shapes are calibrated against production sessions (2026-09-28/29): idle at + * ~0.01-0.03 cores with flat memory, builds at 1.5-3.8 cores for minutes with + * memory swinging by gigabytes, most tool calls lasting milliseconds and a few + * lasting minutes, and long sessions made of many wake cycles on several nodes. + * Everything is seeded, so screenshots are reproducible. + */ +import type { + ResourceTimelineChunkEntry, + ResourceTimelineChunkResponse, + ResourceTimelineIndexResponse, + ResourceTimelineRollup, + ResourceTimelineRunEntry, + WorkspaceResourceSample, + WorkspaceResourceToolSpan, +} from '../../src/lib/api/resource-timeline'; + +const GB = 1024 ** 3; +const MB = 1024 ** 2; +const MINUTE = 60_000; +const SAMPLE_INTERVAL_MS = 5_000; +const CHUNK_INTERVAL_MS = 15 * MINUTE; + +interface Reservation { + cpuMillis: number; + memoryMb: number; +} + +interface RunPlan { + startMin: number; + durationMin: number; + node: string; + reservation: Reservation | null; + unsupported?: string; + /** Force an out-of-memory kill this many minutes into the run. */ + oomAtMin?: number; +} + +type Workload = 'coordinator' | 'builder'; + +interface ScenarioPlan { + /** Minutes before `now` the session started. */ + startedMinutesAgo: number; + seed: number; + workload: Workload; + runs: RunPlan[]; + /** VM agents that predate working-set memory and tool labels report neither. */ + modernAgent: boolean; + /** Chunks uploaded before rollups existed carry only their summary. */ + rollups: boolean; + /** Oldest chunks the server left out past its cap. */ + omittedChunkCount?: number; + collection?: ResourceTimelineIndexResponse['collection']; + runtime?: string; +} + +export type ResourceScenarioId = 'overnight' | 'legacy-agent' | 'truncated' | 'pending' | 'instant'; + +const SMALL: Reservation = { cpuMillis: 2_000, memoryMb: 4_096 }; +const LARGE: Reservation = { cpuMillis: 4_000, memoryMb: 8_192 }; + +const SCENARIOS: Record = { + // Modelled on session 449d73f8: many wakes on three nodes, a 9-hour overnight + // sleep, an OOM kill, a wake whose container never started, and one wake that + // landed on a larger size. + overnight: { + startedMinutesAgo: 1_625, + seed: 7, + workload: 'coordinator', + modernAgent: true, + rollups: true, + runs: [ + { startMin: 0, durationMin: 52, node: 'node-hel1-a', reservation: SMALL }, + { startMin: 60, durationMin: 34, node: 'node-hel1-a', reservation: SMALL }, + { startMin: 131, durationMin: 18, node: 'node-fsn1-b', reservation: SMALL }, + { startMin: 689, durationMin: 115, node: 'node-fsn1-b', reservation: LARGE }, + { startMin: 845, durationMin: 47, node: 'node-nbg1-c', reservation: SMALL }, + { startMin: 904, durationMin: 131, node: 'node-nbg1-c', reservation: SMALL, oomAtMin: 88 }, + { + startMin: 1_041, + durationMin: 0.35, + node: 'node-hel1-a', + reservation: SMALL, + unsupported: 'no running devcontainer found', + }, + { startMin: 1_041.7, durationMin: 21, node: 'node-hel1-a', reservation: SMALL }, + { startMin: 1_242, durationMin: 64, node: 'node-hel1-a', reservation: SMALL }, + { startMin: 1_398, durationMin: 222, node: 'node-hel1-a', reservation: null }, + ], + }, + 'legacy-agent': { + startedMinutesAgo: 820, + seed: 11, + workload: 'builder', + modernAgent: false, + rollups: false, + runs: [ + { startMin: 0, durationMin: 52, node: 'node-hel1-a', reservation: null }, + { startMin: 60, durationMin: 34, node: 'node-hel1-a', reservation: null }, + { startMin: 689, durationMin: 115, node: 'node-fsn1-b', reservation: null }, + ], + }, + truncated: { + startedMinutesAgo: 250, + seed: 21, + workload: 'builder', + modernAgent: true, + rollups: true, + omittedChunkCount: 612, + runs: [{ startMin: 0, durationMin: 245, node: 'node-hel1-a', reservation: LARGE }], + }, + pending: { + startedMinutesAgo: 4, + seed: 1, + workload: 'coordinator', + modernAgent: true, + rollups: true, + runs: [], + collection: 'pending', + runtime: 'vm', + }, + instant: { + startedMinutesAgo: 30, + seed: 1, + workload: 'coordinator', + modernAgent: true, + rollups: true, + runs: [], + collection: 'unsupported', + runtime: 'cf-container', + }, +}; + +/** mulberry32 — tiny, fast, good enough for fixture data. */ +function createRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +type PhaseKind = 'idle' | 'agent' | 'build' | 'install' | 'test' | 'fetch'; + +interface Phase { + kind: PhaseKind; + start: number; + end: number; + cpu: number; + memoryLift: number; +} + +const PHASE_WEIGHTS: Record> = { + coordinator: [ + ['idle', 5], + ['agent', 6], + ['build', 0.7], + ['install', 0.3], + ['test', 0.5], + ['fetch', 1.5], + ], + builder: [ + ['idle', 3], + ['agent', 5], + ['build', 2], + ['install', 1], + ['test', 1.5], + ['fetch', 1], + ], +}; + +function pickPhase(random: () => number, workload: Workload): PhaseKind { + const weights = PHASE_WEIGHTS[workload]; + let roll = random() * weights.reduce((sum, [, weight]) => sum + weight, 0); + for (const [kind, weight] of weights) { + roll -= weight; + if (roll <= 0) return kind; + } + return 'agent'; +} + +function phaseShape(kind: PhaseKind, random: () => number) { + switch (kind) { + case 'idle': + return { minutes: 1 + random() * 7, cpu: 0.015, memoryLift: 0 }; + case 'agent': + return { minutes: 1 + random() * 5, cpu: 0.05 + random() * 0.12, memoryLift: 0.05 * GB }; + case 'build': + return { + minutes: 2 + random() * 8, + cpu: 1.5 + random() * 2.2, + memoryLift: (0.8 + random() * 1.4) * GB, + }; + case 'install': + return { minutes: 1 + random() * 2, cpu: 0.6 + random() * 0.6, memoryLift: 0.3 * GB }; + case 'test': + return { + minutes: 2 + random() * 6, + cpu: 1.2 + random() * 2.4, + memoryLift: (1.2 + random() * 1.6) * GB, + }; + case 'fetch': + return { minutes: 0.5 + random() * 1.5, cpu: 0.08, memoryLift: 0.02 * GB }; + } +} + +function planPhases(start: number, end: number, random: () => number, workload: Workload): Phase[] { + const phases: Phase[] = []; + let cursor = start; + let previous: PhaseKind = 'idle'; + while (cursor < end) { + let kind = pickPhase(random, workload); + if (kind === previous && kind !== 'agent') kind = 'agent'; + const shape = phaseShape(kind, random); + const phaseEnd = Math.min(end, cursor + shape.minutes * MINUTE); + phases.push({ + kind, + start: cursor, + end: phaseEnd, + cpu: shape.cpu, + memoryLift: shape.memoryLift, + }); + cursor = phaseEnd; + previous = kind; + } + return phases; +} + +type AgentTool = [name: string, kind: string, minMs: number, maxMs: number]; +const READ_TOOL: AgentTool = ['Read', 'read', 5, 400]; +const AGENT_TOOLS: AgentTool[] = [ + READ_TOOL, + ['Grep', 'search', 20, 900], + ['Glob', 'search', 5, 200], + ['Edit', 'edit', 10, 600], + ['Write', 'edit', 10, 300], + ['TodoWrite', 'think', 5, 50], + ['mcp__sam-mcp__update_task_status', 'other', 150, 2_500], + ['Bash', 'execute', 200, 6_000], +]; + +function spansForPhase( + phase: Phase, + random: () => number, + nextId: () => string, + labelled: boolean +): WorkspaceResourceToolSpan[] { + const span = ( + name: string, + kind: string, + startedAt: number, + endedAt: number + ): WorkspaceResourceToolSpan => + labelled + ? { id: nextId(), kind, toolName: name, startedAt, endedAt } + : { id: nextId(), kind: 'acp_tool_call', startedAt, endedAt }; + const duration = phase.end - phase.start; + switch (phase.kind) { + case 'build': + case 'test': + case 'install': + return [ + span('Bash', 'execute', phase.start + Math.min(8_000, duration * 0.05), phase.end - 1_000), + ]; + case 'fetch': { + const spans: WorkspaceResourceToolSpan[] = []; + for (let t = phase.start + 2_000; t < phase.end - 6_000; t += 8_000 + random() * 12_000) { + spans.push(span('WebFetch', 'fetch', t, t + 900 + random() * 5_000)); + } + return spans; + } + case 'agent': { + const spans: WorkspaceResourceToolSpan[] = []; + for ( + let t = phase.start + random() * 6_000; + t < phase.end - 3_000; + t += 4_000 + random() * 22_000 + ) { + const [name, kind, minMs, maxMs] = + AGENT_TOOLS[Math.floor(random() * AGENT_TOOLS.length)] ?? READ_TOOL; + spans.push(span(name, kind, t, t + minMs + random() * (maxMs - minMs))); + } + return spans; + } + case 'idle': + return []; + } +} + +interface GeneratedRun { + id: string; + plan: RunPlan; + startedAt: number; + endedAt: number; + samples: WorkspaceResourceSample[]; + toolSpans: WorkspaceResourceToolSpan[]; +} + +function generateRun( + plan: RunPlan, + scenario: ScenarioPlan, + sessionStart: number, + index: number +): GeneratedRun { + const random = createRandom(scenario.seed * 97 + index * 7919); + const startedAt = sessionStart + plan.startMin * MINUTE; + const endedAt = startedAt + plan.durationMin * MINUTE; + const id = `ws-timeline-${index + 1}`; + if (plan.unsupported) return { id, plan, startedAt, endedAt, samples: [], toolSpans: [] }; + + const phases = planPhases(startedAt, endedAt, random, scenario.workload); + let spanCounter = 0; + const nextId = () => `span-${index}-${(spanCounter += 1)}`; + const toolSpans = phases.flatMap((phase) => + spansForPhase(phase, random, nextId, scenario.modernAgent) + ); + + const baseMemory = (0.75 + random() * 0.45) * GB; + const limit = plan.reservation ? plan.reservation.memoryMb * MB : Infinity; + const oomAt = plan.oomAtMin == null ? null : startedAt + plan.oomAtMin * MINUTE; + const gapAt = plan.durationMin > 90 ? startedAt + plan.durationMin * 0.4 * MINUTE : null; + const samples: WorkspaceResourceSample[] = []; + let cache = 0.1 * GB; + let memoryLift = 0; + let highWater = 0; + let oomFired = false; + let phaseIndex = 0; + + for (let t = startedAt + SAMPLE_INTERVAL_MS; t <= endedAt; t += SAMPLE_INTERVAL_MS) { + if (gapAt != null && t >= gapAt && t < gapAt + 40_000) { + samples.push({ t, gap: true }); + continue; + } + while (phaseIndex < phases.length - 1 && (phases[phaseIndex]?.end ?? Infinity) < t) + phaseIndex += 1; + const phase = phases[phaseIndex]; + if (!phase) break; + const progress = (t - phase.start) / Math.max(1, phase.end - phase.start); + const ramp = Math.min(1, progress * 6) * Math.min(1, (1 - progress) * 8); + let cores = Math.max( + 0.005, + phase.cpu * (phase.kind === 'idle' ? 1 : ramp) * (0.75 + random() * 0.5) + ); + memoryLift += (phase.memoryLift * ramp * (0.8 + random() * 0.4) - memoryLift) * 0.35; + let ioRead = phase.kind === 'build' ? random() * 3 * MB : random() * 0.2 * MB; + const ioWrite = phase.kind === 'install' ? (4 + random() * 16) * MB * 5 : random() * 0.4 * MB; + if (progress < 0.08 && phase.kind !== 'idle' && phase.kind !== 'agent') + ioRead += (10 + random() * 60) * MB; + cache = Math.min(cache + (ioRead + ioWrite) * 0.6, (baseMemory + memoryLift) * 0.45) * 0.998; + + let memory = baseMemory + memoryLift + cache; + let oom = 0; + if (!oomFired && oomAt != null && t >= oomAt && phase.kind !== 'idle') { + memory = limit; + oom = 1; + oomFired = true; + memoryLift = 0; + cores = Math.max(cores, 3.4); + } else if (memory > limit * 0.98) { + memory = limit * 0.98; + } + const spike = + (phase.kind === 'build' || phase.kind === 'test') && random() < 0.03 + ? (0.05 + random() * 0.3) * GB + : 0; + highWater = Math.max(highWater, memory + spike); + samples.push({ + t, + intervalMillis: SAMPLE_INTERVAL_MS, + cpuMillis: Math.round(cores * SAMPLE_INTERVAL_MS), + memoryBytes: Math.round(memory), + memoryPeakBytes: Math.round(highWater), + ...(scenario.modernAgent + ? { memoryWorkingSetBytes: Math.round(Math.max(0, memory - cache)) } + : {}), + ioReadBytes: Math.round(ioRead), + ioWriteBytes: Math.round(ioWrite), + ...(oom ? { oomKill: oom } : {}), + }); + } + return { id, plan, startedAt, endedAt, samples, toolSpans }; +} + +const mean = (values: number[]) => + values.length ? values.reduce((a, b) => a + b, 0) / values.length : null; +const maxOf = (values: number[]) => (values.length ? Math.max(...values) : null); + +/** The same per-minute rollup the API computes on upload. */ +function rollupOf( + samples: WorkspaceResourceSample[], + spans: WorkspaceResourceToolSpan[], + start: number, + end: number +): ResourceTimelineRollup { + const rollup: ResourceTimelineRollup = { + v: 1, + bucketMs: MINUTE, + start: [], + end: [], + samples: [], + cpuMeanCores: [], + cpuMaxCores: [], + memoryMeanBytes: [], + memoryMaxBytes: [], + workingSetMeanBytes: [], + workingSetMaxBytes: [], + ioReadBytes: [], + ioWriteBytes: [], + oomKills: [], + toolCallStarts: [], + }; + for (let from = Math.floor(start / MINUTE) * MINUTE; from < end; from += MINUTE) { + const to = from + MINUTE; + const inside = samples.filter((s) => !s.gap && s.t > from && s.t <= to); + const starts = spans.filter((s) => s.startedAt >= from && s.startedAt < to).length; + if (inside.length === 0 && starts === 0) continue; + const cores = inside.map((s) => (s.cpuMillis ?? 0) / SAMPLE_INTERVAL_MS); + const memory = inside.map((s) => s.memoryBytes ?? 0); + const workingSet = inside.flatMap((s) => + s.memoryWorkingSetBytes == null ? [] : [s.memoryWorkingSetBytes] + ); + rollup.start.push(Math.max(from, start)); + rollup.end.push(Math.min(to, end)); + rollup.samples.push(inside.length); + rollup.cpuMeanCores.push(mean(cores)); + rollup.cpuMaxCores.push(maxOf(cores)); + rollup.memoryMeanBytes.push(mean(memory)); + rollup.memoryMaxBytes.push(maxOf([...memory, ...inside.map((s) => s.memoryPeakBytes ?? 0)])); + rollup.workingSetMeanBytes.push(mean(workingSet)); + rollup.workingSetMaxBytes.push(maxOf(workingSet)); + rollup.ioReadBytes.push(inside.reduce((sum, s) => sum + (s.ioReadBytes ?? 0), 0)); + rollup.ioWriteBytes.push(inside.reduce((sum, s) => sum + (s.ioWriteBytes ?? 0), 0)); + rollup.oomKills.push(inside.reduce((sum, s) => sum + (s.oom ?? 0) + (s.oomKill ?? 0), 0)); + rollup.toolCallStarts.push(starts); + } + return rollup; +} + +function summaryOf(samples: WorkspaceResourceSample[], modernAgent: boolean) { + const measured = samples.filter((s) => !s.gap); + const cpu = measured.map((s) => s.cpuMillis ?? 0); + const memory = measured.map((s) => s.memoryBytes ?? 0); + const workingSet = measured.map((s) => s.memoryWorkingSetBytes ?? 0); + return { + sampleIntervalMillis: SAMPLE_INTERVAL_MS, + cpuMeanMillis: mean(cpu), + cpuPeakMillis: maxOf(cpu), + memoryMeanBytes: mean(memory), + memoryPeakBytes: maxOf(memory), + memoryKernelPeakBytes: maxOf(measured.map((s) => s.memoryPeakBytes ?? 0)), + ioReadBytes: measured.reduce((sum, s) => sum + (s.ioReadBytes ?? 0), 0), + ioWriteBytes: measured.reduce((sum, s) => sum + (s.ioWriteBytes ?? 0), 0), + oomCount: measured.reduce((sum, s) => sum + (s.oomKill ?? 0), 0), + ...(modernAgent + ? { + memoryWorkingSetMeanBytes: mean(workingSet), + memoryWorkingSetPeakBytes: maxOf(workingSet), + } + : {}), + }; +} + +export interface ResourceScenarioApi { + index: ResourceTimelineIndexResponse; + chunks: Map; +} + +export function buildResourceScenario( + id: ResourceScenarioId, + sessionId: string, + now: number +): ResourceScenarioApi { + const plan = SCENARIOS[id]; + const sessionStart = now - plan.startedMinutesAgo * MINUTE; + const chunks = new Map(); + const entries: ResourceTimelineChunkEntry[] = []; + const runs: ResourceTimelineRunEntry[] = []; + + plan.runs.forEach((runPlan, index) => { + const run = generateRun(runPlan, plan, sessionStart, index); + runs.push({ + workspaceId: run.id, + nodeId: runPlan.node, + runtime: 'vm', + startedAt: run.startedAt, + endedAt: run.endedAt, + reservation: runPlan.reservation, + }); + for ( + let start = run.startedAt, sequence = 0; + start < run.endedAt; + start += CHUNK_INTERVAL_MS, sequence += 1 + ) { + const end = Math.min(run.endedAt, start + CHUNK_INTERVAL_MS); + const chunkId = `wrchunk:${run.id}:${sequence}`; + const samples = run.samples.filter((s) => s.t > start && s.t <= end); + // The collector records a span when it completes, so it lands in the chunk where it ends. + const toolSpans = run.toolSpans.filter( + (s) => (s.endedAt ?? s.startedAt) > start && (s.endedAt ?? s.startedAt) <= end + ); + chunks.set(chunkId, { + chunkId, + samples, + toolSpans, + gaps: [], + originalSampleCount: samples.length, + downsampled: false, + downsampleLimit: 720, + }); + entries.push({ + id: chunkId, + workspaceId: run.id, + startedAt: start, + endedAt: end, + sampleCount: samples.filter((s) => !s.gap).length, + gapCount: samples.filter((s) => s.gap).length, + toolSpanCount: toolSpans.length, + summary: summaryOf(samples, plan.modernAgent), + completeness: runPlan.unsupported + ? { unsupported: runPlan.unsupported } + : { status: 'complete' }, + rollup: + plan.rollups && samples.length > 0 ? rollupOf(samples, toolSpans, start, end) : null, + }); + } + }); + + const omitted = plan.omittedChunkCount ?? 0; + return { + chunks, + index: { + sessionId, + runs, + chunks: entries, + totalChunkCount: entries.length + omitted, + omittedChunkCount: omitted, + maxChunks: entries.length, + collection: plan.collection ?? 'collected', + runtime: plan.runtime ?? 'vm', + }, + }; +} diff --git a/apps/web/tests/playwright/session-tool-rail-audit.spec.ts b/apps/web/tests/playwright/session-tool-rail-audit.spec.ts index b5ae38e31d..daeee2d7c0 100644 --- a/apps/web/tests/playwright/session-tool-rail-audit.spec.ts +++ b/apps/web/tests/playwright/session-tool-rail-audit.spec.ts @@ -19,6 +19,7 @@ import { screenshot, seedTheme, } from './audit-helpers'; +import { buildResourceScenario, type ResourceScenarioId } from './resource-timeline-scenarios'; const PROJECT_ID = 'proj-rail-1'; const SESSION_ID = 'cs-rail-1'; @@ -101,155 +102,6 @@ function makeChatSession(options: SessionOptions = {}) { const LONG_TOKEN = 'sam/layered-resource-management-with-an-extremely-long-branch-name-that-will-not-wrap-0123456789'; -const RESOURCE_HISTORY_SUMMARY = { - id: 'workspace:proj-rail-1:ws-rail-1:session:cs-rail-1', - projectId: PROJECT_ID, - workspaceId: WORKSPACE_ID, - sessionId: SESSION_ID, - taskId: 'task-rail-1', - nodeId: 'node-rail-1', - agentProfileId: 'profile-resource-heavy', - skillId: 'skill-resource-history', - agentType: 'openai-codex', - runtime: 'vm', - sourceVersion: 1, - startedAt: NOW - 1_800_000, - endedAt: NOW - 900_000, - sampleCount: 180, - gapCount: 2, - toolSpanCount: 4, - cpuMeanMillis: 38, - cpuPeakMillis: 410, - memoryMeanBytes: 524_288_000, - memoryPeakBytes: 1_342_177_280, - memoryKernelPeakBytes: 1_610_612_736, - ioReadBytes: 18_874_368, - ioWriteBytes: 94_371_840, - oomCount: 1, - completeness: { status: 'partial', nodeLossMayLoseUnflushedWindow: true }, - summary: { weightedMeanWallMillis: 895_000 }, - firstChunkId: 'wrchunk-rail-1', - latestChunkId: 'wrchunk-rail-2', -}; - -const RESOURCE_HISTORY_CHUNKS = [ - { - id: 'wrchunk-rail-2', - workspaceId: WORKSPACE_ID, - sessionId: SESSION_ID, - taskId: 'task-rail-1', - nodeId: 'node-rail-1', - chunkSequence: 2, - sourceVersion: 1, - storageFormat: 'resource-history-gzip-json-v1', - compressedBytes: 6299, - uncompressedBytes: 106_968, - sha256: 'a'.repeat(64), - startedAt: NOW - 1_800_000, - endedAt: NOW - 900_000, - sampleCount: 180, - gapCount: 2, - toolSpanCount: 4, - completeness: { status: 'partial' }, - summary: { cpuPeakMillis: 410 }, - expiresAt: NOW + 90 * 86_400_000, - }, - { - id: 'wrchunk-rail-1', - workspaceId: WORKSPACE_ID, - sessionId: SESSION_ID, - taskId: 'task-rail-1', - nodeId: 'node-rail-1', - chunkSequence: 1, - sourceVersion: 1, - storageFormat: 'resource-history-gzip-json-v1', - compressedBytes: 5980, - uncompressedBytes: 99_000, - sha256: 'b'.repeat(64), - startedAt: NOW - 2_700_000, - endedAt: NOW - 1_800_000, - sampleCount: 180, - gapCount: 0, - toolSpanCount: 3, - completeness: { status: 'complete' }, - summary: { cpuPeakMillis: 205 }, - expiresAt: NOW + 90 * 86_400_000, - }, -]; - -function resourceHistoryCpuMillis(index: number): number { - if (index === 14) return 410; - if (index % 7 === 0) return 160; - return 32 + (index % 5) * 9; -} - -const RESOURCE_HISTORY_DETAIL = { - chunkId: 'wrchunk-rail-2', - originalSampleCount: 180, - downsampled: true, - downsampleLimit: 720, - samples: Array.from({ length: 36 }, (_, i) => ({ - t: NOW - 1_800_000 + i * 25_000, - intervalMillis: 5_000, - cpuMillis: resourceHistoryCpuMillis(i), - memoryBytes: i === 24 ? 1_342_177_280 : 410_000_000 + i * 12_000_000, - memoryPeakBytes: i >= 24 ? 1_342_177_280 : 610_000_000 + i * 8_000_000, - ioReadBytes: i % 8 === 0 ? 1_048_576 : 16_384, - ioWriteBytes: i % 9 === 0 ? 4_194_304 : 65_536, - oom: i === 24 ? 1 : 0, - gap: i === 20, - })), - toolSpans: [ - { - id: 'tool-compile', - kind: 'acp_tool_call', - startedAt: NOW - 1_650_000, - endedAt: NOW - 1_520_000, - concurrency: 1, - }, - { - id: 'tool-tests', - kind: 'acp_tool_call', - startedAt: NOW - 1_470_000, - endedAt: NOW - 1_240_000, - concurrency: 2, - }, - { - id: 'tool-review', - kind: 'acp_tool_call', - startedAt: NOW - 1_210_000, - endedAt: NOW - 980_000, - concurrency: 1, - approximate: true, - }, - ], - gaps: [{ startedAt: NOW - 1_300_000, endedAt: NOW - 1_250_000, reason: 'sampler_delay' }], -}; - -/** The older, quieter chunk: the timeline loads every chunk its view needs, not only the newest. */ -const RESOURCE_HISTORY_QUIET_DETAIL = { - chunkId: 'wrchunk-rail-1', - originalSampleCount: 180, - downsampled: false, - downsampleLimit: 720, - samples: Array.from({ length: 180 }, (_, i) => ({ - t: NOW - 2_700_000 + (i + 1) * 5_000, - intervalMillis: 5_000, - cpuMillis: 20 + (i % 3) * 5, - memoryBytes: 400_000_000, - memoryPeakBytes: 420_000_000, - ioReadBytes: 4_096, - ioWriteBytes: 8_192, - })), - toolSpans: [], - gaps: [], -}; - -const RESOURCE_HISTORY_DETAILS: Record = { - 'wrchunk-rail-2': RESOURCE_HISTORY_DETAIL, - 'wrchunk-rail-1': RESOURCE_HISTORY_QUIET_DETAIL, -}; - /** Enough messages to overflow any test viewport, so the scroll-to-bottom button appears. */ function makeManyMessages(count = 60) { return { @@ -316,6 +168,8 @@ interface MockOptions extends SessionOptions { empty?: boolean; /** Seeds a long conversation so the scroll-to-bottom button can actually appear. */ manyMessages?: boolean; + /** Which synthetic resource history the Resources drawer reads. */ + resourceScenario?: ResourceScenarioId; } async function setupMocks(page: Page, options: MockOptions = {}) { @@ -325,7 +179,10 @@ async function setupMocks(page: Page, options: MockOptions = {}) { messagesLong = false, empty = false, manyMessages = false, + resourceScenario = 'overnight', } = options; + const resources = buildResourceScenario(resourceScenario, SESSION_ID, NOW); + const resourceTimelinePath = `/api/projects/${PROJECT_ID}/sessions/${SESSION_ID}/resource-timeline`; await page.addInitScript( ({ userId, storageKey, seededMode }) => { @@ -376,15 +233,13 @@ async function setupMocks(page: Page, options: MockOptions = {}) { return; } - if (pathname === `/api/projects/${PROJECT_ID}/sessions/${SESSION_ID}/resource-history`) { - const detail = RESOURCE_HISTORY_DETAILS[new URL(url).searchParams.get('chunkId') ?? '']; - await route.fulfill({ - json: { - summary: RESOURCE_HISTORY_SUMMARY, - chunks: RESOURCE_HISTORY_CHUNKS, - ...(detail ? { detail } : {}), - }, - }); + if (pathname === resourceTimelinePath) { + await route.fulfill({ json: resources.index }); + return; + } + if (pathname.startsWith(`${resourceTimelinePath}/chunks/`)) { + const chunk = resources.chunks.get(decodeURIComponent(pathname.slice(resourceTimelinePath.length + 8))); + await route.fulfill(chunk ? { json: chunk } : { status: 404, json: { error: 'NOT_FOUND' } }); return; } if (pathname === `/api/projects/${PROJECT_ID}/sessions/${SESSION_ID}/state`) { @@ -1098,3 +953,65 @@ test.describe('Session resource history drawer', () => { await capture(page, `resource-history-detail-${page.viewportSize()?.width ?? 'viewport'}`); }); }); + +async function openResources(page: Page, resourceScenario: ResourceScenarioId) { + await openChat(page, { state: 'active', resourceScenario }); + await page.getByTestId('session-tool-resources').click(); + const dialog = page.getByRole('dialog', { name: 'Session resources' }); + await expect(dialog).toBeVisible(); + return dialog; +} + +test.describe('Session resource timeline scenarios', () => { + test('zooming in fetches full detail only for the visible chunks, each once', async ({ page }) => { + const chunkRequests: string[] = []; + page.on('request', (request) => { + if (request.url().includes('/resource-timeline/chunks/')) chunkRequests.push(request.url()); + }); + const dialog = await openResources(page, 'overnight'); + await expect(dialog.getByText('Whole session', { exact: true })).toBeVisible({ timeout: 10_000 }); + const total = buildResourceScenario('overnight', SESSION_ID, NOW).chunks.size; + const beforeZoom = chunkRequests.length; + + await dialog.getByRole('button', { name: '15m', exact: true }).click(); + await expect.poll(() => chunkRequests.length, { timeout: 10_000 }).toBeGreaterThan(beforeZoom); + await page.waitForTimeout(600); + // Zoomed to 15 minutes: a handful of chunks at most, never the whole session. + expect(chunkRequests.length).toBeLessThan(total); + await dialog.getByRole('button', { name: '1h', exact: true }).click(); + await dialog.getByRole('button', { name: '15m', exact: true }).click(); + await page.waitForTimeout(600); + expect(new Set(chunkRequests).size).toBe(chunkRequests.length); + await capture(page, `resource-timeline-zoomed-${page.viewportSize()?.width ?? 'viewport'}`); + }); + + test('older VM agents without rollups still draw the whole session', async ({ page }) => { + const dialog = await openResources(page, 'legacy-agent'); + await expect(dialog.getByText('Whole session', { exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByRole('slider', { name: /Session timeline/ })).toBeVisible(); + await expect(dialog.getByText(/3 wake cycles/)).toBeVisible(); + await capture(page, `resource-timeline-legacy-${page.viewportSize()?.width ?? 'viewport'}`); + }); + + test('a session past the server cap says how much older history is not shown', async ({ page }) => { + const dialog = await openResources(page, 'truncated'); + await expect(dialog.getByText(/612 older 15-minute segments are not shown/)).toBeVisible({ timeout: 10_000 }); + // Liveness: the part that is shown still draws. + await expect(dialog.getByRole('slider', { name: /Session timeline/ })).toBeVisible(); + await capture(page, `resource-timeline-truncated-${page.viewportSize()?.width ?? 'viewport'}`); + }); + + test('a new VM session explains when its first samples arrive', async ({ page }) => { + const dialog = await openResources(page, 'pending'); + await expect(dialog.getByText('No resource samples yet')).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByText('Not recorded for Instant sessions')).toHaveCount(0); + await capture(page, `resource-timeline-pending-${page.viewportSize()?.width ?? 'viewport'}`); + }); + + test('an Instant session says nothing is recorded instead of promising data', async ({ page }) => { + const dialog = await openResources(page, 'instant'); + await expect(dialog.getByText('Not recorded for Instant sessions')).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByText('No resource samples yet')).toHaveCount(0); + await capture(page, `resource-timeline-instant-${page.viewportSize()?.width ?? 'viewport'}`); + }); +}); diff --git a/apps/web/tests/unit/app-routes.test.tsx b/apps/web/tests/unit/app-routes.test.tsx index f181e3baf7..bb52fc6587 100644 --- a/apps/web/tests/unit/app-routes.test.tsx +++ b/apps/web/tests/unit/app-routes.test.tsx @@ -308,7 +308,6 @@ describe('App routes', () => { expect(DEV_ONLY_ROUTE_PATHS).toEqual([ '/sam', - '/prototype/resource-timeline', '/__test/trial-chat-gate', '/__test/error-boundary', '/ui-standards', @@ -321,7 +320,6 @@ describe('App routes', () => { expect(screen.getByTestId('landing-page')).toBeInTheDocument(); expect(screen.queryByTestId('sam-prototype-page')).not.toBeInTheDocument(); - expect(screen.queryByText('Session resources timeline')).not.toBeInTheDocument(); expect(screen.queryByTestId('trial-chat-gate-harness-page')).not.toBeInTheDocument(); expect(screen.queryByTestId('ui-standards-page')).not.toBeInTheDocument(); } diff --git a/apps/web/tests/unit/components/resource-timeline/readout.test.ts b/apps/web/tests/unit/components/resource-timeline/readout.test.ts index 53d340ac8d..4610064970 100644 --- a/apps/web/tests/unit/components/resource-timeline/readout.test.ts +++ b/apps/web/tests/unit/components/resource-timeline/readout.test.ts @@ -21,7 +21,7 @@ const GB = 1024 ** 3; const T0 = new Date(2026, 8, 28, 19, 0, 0).getTime(); function run(id: string, startedAt: number, endedAt: number): ResourceRun { - return { id, nodeId: 'node-a', startedAt, endedAt, unsupportedReason: null }; + return { id, nodeId: 'node-a', startedAt, endedAt, unsupportedReason: null, reservation: null }; } function sample(end: number, cores: number): ResourceAggregate { diff --git a/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts b/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts index c797e8ef09..101157b35c 100644 --- a/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts +++ b/apps/web/tests/unit/components/resource-timeline/resource-source.test.ts @@ -8,27 +8,24 @@ import { } from '../../../../src/components/chat/resource-timeline/resource-source'; import type { ResourceToolSpan } from '../../../../src/components/chat/resource-timeline/types'; import type { - WorkspaceResourceChunk, - WorkspaceResourceHistoryResponse, - WorkspaceResourceSummary, + ResourceTimelineChunkEntry, + ResourceTimelineIndexResponse, + ResourceTimelineRollup, + ResourceTimelineRunEntry, } from '../../../../src/lib/api'; const T0 = Date.UTC(2026, 8, 28, 19, 0, 0); const MINUTE = 60_000; -function chunk(id: string, workspaceId: string, startedAt: number, overrides: Partial = {}): WorkspaceResourceChunk { +function chunk( + id: string, + workspaceId: string, + startedAt: number, + overrides: Partial = {} +): ResourceTimelineChunkEntry { return { id, workspaceId, - sessionId: 'session-1', - taskId: null, - nodeId: `node-of-${workspaceId}`, - chunkSequence: 1, - sourceVersion: 1, - storageFormat: 'resource-history-gzip-json-v1', - compressedBytes: 1000, - uncompressedBytes: 9000, - sha256: 'abc', startedAt, endedAt: startedAt + 15 * MINUTE, sampleCount: 180, @@ -42,25 +39,92 @@ function chunk(id: string, workspaceId: string, startedAt: number, overrides: Pa memoryMeanBytes: 1e9, memoryPeakBytes: 2e9, memoryKernelPeakBytes: 2.2e9, + memoryWorkingSetMeanBytes: 6e8, + memoryWorkingSetPeakBytes: 9e8, ioReadBytes: 10, ioWriteBytes: 20, oomCount: 0, }, - expiresAt: startedAt + 90 * 24 * 60 * MINUTE, + rollup: null, ...overrides, }; } -function response(chunks: WorkspaceResourceChunk[], firstChunkId: string | null): WorkspaceResourceHistoryResponse { - const summary = { firstChunkId, summary: { sampleIntervalMillis: 5000 } } as unknown as WorkspaceResourceSummary; - return { summary, chunks }; +function run( + workspaceId: string, + startedAt: number, + endedAt: number, + reservation: ResourceTimelineRunEntry['reservation'] = null +): ResourceTimelineRunEntry { + return { + workspaceId, + nodeId: `node-of-${workspaceId}`, + runtime: 'vm', + startedAt, + endedAt, + reservation, + }; +} + +function response( + chunks: ResourceTimelineChunkEntry[], + runs: ResourceTimelineRunEntry[], + overrides: Partial = {} +): ResourceTimelineIndexResponse { + return { + sessionId: 'session-1', + runs, + chunks, + totalChunkCount: chunks.length, + omittedChunkCount: 0, + maxChunks: 1000, + collection: chunks.length ? 'collected' : 'pending', + runtime: 'vm', + ...overrides, + }; +} + +function rollup(start: number, minutes: number): ResourceTimelineRollup { + const buckets = Array.from({ length: minutes }, (_, i) => i); + return { + v: 1, + bucketMs: MINUTE, + start: buckets.map((i) => start + i * MINUTE), + end: buckets.map((i) => start + (i + 1) * MINUTE), + samples: buckets.map(() => 12), + cpuMeanCores: buckets.map((i) => i / 10), + cpuMaxCores: buckets.map((i) => i / 5), + memoryMeanBytes: buckets.map(() => 1e9), + memoryMaxBytes: buckets.map(() => 1.5e9), + workingSetMeanBytes: buckets.map(() => null), + workingSetMaxBytes: buckets.map(() => null), + ioReadBytes: buckets.map(() => 100), + ioWriteBytes: buckets.map(() => 200), + oomKills: buckets.map((i) => (i === 2 ? 1 : 0)), + toolCallStarts: buckets.map((i) => i), + }; } describe('sampleToAggregate', () => { it('turns CPU milliseconds per interval into cores and keeps disk bytes', () => { - const aggregate = sampleToAggregate({ t: T0, intervalMillis: 5000, cpuMillis: 2500, memoryBytes: 1e9, ioReadBytes: 7, ioWriteBytes: 9 }); + const aggregate = sampleToAggregate({ + t: T0, + intervalMillis: 5000, + cpuMillis: 2500, + memoryBytes: 1e9, + ioReadBytes: 7, + ioWriteBytes: 9, + }); - expect(aggregate).toMatchObject({ start: T0 - 5000, end: T0, cpuMeanCores: 0.5, cpuMaxCores: 0.5, ioReadBytes: 7, ioWriteBytes: 9, exact: true }); + expect(aggregate).toMatchObject({ + start: T0 - 5000, + end: T0, + cpuMeanCores: 0.5, + cpuMaxCores: 0.5, + ioReadBytes: 7, + ioWriteBytes: 9, + exact: true, + }); }); it('has no CPU or disk reading for the first sample of a run, which has no interval yet', () => { @@ -72,15 +136,29 @@ describe('sampleToAggregate', () => { }); it('drops the memory reading of a sample the collector could not observe', () => { - const aggregate = sampleToAggregate({ t: T0, intervalMillis: 5000, memoryBytes: 0, unsupported: 'no running devcontainer found' }); + const aggregate = sampleToAggregate({ + t: T0, + intervalMillis: 5000, + memoryBytes: 0, + unsupported: 'no running devcontainer found', + }); expect(aggregate.memoryMeanBytes).toBeNull(); expect(aggregate.cpuMeanCores).toBeNull(); }); }); describe('withToolStarts', () => { - const samples = [0, 1, 2].map((i) => sampleToAggregate({ t: T0 + (i + 1) * 5000, intervalMillis: 5000, cpuMillis: 0 })); - const span = (startedAt: number): ResourceToolSpan => ({ id: `s${startedAt}`, kind: 'read', name: 'Read', startedAt, endedAt: startedAt + 10, approximateEnd: false }); + const samples = [0, 1, 2].map((i) => + sampleToAggregate({ t: T0 + (i + 1) * 5000, intervalMillis: 5000, cpuMillis: 0 }) + ); + const span = (startedAt: number): ResourceToolSpan => ({ + id: `s${startedAt}`, + kind: 'read', + name: 'Read', + startedAt, + endedAt: startedAt + 10, + approximateEnd: false, + }); it('counts each call on the sample whose interval saw it start', () => { const counted = withToolStarts(samples, [span(T0 + 1000), span(T0 + 6000), span(T0 + 7000)]); @@ -94,50 +172,135 @@ describe('withToolStarts', () => { }); describe('indexFromApi', () => { - it('orders chunks oldest first and groups them into one run per workspace', () => { + it('orders chunks oldest first and carries each run with its own reservation', () => { const index = indexFromApi( - response([chunk('c3', 'ws-2', T0 + 60 * MINUTE), chunk('c2', 'ws-1', T0 + 15 * MINUTE), chunk('c1', 'ws-1', T0)], 'c3') + response( + [ + chunk('c3', 'ws-2', T0 + 60 * MINUTE), + chunk('c2', 'ws-1', T0 + 15 * MINUTE), + chunk('c1', 'ws-1', T0), + ], + [ + run('ws-2', T0 + 60 * MINUTE, T0 + 75 * MINUTE, { cpuMillis: 4000, memoryMb: 8192 }), + run('ws-1', T0, T0 + 30 * MINUTE, { cpuMillis: 2000, memoryMb: 4096 }), + ] + ) ); expect(index.chunks.map((ref) => ref.id)).toEqual(['c1', 'c2', 'c3']); expect(index.runs).toEqual([ - { id: 'ws-1', nodeId: 'node-of-ws-1', startedAt: T0, endedAt: T0 + 30 * MINUTE, unsupportedReason: null }, - { id: 'ws-2', nodeId: 'node-of-ws-2', startedAt: T0 + 60 * MINUTE, endedAt: T0 + 75 * MINUTE, unsupportedReason: null }, + { + id: 'ws-1', + nodeId: 'node-of-ws-1', + startedAt: T0, + endedAt: T0 + 30 * MINUTE, + unsupportedReason: null, + reservation: { cpuCores: 2, memoryBytes: 4096 * 1024 ** 2 }, + }, + { + id: 'ws-2', + nodeId: 'node-of-ws-2', + startedAt: T0 + 60 * MINUTE, + endedAt: T0 + 75 * MINUTE, + unsupportedReason: null, + reservation: { cpuCores: 4, memoryBytes: 8192 * 1024 ** 2 }, + }, ]); + expect(index.collection).toBe('collected'); }); - it('reads the per-chunk summary as one coarse aggregate in cores', () => { - const index = indexFromApi(response([chunk('c1', 'ws-1', T0)], 'c1')); + it('draws a chunk from its per-minute rollup when the server stored one', () => { + const index = indexFromApi( + response( + [chunk('c1', 'ws-1', T0, { rollup: rollup(T0, 15) })], + [run('ws-1', T0, T0 + 15 * MINUTE)] + ) + ); + const overview = index.chunks[0]?.overview ?? []; + + expect(overview).toHaveLength(15); + expect(overview[3]).toMatchObject({ + start: T0 + 3 * MINUTE, + end: T0 + 4 * MINUTE, + cpuMeanCores: 0.3, + cpuMaxCores: 0.6, + memoryMaxBytes: 1.5e9, + toolCallStarts: 3, + exact: false, + }); + expect(overview[2]?.oomKills).toBe(1); + }); + + it('falls back to the chunk summary, in cores and with the working set, for chunks without a rollup', () => { + const index = indexFromApi( + response([chunk('c1', 'ws-1', T0)], [run('ws-1', T0, T0 + 15 * MINUTE)]) + ); const [overview] = index.chunks[0]?.overview ?? []; - expect(overview).toMatchObject({ cpuMeanCores: 0.5, cpuMaxCores: 3, memoryMaxBytes: 2e9, toolCallStarts: 12, exact: false }); + expect(index.chunks[0]?.overview).toHaveLength(1); + expect(overview).toMatchObject({ + cpuMeanCores: 0.5, + cpuMaxCores: 3, + memoryMaxBytes: 2e9, + workingSetMeanBytes: 6e8, + workingSetMaxBytes: 9e8, + toolCallStarts: 12, + exact: false, + }); expect(index.chunks[0]?.memoryHighWaterBytes).toBe(2.2e9); }); - it('reports truncated history when the newest workspace lost its own first chunk from the page', () => { - const truncated = indexFromApi(response([chunk('c9', 'ws-1', T0)], 'c1')); - const complete = indexFromApi(response([chunk('c1', 'ws-1', T0)], 'c1')); + it('discloses how many older chunks the server left out, and reports complete otherwise', () => { + const chunks = [chunk('c1', 'ws-1', T0)]; + const runs = [run('ws-1', T0, T0 + 15 * MINUTE)]; + expect(indexFromApi(response(chunks, runs, { omittedChunkCount: 42 })).completeness).toEqual({ + kind: 'truncated', + omitted: 42, + }); + expect(indexFromApi(response(chunks, runs)).completeness).toEqual({ kind: 'complete' }); + }); - expect(truncated.completeness).toEqual({ kind: 'truncated', omitted: null }); - expect(complete.completeness).toEqual({ kind: 'complete' }); + it('passes through whether an empty session records history at all', () => { + expect( + indexFromApi(response([], [], { collection: 'unsupported', runtime: 'cf-container' })) + .collection + ).toBe('unsupported'); + expect(indexFromApi(response([], [])).collection).toBe('pending'); }); - it('carries the reason a run could not be observed', () => { + it('carries the reason a run could not be observed, and treats a missing reservation as unknown', () => { const index = indexFromApi( - response([chunk('c1', 'ws-1', T0, { completeness: { status: 'partial', unsupported: 'no running devcontainer found' } })], 'c1') + response( + [ + chunk('c1', 'ws-1', T0, { + completeness: { status: 'partial', unsupported: 'no running devcontainer found' }, + }), + ], + [run('ws-1', T0, T0 + 15 * MINUTE)] + ) ); expect(index.runs[0]?.unsupportedReason).toBe('no running devcontainer found'); + expect(index.runs[0]?.reservation).toBeNull(); }); }); describe('chunkDetailFromApi', () => { it('maps tool labels when present and falls back for spans from older agents', () => { - // Newer VM agents add `toolName`; the web type predates it, which is why the adapter reads it defensively. - const labelled = { id: 'b', kind: 'execute', startedAt: T0 + 3000, endedAt: T0 + 4000, toolName: 'Bash' }; + // VM agents that predate tool labels report every span as `acp_tool_call` with no name. + const labelled = { + id: 'b', + kind: 'execute', + startedAt: T0 + 3000, + endedAt: T0 + 4000, + toolName: 'Bash', + }; const detail = chunkDetailFromApi('c1', { chunkId: 'c1', samples: [{ t: T0 + 5000, intervalMillis: 5000, cpuMillis: 5000 }], - toolSpans: [{ id: 'a', kind: 'acp_tool_call', startedAt: T0 + 1000, endedAt: T0 + 2000 }, labelled], + toolSpans: [ + { id: 'a', kind: 'acp_tool_call', startedAt: T0 + 1000, endedAt: T0 + 2000 }, + labelled, + ], gaps: [{ startedAt: T0, endedAt: T0 + 40_000, reason: 'sampler_delay' }], originalSampleCount: 1, downsampled: false, diff --git a/apps/web/tests/unit/components/resource-timeline/series.test.ts b/apps/web/tests/unit/components/resource-timeline/series.test.ts index 9242157872..e2c51c2659 100644 --- a/apps/web/tests/unit/components/resource-timeline/series.test.ts +++ b/apps/web/tests/unit/components/resource-timeline/series.test.ts @@ -37,7 +37,7 @@ function samples(from: number, count: number, cores = 0.1): ResourceAggregate[] } function run(startedAt: number, endedAt: number): ResourceRun { - return { id: `run-${startedAt}`, nodeId: null, startedAt, endedAt, unsupportedReason: null }; + return { id: `run-${startedAt}`, nodeId: null, startedAt, endedAt, unsupportedReason: null, reservation: null }; } describe('buildSeries', () => { diff --git a/apps/web/tests/unit/components/resource-timeline/time-axis.test.ts b/apps/web/tests/unit/components/resource-timeline/time-axis.test.ts index 15509bd6ba..0204150183 100644 --- a/apps/web/tests/unit/components/resource-timeline/time-axis.test.ts +++ b/apps/web/tests/unit/components/resource-timeline/time-axis.test.ts @@ -16,7 +16,7 @@ const HOUR = 60 * MINUTE; const T0 = new Date(2026, 8, 28, 19, 0, 0).getTime(); function run(id: string, startedAt: number, endedAt: number): ResourceRun { - return { id, nodeId: null, startedAt, endedAt, unsupportedReason: null }; + return { id, nodeId: null, startedAt, endedAt, unsupportedReason: null, reservation: null }; } /** An hour of work, a nine-hour night, another hour of work. */ diff --git a/apps/web/tests/unit/components/resource-timeline/timeline-view.test.ts b/apps/web/tests/unit/components/resource-timeline/timeline-view.test.ts index d4517e0061..2da38ce9e4 100644 --- a/apps/web/tests/unit/components/resource-timeline/timeline-view.test.ts +++ b/apps/web/tests/unit/components/resource-timeline/timeline-view.test.ts @@ -9,7 +9,7 @@ const HOUR = 60 * MINUTE; const T0 = Date.UTC(2026, 8, 28, 19, 0, 0); function run(startedAt: number, endedAt: number): ResourceRun { - return { id: `run-${startedAt}`, nodeId: null, startedAt, endedAt, unsupportedReason: null }; + return { id: `run-${startedAt}`, nodeId: null, startedAt, endedAt, unsupportedReason: null, reservation: null }; } const RUNS = [run(T0, T0 + HOUR), run(T0 + 10 * HOUR, T0 + 11 * HOUR)]; From e7e3413021e09f118fa94da886905b6a1d45f494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 09:28:30 +0000 Subject: [PATCH 10/22] fix(web): don't call a truncated resource history the whole session Co-Authored-By: Claude Opus 5.5 --- .../chat/resource-timeline/ResourceTimeline.tsx | 2 +- apps/web/src/components/chat/resource-timeline/readout.ts | 8 +++++--- apps/web/tests/playwright/session-tool-rail-audit.spec.ts | 2 ++ .../unit/components/resource-timeline/readout.test.ts | 8 ++++++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/resource-timeline/ResourceTimeline.tsx b/apps/web/src/components/chat/resource-timeline/ResourceTimeline.tsx index ae6d8f96bd..9efa82e3d5 100644 --- a/apps/web/src/components/chat/resource-timeline/ResourceTimeline.tsx +++ b/apps/web/src/components/chat/resource-timeline/ResourceTimeline.tsx @@ -149,7 +149,7 @@ function TimelineBody({ source, index }: Readonly<{ source: ResourceHistorySourc const cursorInView = cursorX != null && cursorX >= view.min && cursorX <= view.max; const readout = cursorInView ? readoutAtCursor(cursorX, series, axis, index.runs, data.toolSpans, index.sampleIntervalMs) - : readoutForRange(view.min, view.max, axis, data.aggregates); + : readoutForRange(view.min, view.max, axis, data.aggregates, index.completeness.kind === 'truncated'); const peaks = useMemo( () => diff --git a/apps/web/src/components/chat/resource-timeline/readout.ts b/apps/web/src/components/chat/resource-timeline/readout.ts index da81d3ad17..bdd10bf1f0 100644 --- a/apps/web/src/components/chat/resource-timeline/readout.ts +++ b/apps/web/src/components/chat/resource-timeline/readout.ts @@ -129,19 +129,21 @@ export function readoutForRange( viewMin: number, viewMax: number, axis: TimeAxis, - aggregates: readonly ResourceAggregate[] + aggregates: readonly ResourceAggregate[], + /** Older history exists that the index left out, so "everything shown" is not the whole session. */ + truncated = false ): Readout { const from = toReal(axis, viewMin); const to = toReal(axis, viewMax); const usage = summarizeUsage(aggregates, from, to); const whole = viewMin <= axis.min && viewMax >= axis.max; - const scope = whole ? 'Whole session' : formatRange(from, to, viewMax - viewMin < 30 * 60_000 ? 1_000 : 60_000); + const scope = whole ? (truncated ? 'Everything shown' : 'Whole session') : formatRange(from, to, viewMax - viewMin < 30 * 60_000 ? 1_000 : 60_000); const activeTotal = formatElapsed(axis.activeMs); return { mode: 'range', time: scope, context: whole - ? `All ${activeTotal} of active time` + ? `All ${activeTotal} of ${truncated ? 'shown ' : ''}active time` : `${formatElapsed(activeMsInView(axis, viewMin, viewMax))} of ${activeTotal} active time in view`, cpu: usage.cpuMaxCores == null ? DASH : `avg ${formatCores(usage.cpuMeanCores)} · peak ${formatCores(usage.cpuMaxCores)}`, memory: diff --git a/apps/web/tests/playwright/session-tool-rail-audit.spec.ts b/apps/web/tests/playwright/session-tool-rail-audit.spec.ts index daeee2d7c0..8ca387a853 100644 --- a/apps/web/tests/playwright/session-tool-rail-audit.spec.ts +++ b/apps/web/tests/playwright/session-tool-rail-audit.spec.ts @@ -996,6 +996,8 @@ test.describe('Session resource timeline scenarios', () => { test('a session past the server cap says how much older history is not shown', async ({ page }) => { const dialog = await openResources(page, 'truncated'); await expect(dialog.getByText(/612 older 15-minute segments are not shown/)).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByText('Everything shown', { exact: true })).toBeVisible(); + await expect(dialog.getByText('Whole session', { exact: true })).toHaveCount(0); // Liveness: the part that is shown still draws. await expect(dialog.getByRole('slider', { name: /Session timeline/ })).toBeVisible(); await capture(page, `resource-timeline-truncated-${page.viewportSize()?.width ?? 'viewport'}`); diff --git a/apps/web/tests/unit/components/resource-timeline/readout.test.ts b/apps/web/tests/unit/components/resource-timeline/readout.test.ts index 4610064970..5438d948dc 100644 --- a/apps/web/tests/unit/components/resource-timeline/readout.test.ts +++ b/apps/web/tests/unit/components/resource-timeline/readout.test.ts @@ -112,6 +112,14 @@ describe('readoutForRange', () => { expect(readout.context).toBe('All 2h of active time'); expect(readout.memory).toBe('peak 2.0 GB (3.0 GB w/ cache)'); }); + + it('does not call a truncated history the whole session', () => { + const axis = buildTimeAxis(RUNS, 'active'); + const readout = readoutForRange(axis.min, axis.max, axis, SAMPLES, true); + + expect(readout.time).toBe('Everything shown'); + expect(readout.context).toBe('All 2h of shown active time'); + }); }); describe('formatToolName', () => { From c6aabc84082f23483e1d5abc3b1c7987138f3fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 09:29:18 +0000 Subject: [PATCH 11/22] test(api): seed attribution tables in the timeline harness after rebase Co-Authored-By: Claude Opus 5.5 --- apps/api/tests/unit/workspace-resource-timeline.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/api/tests/unit/workspace-resource-timeline.test.ts b/apps/api/tests/unit/workspace-resource-timeline.test.ts index 9da5df377c..0df0300dc6 100644 --- a/apps/api/tests/unit/workspace-resource-timeline.test.ts +++ b/apps/api/tests/unit/workspace-resource-timeline.test.ts @@ -122,6 +122,10 @@ function setup(envOverrides: Record = {}) { createSchemaTables(sqlite, [ schema.nodes, schema.workspaces, + schema.tasks, + schema.agentSessions, + schema.agentProfiles, + schema.skills, schema.workspaceResourceSummaries, schema.workspaceResourceChunks, ]); From 889066b078dceda1e267d7d14920deaf0253a4dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 09:35:17 +0000 Subject: [PATCH 12/22] feat: expired-history state; rewrite Resources docs for the whole-session timeline Co-Authored-By: Claude Opus 5.5 --- .claude/skills/api-reference/SKILL.md | 2 + .claude/skills/env-reference/SKILL.md | 2 + .../services/workspace-resource-timeline.ts | 33 +- .../unit/workspace-resource-timeline.test.ts | 18 ++ .../resource-timeline/ResourceTimeline.tsx | 108 ++++--- .../resource-timeline/TimelineControls.tsx | 38 ++- .../resource-timeline/TimelineNavigator.tsx | 45 ++- .../chat/resource-timeline/TimelinePanels.tsx | 29 +- .../chat/resource-timeline/format.ts | 4 +- .../chat/resource-timeline/panels.ts | 88 +++++- .../chat/resource-timeline/readout.ts | 57 +++- .../chat/resource-timeline/resource-source.ts | 28 +- .../chat/resource-timeline/series.ts | 7 +- .../chat/resource-timeline/time-axis.ts | 31 +- .../chat/resource-timeline/types.ts | 7 +- .../chat/resource-timeline/useElementWidth.ts | 4 +- .../useResourceTimelineData.ts | 26 +- .../resource-timeline/useTimelineGestures.ts | 5 +- apps/web/src/lib/api/resource-timeline.ts | 2 +- .../playwright/resource-timeline-scenarios.ts | 13 +- .../session-tool-rail-audit.spec.ts | 47 ++- .../docs/session-resources-drawer-mobile.png | Bin 174519 -> 218842 bytes .../images/docs/session-resources-drawer.png | Bin 474308 -> 217529 bytes .../images/docs/session-resources-zoomed.png | Bin 0 -> 184836 bytes .../docs/docs/guides/session-resources.md | 283 +++++++++--------- .../src/content/docs/docs/reference/api.md | 11 + .../docs/docs/reference/configuration.md | 3 + 27 files changed, 618 insertions(+), 273 deletions(-) create mode 100644 apps/www/public/images/docs/session-resources-zoomed.png diff --git a/.claude/skills/api-reference/SKILL.md b/.claude/skills/api-reference/SKILL.md index 2faa998a62..87bb3b4267 100644 --- a/.claude/skills/api-reference/SKILL.md +++ b/.claude/skills/api-reference/SKILL.md @@ -58,6 +58,8 @@ user-invocable: false - `GET /api/projects/:projectId/sessions/:sessionId/state` — Get lightweight ACP activity state for a chat session - `GET /api/projects/:projectId/sessions/:sessionId/messages` — List persisted session messages (supports `roles`, `before`, `after`, `limit`, `compact`, `order=asc|desc`). `before`/`after` accept an exact `[createdAt,sequence,id]` cursor taken from a page's edge row — required to resume inside a group of rows sharing one timestamp — or a legacy millisecond timestamp that excludes every row at that time. Without `order`, an `after`-only request reads ascending; anything else reads newest-first. Pages are always returned oldest-first. - `GET /api/projects/:projectId/sessions/:sessionId/messages/:messageId/tool-content` — Lazy-load stored tool content for compact messages, falling back to the private R2 archive when inline payloads have been stripped +- `GET /api/projects/:projectId/sessions/:sessionId/resource-timeline` — Whole-session resource timeline index: every retained chunk (ascending) with its summary and per-minute `rollup`, `runs` per workspace with `reservation`, `omittedChunkCount` past `WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS`, and `collection` (`collected`/`pending`/`unsupported`/`expired`) explaining an empty timeline +- `GET /api/projects/:projectId/sessions/:sessionId/resource-timeline/chunks/:chunkId` — One chunk's 5-second samples and tool spans, scoped to the path's project and session (foreign chunk → 404) - `GET /api/projects/:projectId/sessions/:sessionId/comments` — List message-anchored comment threads (supports `messageId`, `status=open|sent|resolved`, `afterSequence`, `limit`) - `POST /api/projects/:projectId/sessions/:sessionId/comments` — Create a message-anchored comment thread (`{ messageId, body, quote?, clientMutationId? }`) - `POST /api/projects/:projectId/sessions/:sessionId/comments/:threadId/replies` — Append a comment reply (`{ body, clientMutationId? }`) diff --git a/.claude/skills/env-reference/SKILL.md b/.claude/skills/env-reference/SKILL.md index 1d499e015c..24d83a3c3c 100644 --- a/.claude/skills/env-reference/SKILL.md +++ b/.claude/skills/env-reference/SKILL.md @@ -192,6 +192,8 @@ Activity coalescing and binding caches are per Worker isolate. Delayed flushes c - `NODE_STOPPED_HANDOFF_REQUEST_TIMEOUT_MS` — Per-candidate provider/DNS deadline during stopped-node handoff, capped by remaining sweep time; provider failures enter cleanup backoff (default: `5000`) - `IDLE_CLEANUP_MAX_RESIDENCE_MS` — Maximum ProjectData idle-cleanup schedule residence before preserved/error outcomes stop re-arming and surface attention (default: `7200000`) - `IDLE_CLEANUP_MAX_CANDIDATES_PER_SWEEP` — Maximum idle-cleanup schedules or workspace idle checks one ProjectData alarm pass takes, and maximum reporter-scoped task candidates inspected per check (default: `5`) +- `WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS` — Max chunks the session resource timeline lists; older ones are disclosed as omitted (default: `1000`) +- `WORKSPACE_RESOURCE_ROLLUP_BUCKET_MS` / `WORKSPACE_RESOURCE_ROLLUP_MAX_BUCKETS` — Per-chunk rollup bucket width computed on upload, and the bucket cap that widens it (defaults: `60000` / `60`) - `WORKSPACE_IDLE_TIMEOUT_MS` — Installation default for how long an active chat session's workspace can go without messages or terminal activity before ProjectData retires it, once its runtime is conclusively dead; the project Workspace Idle Timeout setting overrides it (default: `7200000`) - `WORKSPACE_IDLE_BACKOFF_BASE_MS` — First retry delay after a ProjectData workspace-idle check finds an idle workspace it cannot retire yet: inconclusive task candidates, a live or unprovable runtime, a missing project identity, or a failed check (default: `600000`; `apps/api/src/durable-objects/project-data/workspace-idle-timeouts.ts`) - `WORKSPACE_IDLE_BACKOFF_MAX_MS` — Maximum workspace-idle retry delay; the delay doubles from the base and resets on new activity or when the session wakes (default: `21600000`) diff --git a/apps/api/src/services/workspace-resource-timeline.ts b/apps/api/src/services/workspace-resource-timeline.ts index 83fe9d04fc..4550ca961a 100644 --- a/apps/api/src/services/workspace-resource-timeline.ts +++ b/apps/api/src/services/workspace-resource-timeline.ts @@ -60,10 +60,12 @@ export interface ResourceTimelineIndexResponse { omittedChunkCount: number; maxChunks: number; /** - * `unsupported` when nothing is recorded and the session's runtime does not - * collect resource history; `pending` when nothing has been uploaded yet. + * Why an empty timeline is empty: `unsupported` when the session's runtime + * collects no resource history, `expired` when samples were collected but have + * passed their retention (the session summary outlives them), and `pending` + * when nothing has been uploaded yet. */ - collection: 'collected' | 'pending' | 'unsupported'; + collection: 'collected' | 'pending' | 'unsupported' | 'expired'; runtime: string | null; } @@ -158,6 +160,19 @@ async function loadSessionRuntime( return row?.runtime ?? null; } +/** A retained summary with no chunks means the samples existed and expired. */ +async function hasSessionSummary(env: Env, projectId: string, sessionId: string): Promise { + const row = await env.DATABASE.prepare( + `SELECT 1 AS present + FROM workspace_resource_summaries + WHERE project_id = ? AND session_id = ? + LIMIT 1` + ) + .bind(projectId, sessionId) + .first<{ present: number }>(); + return row != null; +} + function groupRuns( chunks: readonly ResourceTimelineChunk[], workspaces: ReadonlyMap @@ -231,7 +246,10 @@ export async function getSessionResourceTimeline( chunks.reverse(); if (chunks.length === 0) { - const runtime = await loadSessionRuntime(env, projectId, sessionId); + const [runtime, hasSummary] = await Promise.all([ + loadSessionRuntime(env, projectId, sessionId), + hasSessionSummary(env, projectId, sessionId), + ]); return { sessionId, runs: [], @@ -239,7 +257,12 @@ export async function getSessionResourceTimeline( totalChunkCount, omittedChunkCount: 0, maxChunks, - collection: runtime && UNSUPPORTED_RUNTIMES.has(runtime) ? 'unsupported' : 'pending', + collection: + runtime && UNSUPPORTED_RUNTIMES.has(runtime) + ? 'unsupported' + : hasSummary + ? 'expired' + : 'pending', runtime, }; } diff --git a/apps/api/tests/unit/workspace-resource-timeline.test.ts b/apps/api/tests/unit/workspace-resource-timeline.test.ts index 0df0300dc6..2094d6cc64 100644 --- a/apps/api/tests/unit/workspace-resource-timeline.test.ts +++ b/apps/api/tests/unit/workspace-resource-timeline.test.ts @@ -438,6 +438,24 @@ describe('session resource timeline', () => { expect(unknown).toMatchObject({ collection: 'pending', runtime: null }); }); + it('reports expired history when samples aged out but the session summary remains', async () => { + const { sqlite, env, addNode, addWorkspace } = setup(); + addNode('node-a', 'vm'); + addWorkspace('ws-1', 'proj-1', 'node-a', 'sess-old'); + addWorkspace('ws-2', 'proj-2', 'node-a', 'sess-foreign'); + await upload(env, { projectId: 'proj-1', workspaceId: 'ws-1', sessionId: 'sess-old', sequence: 0, start: T0 }); + await upload(env, { projectId: 'proj-2', workspaceId: 'ws-2', sessionId: 'sess-foreign', sequence: 0, start: T0 }); + // Retention cleanup deletes the chunks and keeps the longer-lived summary. + sqlite.prepare('DELETE FROM workspace_resource_chunks').run(); + + const expired = await getSessionResourceTimeline(env, { projectId: 'proj-1', sessionId: 'sess-old' }); + // Another project's summary for a session id must not make this project's answer "expired". + const foreign = await getSessionResourceTimeline(env, { projectId: 'proj-1', sessionId: 'sess-foreign' }); + + expect(expired).toMatchObject({ collection: 'expired', chunks: [] }); + expect(foreign).toMatchObject({ collection: 'pending', chunks: [] }); + }); + it('reads a chunk only through its own project and session', async () => { const { env, addNode, addWorkspace } = setup(); addNode('node-a', 'vm'); diff --git a/apps/web/src/components/chat/resource-timeline/ResourceTimeline.tsx b/apps/web/src/components/chat/resource-timeline/ResourceTimeline.tsx index 9efa82e3d5..6ca4a6ccb9 100644 --- a/apps/web/src/components/chat/resource-timeline/ResourceTimeline.tsx +++ b/apps/web/src/components/chat/resource-timeline/ResourceTimeline.tsx @@ -1,9 +1,9 @@ import { Button, Spinner } from '@simple-agent-manager/ui'; import { AlertTriangle } from 'lucide-react'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { type ReactNode, useCallback, useMemo, useRef, useState } from 'react'; import { formatBytes, formatDayTime, formatElapsed, formatMinutes } from './format'; -import { type PanelState,PLOT_LEFT_GUTTER_PX, PLOT_RIGHT_PADDING_PX } from './panels'; +import { type PanelState, PLOT_LEFT_GUTTER_PX, PLOT_RIGHT_PADDING_PX } from './panels'; import { readoutAtCursor, readoutForRange } from './readout'; import type { ResourceHistorySource } from './resource-source'; import { buildSeries, findPeaks, type UsagePeak } from './series'; @@ -14,7 +14,11 @@ import { TimelineNavigator } from './TimelineNavigator'; import { TimelinePanels } from './TimelinePanels'; import type { ResourceTimelineIndex } from './types'; import { useElementWidth } from './useElementWidth'; -import { useResourceTimelineData, useResourceTimelineIndex, useTimelineAxis } from './useResourceTimelineData'; +import { + useResourceTimelineData, + useResourceTimelineIndex, + useTimelineAxis, +} from './useResourceTimelineData'; /** Peaks closer together than this count as one moment. */ const PEAK_SEPARATION_MS = 20 * 60_000; @@ -47,41 +51,47 @@ export function ResourceTimeline({ source }: Readonly<{ source: ResourceHistoryS ); } if (indexQuery.data.chunks.length === 0) { - return indexQuery.data.collection === 'unsupported' ? ( - - ) : ( - - ); + switch (indexQuery.data.collection) { + case 'unsupported': + return ( + + Instant sessions run in a lightweight container that does not record CPU, memory or disk + usage yet. Sessions on a VM workspace record the full timeline. + + ); + case 'expired': + return ( + + This session's CPU, memory and disk samples are older than the retention period and + have been deleted. + + ); + default: + return ( + + The workspace samples CPU, memory and disk every few seconds and uploads them every{' '} + {formatMinutes(indexQuery.data.uploadIntervalMs)}, so the first data appears about{' '} + {formatMinutes(indexQuery.data.uploadIntervalMs)} after the session starts. + + ); + } } return ; } -function EmptyTimeline({ uploadIntervalMs }: Readonly<{ uploadIntervalMs: number }>) { - return ( -
-

No resource samples yet

-

- The workspace samples CPU, memory and disk every few seconds and uploads them every{' '} - {formatMinutes(uploadIntervalMs)}, so the first data appears about {formatMinutes(uploadIntervalMs)} after the - session starts. -

-
- ); -} - -function UnsupportedTimeline() { +function EmptyNotice({ title, children }: Readonly<{ title: string; children: ReactNode }>) { return (
-

Not recorded for Instant sessions

-

- Instant sessions run in a lightweight container that does not record CPU, memory or disk usage yet. Sessions on - a VM workspace record the full timeline. -

+

{title}

+

{children}

); } -function SessionSummary({ index, activeMs }: Readonly<{ index: ResourceTimelineIndex; activeMs: number }>) { +function SessionSummary({ + index, + activeMs, +}: Readonly<{ index: ResourceTimelineIndex; activeMs: number }>) { const first = index.runs[0]?.startedAt ?? index.chunks[0]?.startedAt ?? 0; const last = index.chunks.at(-1)?.endedAt ?? first; const nodes = new Set(index.runs.map((run) => run.nodeId).filter(Boolean)).size; @@ -112,7 +122,10 @@ function SessionSummary({ index, activeMs }: Readonly<{ index: ResourceTimelineI ); } -function TimelineBody({ source, index }: Readonly<{ source: ResourceHistorySource; index: ResourceTimelineIndex }>) { +function TimelineBody({ + source, + index, +}: Readonly<{ source: ResourceHistorySource; index: ResourceTimelineIndex }>) { const [axisMode, setAxisMode] = useState('active'); const axis = useTimelineAxis(index, axisMode); const { view, intent, setRange, zoom, showSpan, showLatest, showAll } = useTimelineView(axis); @@ -123,7 +136,11 @@ function TimelineBody({ source, index }: Readonly<{ source: ResourceHistorySourc const width = useElementWidth(measureRef); const plotWidth = Math.max(1, width - PLOT_LEFT_GUTTER_PX - PLOT_RIGHT_PADDING_PX); - const data = useResourceTimelineData(source, index, axis, { min: view.min, max: view.max, widthPx: plotWidth }); + const data = useResourceTimelineData(source, index, axis, { + min: view.min, + max: view.max, + widthPx: plotWidth, + }); const series = useMemo( () => buildSeries(data.aggregates, axis, view.min, view.max, plotWidth, index.sampleIntervalMs), [data.aggregates, axis, view.min, view.max, plotWidth, index.sampleIntervalMs] @@ -149,7 +166,13 @@ function TimelineBody({ source, index }: Readonly<{ source: ResourceHistorySourc const cursorInView = cursorX != null && cursorX >= view.min && cursorX <= view.max; const readout = cursorInView ? readoutAtCursor(cursorX, series, axis, index.runs, data.toolSpans, index.sampleIntervalMs) - : readoutForRange(view.min, view.max, axis, data.aggregates, index.completeness.kind === 'truncated'); + : readoutForRange( + view.min, + view.max, + axis, + data.aggregates, + index.completeness.kind === 'truncated' + ); const peaks = useMemo( () => @@ -160,7 +183,10 @@ function TimelineBody({ source, index }: Readonly<{ source: ResourceHistorySourc [data.overview] ); - const onCursor = useCallback((x: number | null) => setCursorT(x == null ? null : toReal(axis, x)), [axis]); + const onCursor = useCallback( + (x: number | null) => setCursorT(x == null ? null : toReal(axis, x)), + [axis] + ); const onAxisMode = useCallback((mode: TimeAxisMode) => setAxisMode(mode), []); const centre = cursorInView ? cursorX : (view.min + view.max) / 2; const runLabel = useCallback( @@ -222,19 +248,23 @@ function TimelineBody({ source, index }: Readonly<{ source: ResourceHistorySourc About this data
  • - Sampled every {formatElapsed(index.sampleIntervalMs)} from the workspace container's cgroup. It covers - everything in the container, not one process. + Sampled every {formatElapsed(index.sampleIntervalMs)} from the workspace + container's cgroup. It covers everything in the container, not one process.
  • CPU is in cores: 1.0 means one core fully busy.
  • - Memory “used” is the working set the kernel cannot reclaim; “incl. cache” adds page - cache it can drop under pressure. Size workspaces by “used”. + Memory “used” is the working set the kernel cannot reclaim; “incl. + cache” adds page cache it can drop under pressure. Size workspaces by + “used”. +
  • +
  • + Tool calls line up with usage by time only. A spike during a tool call is a correlation, + not proof that the tool caused it.
  • - Tool calls line up with usage by time only. A spike during a tool call is a correlation, not proof that the - tool caused it. + Zoomed out, each point is an average with its peak shaded; zoom in for individual + samples.
  • -
  • Zoomed out, each point is an average with its peak shaded; zoom in for individual samples.
diff --git a/apps/web/src/components/chat/resource-timeline/TimelineControls.tsx b/apps/web/src/components/chat/resource-timeline/TimelineControls.tsx index 9502cfe6e6..3876408356 100644 --- a/apps/web/src/components/chat/resource-timeline/TimelineControls.tsx +++ b/apps/web/src/components/chat/resource-timeline/TimelineControls.tsx @@ -25,7 +25,12 @@ export function ReadoutBar({ pendingChunks, failedChunks, onClear, -}: Readonly<{ readout: Readout; pendingChunks: number; failedChunks: number; onClear: () => void }>) { +}: Readonly<{ + readout: Readout; + pendingChunks: number; + failedChunks: number; + onClear: () => void; +}>) { return (
@@ -90,7 +95,12 @@ export function RangeControls({ const presets = PRESET_SPANS_MS.filter((preset) => preset.ms < fullSpanMs * 0.9); return (
- {presets.map((preset) => { @@ -118,7 +128,11 @@ export function RangeControls({