diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index 1db2e89e9..545ff34c6 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -2462,8 +2462,9 @@ test("presents Dashboard page-chain results and System boundaries without extra await page.getByRole("button", { name: "Dashboard", exact: true }).click(); await dashboard.getByRole("table", { name: "Recent Sessions" }).getByRole("button", { name: "Lifecycle Agent" }).click(); - await expect(page.locator(".session-page")).toBeVisible(); - await expect(page.getByText("Lifecycle Agent", { exact: true }).first()).toBeVisible(); + const sessionPage = page.locator(".session-page"); + await expect(sessionPage).toBeVisible(); + await expect(sessionPage.getByText("Lifecycle Agent", { exact: true }).first()).toBeVisible(); await page.getByRole("button", { name: "System", exact: true }).click(); const system = page.locator(".system-page"); @@ -2656,6 +2657,7 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand device_id: null, connection_generation: null, }, + lifecycle_state: "active", status: "observed", reason: null, allocation_created_at: baseline - 8_500, @@ -2680,7 +2682,7 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand await expect(dashboard.locator(".dashboard-runtime-sample-count")).toContainText("1 sample ·"); await expect(dashboard.getByRole("heading", { name: "CPU usage" })).toBeVisible(); await expect(dashboard.getByRole("heading", { name: "Memory usage" })).toBeVisible(); - await expect(dashboard.getByRole("heading", { name: "Compute uptime" })).toBeVisible(); + await expect(dashboard.getByRole("heading", { name: "Active sandboxes" })).toBeVisible(); await expect(dashboard.getByRole("heading", { name: "Token throughput" })).toBeVisible(); await expect(dashboard.getByLabel("Live Runtime sampling every 30 seconds")).toBeVisible(); const liveRange = dashboard.getByRole("group", { name: "Runtime live range" }); @@ -2692,7 +2694,7 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand await refresh.click(); await expect(dashboard.getByLabel("CPU usage: 3 live samples")).toBeVisible(); await expect(dashboard.getByLabel("Memory usage: 3 live samples")).toBeVisible(); - await expect(dashboard.getByLabel("Compute uptime: 3 live samples")).toBeVisible(); + await expect(dashboard.getByLabel("Active sandboxes: 3 live samples")).toBeVisible(); await expect(dashboard.getByLabel("Token throughput: 3 live samples")).toBeVisible(); await expect(dashboard.locator(".dashboard-runtime-sample-count")).toContainText("3 samples"); await expect(dashboard.getByText("CPU usage live trend available")).toBeAttached(); @@ -2728,7 +2730,7 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toBeVisible(); await cpuChart.click({ position: { x: 260, y: 90 } }); await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Pinned"); - await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Unavailable"); + await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("10%"); await cpuChart.focus(); await cpuChart.press("ArrowRight"); await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Pinned"); @@ -2760,7 +2762,7 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand const keyboardSelectedAt = Number(await cpuChart.getAttribute("data-selected-at")); expect(keyboardSelectedAt).toBeGreaterThanOrEqual(zoomedViewStart); expect(keyboardSelectedAt).toBeLessThanOrEqual(zoomedViewEnd); - for (const chartName of ["Memory usage", "Compute uptime", "Token throughput"]) { + for (const chartName of ["Memory usage", "Active sandboxes", "Token throughput"]) { await expect(dashboard.getByLabel(`${chartName}: 3 live samples`)).toHaveAttribute("data-view-start", String(initialViewStart)); await expect(dashboard.getByLabel(`${chartName}: 3 live samples`)).toHaveAttribute("data-view-end", String(initialViewEnd)); } @@ -2838,6 +2840,7 @@ test("restores retained Runtime history after a Dashboard reload", async ({ page id: sessionId, object: "agent.runtime_observation", session_id: sessionId, environment_id: environmentId, mode: "openai_hosted", provider_type: "docker", instance: { kind: "managed_allocation", allocation_id: allocationId, device_id: null, connection_generation: null }, + lifecycle_state: "active", status: "observed", reason: null, allocation_created_at: now - 600, resolved_at: now, observed_at: now - 1, started_at: now - 600, cpu: { usage_seconds_total: 120, capacity_cores: 2, usage_cores: null, utilization_ratio: null }, @@ -2910,19 +2913,21 @@ test("restores retained Runtime history after a Dashboard reload", async ({ page await expect(dashboard.getByLabel(/Durable · 30s; 1 Runtime targets/)).toBeVisible(); await expect(dashboard.getByLabel("Runtime durable-history charts")).toBeVisible(); await expect(dashboard.getByRole("heading", { name: "Compute uptime", exact: true })).toHaveCount(0); - await expect(dashboard.locator('[data-chart-engine="uplot"]')).toHaveCount(3); + await expect(dashboard.getByRole("heading", { name: "Active sandboxes", exact: true })).toBeVisible(); + await expect(dashboard.locator('[data-chart-engine="uplot"]')).toHaveCount(4); await expect(dashboard.getByText("CPU usage durable trend available")).toBeAttached(); await expect(dashboard).toContainText("120 buckets"); await expect(dashboard).toContainText("119/120 observations"); + await expect(dashboard.getByText("Active sandboxes durable trend available")).toBeAttached(); await expect(dashboard.getByText("Token throughput durable trend available")).toBeAttached(); const durableCpuChart = dashboard.getByLabel("CPU usage: 120 retained buckets"); await expect(dashboard.getByRole("region", { name: "CPU usage durable history chart" })).toBeVisible(); const durableCpuCard = durableCpuChart.locator("xpath=ancestor::section[contains(@class, 'dashboard-runtime-trend-card')]"); await durableCpuChart.focus(); await durableCpuChart.press("ArrowLeft"); - await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Unavailable"); + await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("0%"); await durableCpuChart.press("ArrowLeft"); - await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Unavailable"); + await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("0%"); const durableMemoryCard = dashboard.getByRole("region", { name: "Memory usage durable history chart" }); const durableMemorySpan = await durableMemoryCard.locator("canvas").evaluate((canvas: HTMLCanvasElement) => { const context = canvas.getContext("2d"); @@ -3037,18 +3042,16 @@ test("publishes Dashboard counts only after every top-level Agent and Session pa await expect(dashboard.locator(".dashboard-source-badge").filter({ hasText: "Runtime" })).toContainText("Unavailable"); expect(sessionAfters).toEqual([null, "session_snapshot", null, "session_snapshot"]); await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await expect.poll(() => sessionAfters.length).toBe(6); + await expect.poll(() => sessionAfters.length).toBe(4); await page.getByRole("button", { name: "Dashboard", exact: true }).click(); await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3"); await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("2"); expect(agentAfters).toEqual([null, "agent_b"]); - // Session collection loads once; the unavailable Runtime snapshot is retried - // on entry to Sessions and again on return to Dashboard. Each reads both pages. + // The cached Dashboard and shared Session collection stay mounted across + // navigation, so no extra page-chain read occurs on either transition. await expect.poll(() => sessionAfters).toEqual([ null, "session_snapshot", null, "session_snapshot", - null, "session_snapshot", - null, "session_snapshot", ]); }); diff --git a/apps/web/src/features/dashboard/DashboardView.test.tsx b/apps/web/src/features/dashboard/DashboardView.test.tsx index 448b016c0..39f3a23e0 100644 --- a/apps/web/src/features/dashboard/DashboardView.test.tsx +++ b/apps/web/src/features/dashboard/DashboardView.test.tsx @@ -228,6 +228,7 @@ describe("Dashboard loaded-result presentation", () => { device_id: null, connection_generation: null, }, + lifecycle_state: "active", status: "observed", reason: null, allocation_created_at: 1_700_000_000, @@ -256,7 +257,8 @@ describe("Dashboard loaded-result presentation", () => { expect(html).toContain('aria-pressed="true">1h'); expect(html).toContain("CPU usage"); expect(html).toContain("Memory usage"); - expect(html).toContain("Compute uptime"); + expect(html).not.toContain("Compute uptime"); + expect(html).toContain("Active sandboxes"); expect(html).toContain("Token throughput"); expect(html).not.toContain("No retained CPU samples"); expect(html).toContain("Latest value"); @@ -342,6 +344,7 @@ describe("Dashboard loaded-result presentation", () => { mode: "openai_hosted", provider_type: "docker", instance: { kind: "managed_allocation", allocation_id: "33333333-3333-4333-8333-333333333333", device_id: null, connection_generation: null }, + lifecycle_state: "active", status: "observed", reason: null, allocation_created_at: 1_700_000_000, diff --git a/apps/web/src/features/dashboard/RuntimeObservabilityContent.tsx b/apps/web/src/features/dashboard/RuntimeObservabilityContent.tsx index 00188b746..2c39d9d37 100644 --- a/apps/web/src/features/dashboard/RuntimeObservabilityContent.tsx +++ b/apps/web/src/features/dashboard/RuntimeObservabilityContent.tsx @@ -315,7 +315,7 @@ export function RuntimeObservabilityContent({ return ( <>
- } label={t("runtime.metrics.active")} value={summary.observedRuntimeCount.toLocaleString(locale)} detail={t("runtime.metrics.activeDetail", { managed: summary.managedRuntimeCount, unavailable: summary.unavailableRuntimeCount })} /> + } label={t("runtime.metrics.sandboxState")} value={t("runtime.metrics.sandboxStateValue", { active: summary.activeSandboxCount.toLocaleString(locale), sleeping: summary.sleepingSandboxCount.toLocaleString(locale) })} detail={t("runtime.metrics.sandboxStateDetail", { total: summary.sandboxTotalCount.toLocaleString(locale), transitioning: (summary.transitioningSandboxCount + summary.pendingSandboxCount).toLocaleString(locale) })} /> } label={t("runtime.metrics.cpu")} value={summary.cpuUsageSecondsTotal === null && summary.cpuCapacityCores === null ? t("runtime.metrics.noSample") : `${summary.cpuUsageSecondsTotal === null ? t("runtime.filters.unavailable") : formatDashboardDuration(summary.cpuUsageSecondsTotal)} / ${summary.cpuCapacityCores === null ? "—" : t("runtime.cores", { value: summary.cpuCapacityCores.toLocaleString(locale) })}`} detail={t("runtime.metrics.cpuDetail", { covered: summary.cpuCoverageCount, total: summary.observedRuntimeCount })} /> } label={t("runtime.metrics.memory")} value={summary.memoryUsageBytes === null && summary.memoryLimitBytes === null ? t("runtime.metrics.noSample") : `${summary.memoryUsageBytes === null ? t("runtime.filters.unavailable") : formatDashboardBytes(summary.memoryUsageBytes)} / ${summary.memoryLimitBytes === null ? t("runtime.filters.unavailable") : formatDashboardBytes(summary.memoryLimitBytes)}`} detail={t("runtime.metrics.memoryDetail", { covered: summary.memoryCoverageCount, total: summary.observedRuntimeCount })} /> } label={t("runtime.metrics.tokens")} value={summary.totalTokens === null ? t("runtime.filters.unavailable") : formatDashboardTokens(summary.totalTokens, locale)} detail={t("runtime.metrics.tokenDetail", { covered: summary.tokenCoverageCount, total: summary.sessionCount })} /> diff --git a/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx b/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx index 5fb94734f..eeba94ed4 100644 --- a/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx +++ b/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx @@ -1,17 +1,17 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { RuntimeTrendCharts, runtimeChartCaption, runtimeChartShowsSparsePoints } from "./RuntimeTrendCharts"; +import { integerTickRatios, RuntimeTrendCharts, runtimeChartCaption, runtimeChartShowsSparsePoints } from "./RuntimeTrendCharts"; import type { RuntimeTrendSample } from "./runtime-trends"; function sample(sampledAt: number, cpuRatio: number | null): RuntimeTrendSample { return { sampledAt, + activeSandboxCount: 1, targets: [{ seriesId: "session-1:allocation-1", label: "Runtime worker", cpuRatio, - uptimeSeconds: 120, }], cpuCandidates: [], memoryUsageBytes: 512, @@ -23,6 +23,11 @@ function sample(sampledAt: number, cpuRatio: number | null): RuntimeTrendSample } describe("Runtime live-window chart accessibility", () => { + it("uses exact integer y-axis positions for Sandbox counts", () => { + expect(integerTickRatios(5).map((ratio) => ratio * 5)).toEqual([5, 3, 2, 0]); + expect(integerTickRatios(17).map((ratio) => ratio * 17)).toEqual([17, 11, 6, 0]); + }); + it("shows isolated or sparse values as points without inventing continuity", () => { expect(runtimeChartShowsSparsePoints([null, 512, null])).toBe(true); expect(runtimeChartShowsSparsePoints([512, 768])).toBe(true); @@ -38,23 +43,32 @@ describe("Runtime live-window chart accessibility", () => { allSeriesHidden: true, validPoints: 0, sampleCount: 24, - emptyMessage: "No complete retained memory samples", + emptyMessage: "No retained observed memory samples", })).toBe("Memory usage all series hidden; use the legend to show a series"); }); it("renders an unavailable current value as zero without retaining a stale value", () => { + const unavailable = { + ...sample(120_000, null), + activeSandboxCount: 0, + memoryUsageBytes: null, + memoryLimitBytes: null, + } satisfies RuntimeTrendSample; const html = renderToStaticMarkup( - , + , ); expect(html).toContain("Runtime worker0%0"); expect(html).not.toContain("Runtime worker50%1"); + expect(html).toContain("used0 B0"); + expect(html).toContain("active00"); }); it("renders empty retained buckets as continuous zero-value chart series", () => { const empty = (sampledAt: number): RuntimeTrendSample => ({ ...sample(sampledAt, null), targets: [], + activeSandboxCount: 0, memoryUsageBytes: null, memoryLimitBytes: null, inputTokensPerMinute: null, @@ -66,6 +80,7 @@ describe("Runtime live-window chart accessibility", () => { expect(html).toContain("usage0%0"); expect(html).toContain("used0 B0"); + expect(html).toContain("active00"); expect(html).toContain("input0/min0"); expect(html).not.toContain("No retained CPU samples"); expect(html).not.toContain("No complete retained memory samples"); @@ -79,7 +94,7 @@ describe("Runtime live-window chart accessibility", () => { expect(html.match(/data-chart-engine="uplot"/g)).toHaveLength(4); expect(html).not.toContain("Collecting live samples"); - expect(html).toContain("Compute uptime"); + expect(html).toContain("Active sandboxes"); }); it("exposes interactive series, point selection, and Grafana-style in-plot range selection", () => { @@ -103,29 +118,42 @@ describe("Runtime live-window chart accessibility", () => { ); expect(html).toContain('aria-label="CPU usage durable history chart"'); - expect(html.match(/data-chart-engine="uplot"/g)).toHaveLength(3); + expect(html.match(/data-chart-engine="uplot"/g)).toHaveLength(4); expect(html).not.toContain("Compute uptime"); expect(html).toContain('aria-label="CPU usage: 2 retained buckets"'); + expect(html).toContain('aria-label="Active sandboxes durable history chart"'); + expect(html).toContain("active10"); expect(html).not.toContain('aria-label="CPU usage: 2 live samples"'); }); - it("can retain an honest uptime card when a consumer requires four metric panels", () => { + it("renders one summed active-Sandbox series instead of one series per Session", () => { + const latest = { ...sample(120_000, .5), activeSandboxCount: 3 }; const html = renderToStaticMarkup( - , + , ); expect(html.match(/data-chart-engine="uplot"/g)).toHaveLength(4); - expect(html).toContain("Compute uptime"); - expect(html).toContain("Live-only metric"); - expect(html).toContain("Select Live to inspect current Runtime uptime"); + expect(html).toContain("active30"); + expect(html.match(/aria-label="Hide active series"/g)).toHaveLength(1); }); + it("renders the Session view as one binary Runtime-active series", () => { + const latest = { ...sample(120_000, .5), activeSandboxCount: 3 }; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Runtime active"); + expect(html).toContain("1 active / 0 inactive"); + expect(html).toContain("RuntimeActive0"); + expect(html).not.toContain("Active sandboxes"); + }); it("announces an isolated durable value as sparse rather than empty", () => { const html = renderToStaticMarkup( , ); expect(html).toContain("Memory usage durable trend has 1 sparse valid point; a line requires consecutive buckets"); - expect(html).not.toContain("Memory usage No complete retained memory samples"); + expect(html).not.toContain("Memory usage No retained observed memory samples"); }); }); diff --git a/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx b/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx index 74d1b6b13..c85f06857 100644 --- a/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx +++ b/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx @@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next"; import uPlot from "uplot"; import "uplot/dist/uPlot.min.css"; -import { formatDashboardBytes, formatDashboardDuration, formatDashboardTokens } from "./dashboard-model"; +import { formatDashboardBytes, formatDashboardTokens } from "./dashboard-model"; import { tokenThroughput, type RuntimeTrendSample } from "./runtime-trends"; interface TrendPoint { @@ -23,6 +23,7 @@ interface TrendSeries { label: string; tone: "orange" | "green" | "blue" | "purple"; points: TrendPoint[]; + stepped?: boolean; } interface TrendBand { @@ -257,6 +258,7 @@ function TrendChart({ stroke: toneColors[entry.tone], width: 2, spanGaps: false, + paths: entry.stepped ? uPlot.paths.stepped!({ align: 1 }) : undefined, points: { show: (plot, seriesIndex, first, last) => runtimeChartShowsSparsePoints( Array.from(plot.data[seriesIndex] ?? []).slice(first, last + 1), @@ -485,12 +487,12 @@ function TrendChart({ ); } -function targetIds(samples: readonly RuntimeTrendSample[], field: "cpuRatio" | "uptimeSeconds"): string[] { +function targetIds(samples: readonly RuntimeTrendSample[], field: "cpuRatio"): string[] { const latest = new Map(); for (const sample of samples) { for (const target of sample.targets) { const value = target[field]; - latest.set(target.seriesId, value ?? latest.get(target.seriesId) ?? 0); + if (value !== null) latest.set(target.seriesId, value); } } return [...latest.entries()].sort((left, right) => right[1] - left[1]).slice(0, 3).map(([id]) => id); @@ -506,24 +508,31 @@ function targetLabel(samples: readonly RuntimeTrendSample[], id: string): string const tones: TrendSeries["tone"][] = ["orange", "green", "blue"]; +export function integerTickRatios(maximum: number): number[] { + const integerMaximum = Math.max(1, Math.ceil(maximum)); + const values = integerMaximum <= 4 + ? Array.from({ length: integerMaximum + 1 }, (_, index) => integerMaximum - index) + : [integerMaximum, Math.round(integerMaximum * 2 / 3), Math.round(integerMaximum / 3), 0]; + return [...new Set(values)].map((value) => value / integerMaximum); +} + export function RuntimeTrendCharts({ samples, source = "live", rangeStart, rangeEnd, - showDurableUptimePlaceholder = false, + activeDisplay = "sum", }: { samples: readonly RuntimeTrendSample[]; source?: RuntimeTrendSource; rangeStart?: number; rangeEnd?: number; - showDurableUptimePlaceholder?: boolean; + activeDisplay?: "sum" | "binary"; }) { const { t, i18n } = useTranslation("dashboard"); const locale = i18n.resolvedLanguage ?? "en"; const charts = useMemo(() => { const cpuIds = targetIds(samples, "cpuRatio"); - const uptimeIds = targetIds(samples, "uptimeSeconds"); const cpu = cpuIds.map((id, index): TrendSeries => ({ id, label: targetLabel(samples, id), @@ -535,15 +544,18 @@ export function RuntimeTrendCharts({ } const memoryUsed = samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.memoryUsageBytes ?? 0 })); const memoryLimit = samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.memoryLimitBytes ?? 0 })); - const uptime = uptimeIds.map((id, index): TrendSeries => ({ - id, - label: targetLabel(samples, id), - tone: tones[(index + 2) % tones.length] ?? "blue", - points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.targets.find((target) => target.seriesId === id)?.uptimeSeconds ?? 0 })), - })); - if (uptime.length === 0 && samples.length > 0) { - uptime.push({ id: "uptime", label: t("charts.runtime"), tone: "blue", points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: 0 })) }); - } + const active = [{ + id: "active", + label: t(activeDisplay === "binary" ? "charts.runtime" : "charts.active.series"), + tone: "green", + stepped: true, + points: samples.map((sample) => ({ + sampledAt: sample.sampledAt, + value: activeDisplay === "binary" + ? (sample.activeSandboxCount ?? 0) > 0 ? 1 : 0 + : sample.activeSandboxCount ?? 0, + })), + }] satisfies TrendSeries[]; const throughput = tokenThroughput(samples); return { cpu, @@ -551,26 +563,35 @@ export function RuntimeTrendCharts({ { id: "used", label: t("charts.used"), tone: "purple", points: memoryUsed }, { id: "limit", label: t("charts.configuredLimit"), tone: "green", points: memoryLimit }, ] satisfies TrendSeries[], - uptime, + active, tokens: [ { id: "input", label: t("charts.input"), tone: "orange", points: throughput.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.inputPerMinute ?? 0 })) }, { id: "output", label: t("charts.output"), tone: "green", points: throughput.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.outputPerMinute ?? 0 })) }, ] satisfies TrendSeries[], }; - }, [samples, t]); + }, [activeDisplay, samples, t]); const cpuMaximum = Math.max(100, ...finite(charts.cpu.flatMap((series) => series.points.map((point) => point.value)))); const memoryMaximum = Math.max(1, ...finite(charts.memory.flatMap((series) => series.points.map((point) => point.value)))); - const uptimeMaximum = Math.max(1, ...finite(charts.uptime.flatMap((series) => series.points.map((point) => point.value)))); + const activeMaximum = Math.max(1, ...finite(charts.active.flatMap((series) => series.points.map((point) => point.value)))); + const activeTicks = integerTickRatios(activeMaximum); const tokenMaximum = Math.max(1, ...finite(charts.tokens.flatMap((series) => series.points.map((point) => point.value)))); const newest = rangeEnd ?? samples.at(-1)?.sampledAt ?? Date.now(); const oldest = rangeStart ?? samples[0]?.sampledAt ?? newest - 60 * 60 * 1_000; const durable = source === "durable"; + const binaryActive = activeDisplay === "binary"; + const activeTitle = t(binaryActive ? "charts.active.runtimeTitle" : "charts.active.sandboxTitle"); + const activeSubtitle = t(binaryActive + ? durable ? "charts.active.binaryDurable" : "charts.active.binaryLive" + : durable ? "charts.active.sumDurable" : "charts.active.sumLive"); + const formatActive = binaryActive + ? (value: number) => t(value >= .5 ? "charts.active.active" : "charts.active.inactive") + : (value: number) => `${Math.round(value)}`; return (
`${Math.round(value)}%`} rangeStart={oldest} rangeEnd={newest} source={source} bands={[{ from: 0, to: 30, tone: "safe" }, { from: 30, to: 70, tone: "warning" }, { from: 70, to: 100, tone: "danger" }]} ticks={[1, .7, .3, 0]} emptyMessage={durable ? t("charts.cpu.empty") : undefined} /> formatDashboardBytes(Math.round(value))} rangeStart={oldest} rangeEnd={newest} source={source} emptyMessage={durable ? t("charts.memory.empty") : undefined} /> - {!durable || showDurableUptimePlaceholder ? formatDashboardDuration(value)} rangeStart={oldest} rangeEnd={newest} source={source} emptyMessage={durable ? t("charts.uptime.empty") : undefined} emptyDetail={durable ? t("charts.uptime.detail") : undefined} /> : null} + t("charts.tokens.perMinute", { value: formatDashboardTokens(Math.round(value), locale) })} rangeStart={oldest} rangeEnd={newest} source={source} emptyMessage={durable ? t("charts.tokens.empty") : undefined} />
); diff --git a/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx b/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx index 91930af2a..270a4c9b3 100644 --- a/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx +++ b/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx @@ -30,16 +30,16 @@ export function RuntimeTrendPanel({ loadRuntimeHistory, headingId = "dashboard-runtime-live-heading", title, - showDurableUptimePlaceholder = false, allowSourceSelection = false, + activeDisplay = "sum", }: { snapshot: RuntimeDashboardSnapshot; stale: boolean; loadRuntimeHistory: RuntimeHistoryLoader; headingId?: string; title?: string; - showDurableUptimePlaceholder?: boolean; allowSourceSelection?: boolean; + activeDisplay?: "sum" | "binary"; }) { const { t, i18n } = useTranslation("dashboard"); const locale = i18n.resolvedLanguage; @@ -178,7 +178,7 @@ export function RuntimeTrendPanel({ {durableState === "failed" && durableError ?

{t("trends.refreshFailed", { error: durableError })}

: null} {durableState === "unavailable" ?

{t("trends.notConfigured")}

: null} - + ); } diff --git a/apps/web/src/features/dashboard/dashboard-model.test.ts b/apps/web/src/features/dashboard/dashboard-model.test.ts index 384d8619b..349e6b3ae 100644 --- a/apps/web/src/features/dashboard/dashboard-model.test.ts +++ b/apps/web/src/features/dashboard/dashboard-model.test.ts @@ -246,6 +246,7 @@ describe("Dashboard loaded-snapshot model", () => { mode: "openai_hosted", provider_type: "docker", instance: { kind: "managed_allocation", allocation_id: "44444444-4444-4444-8444-444444444444", device_id: null, connection_generation: null }, + lifecycle_state: "active", status: "observed", reason: null, allocation_created_at: 100, @@ -262,6 +263,7 @@ describe("Dashboard loaded-snapshot model", () => { mode: "none", provider_type: null, instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null }, + lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", allocation_created_at: null, @@ -276,6 +278,11 @@ describe("Dashboard loaded-snapshot model", () => { expect(model.summary).toMatchObject({ sessionCount: 2, managedRuntimeCount: 1, + sandboxTotalCount: 1, + activeSandboxCount: 1, + sleepingSandboxCount: 0, + transitioningSandboxCount: 0, + pendingSandboxCount: 0, observedRuntimeCount: 1, unavailableRuntimeCount: 0, unsupportedRuntimeCount: 1, @@ -297,6 +304,59 @@ describe("Dashboard loaded-snapshot model", () => { expect(formatDashboardDuration(90)).toBe("1m 30s"); }); + it("counts a shared managed allocation once while retaining both Session rows", () => { + const first = session("11111111-1111-4111-8111-111111111111", { usage: usage(21) }); + const second = session("22222222-2222-4222-8222-222222222222", { usage: usage(5) }); + const allocationId = "44444444-4444-4444-8444-444444444444"; + const firstObservation: RuntimeObservation = { + id: first.id, + object: "agent.runtime_observation", + session_id: first.id, + environment_id: "33333333-3333-4333-8333-333333333333", + mode: "openai_hosted", + provider_type: "docker", + instance: { kind: "managed_allocation", allocation_id: allocationId, device_id: null, connection_generation: null }, + lifecycle_state: "active", + status: "observed", + reason: null, + allocation_created_at: 100, + resolved_at: 220, + observed_at: 210, + started_at: 150, + cpu: { usage_seconds_total: 3.5, capacity_cores: 2, usage_cores: null, utilization_ratio: null }, + memory: { usage_bytes: 512, limit_bytes: 2048 }, + }; + const secondObservation: RuntimeObservation = { + ...firstObservation, + id: second.id, + session_id: second.id, + environment_id: "55555555-5555-4555-8555-555555555555", + resolved_at: 221, + observed_at: 211, + cpu: { usage_seconds_total: 4.5, capacity_cores: 2, usage_cores: null, utilization_ratio: null }, + memory: { usage_bytes: 768, limit_bytes: 2048 }, + }; + + const model = buildRuntimeDashboardModel([first, second], [firstObservation, secondObservation]); + + expect(model.rows).toHaveLength(2); + expect(model.summary).toMatchObject({ + sessionCount: 2, + managedRuntimeCount: 1, + sandboxTotalCount: 1, + activeSandboxCount: 1, + observedRuntimeCount: 1, + cpuUsageSecondsTotal: 4.5, + cpuCapacityCores: 2, + cpuCoverageCount: 1, + memoryUsageBytes: 768, + memoryLimitBytes: 2048, + memoryCoverageCount: 1, + totalTokens: 26, + tokenCoverageCount: 2, + }); + }); + it("holds each Session's last reported tokens in the summary while public usage is null", () => { const running = session("11111111-1111-4111-8111-111111111111", { usage: usage(21) }); const other = session("22222222-2222-4222-8222-222222222222", { usage: usage(5) }); @@ -308,6 +368,7 @@ describe("Dashboard loaded-snapshot model", () => { mode: "none", provider_type: null, instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null }, + lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", allocation_created_at: null, @@ -350,6 +411,7 @@ describe("Dashboard loaded-snapshot model", () => { device_id: null, connection_generation: null, }, + lifecycle_state: "stopped", status: "unavailable", reason: "runtime_not_running", allocation_created_at: 100, @@ -360,7 +422,9 @@ describe("Dashboard loaded-snapshot model", () => { memory: null, }; - expect(buildRuntimeDashboardModel([stopped], [observation]).rows[0]?.allocationAgeSeconds).toBeNull(); + const model = buildRuntimeDashboardModel([stopped], [observation]); + expect(model.rows[0]?.allocationAgeSeconds).toBeNull(); + expect(model.summary).toMatchObject({ sandboxTotalCount: 0, activeSandboxCount: 0, sleepingSandboxCount: 0 }); }); it("does not count capacity-only or limit-only samples as usage coverage", () => { @@ -389,6 +453,7 @@ describe("Dashboard loaded-snapshot model", () => { device_id: null, connection_generation: null, }, + lifecycle_state: "active", status: "observed", reason: null, allocation_created_at: null, diff --git a/apps/web/src/features/dashboard/dashboard-model.ts b/apps/web/src/features/dashboard/dashboard-model.ts index 2fd6a7a14..9f506aef7 100644 --- a/apps/web/src/features/dashboard/dashboard-model.ts +++ b/apps/web/src/features/dashboard/dashboard-model.ts @@ -56,6 +56,11 @@ export interface RuntimeDashboardRow { export interface RuntimeDashboardSummary { sessionCount: number; managedRuntimeCount: number; + sandboxTotalCount: number; + activeSandboxCount: number; + sleepingSandboxCount: number; + transitioningSandboxCount: number; + pendingSandboxCount: number; observedRuntimeCount: number; unavailableRuntimeCount: number; unsupportedRuntimeCount: number; @@ -299,6 +304,11 @@ export function buildRuntimeDashboardModel( const sessionsById = new Map(sessions.map((session) => [session.id, session])); const rows: RuntimeDashboardRow[] = []; let managedRuntimeCount = 0; + let sandboxTotalCount = 0; + let activeSandboxCount = 0; + let sleepingSandboxCount = 0; + let transitioningSandboxCount = 0; + let pendingSandboxCount = 0; let observedRuntimeCount = 0; let unavailableRuntimeCount = 0; let unsupportedRuntimeCount = 0; @@ -322,6 +332,9 @@ export function buildRuntimeDashboardModel( let tokenCoverageCount = 0; let oldestResolvedAt: number | null = null; let newestResolvedAt: number | null = null; + const latestManagedByAllocation = new Map(); + const managedWithoutAllocation: RuntimeObservation[] = []; + const latestObservedByAllocation = new Map(); for (const observation of observations) { const session = sessionsById.get(observation.session_id); @@ -332,9 +345,67 @@ export function buildRuntimeDashboardModel( oldestResolvedAt = oldestResolvedAt === null ? resolvedAt : Math.min(oldestResolvedAt, resolvedAt); newestResolvedAt = newestResolvedAt === null ? resolvedAt : Math.max(newestResolvedAt, resolvedAt); } - if (observation.mode === "openai_hosted") managedRuntimeCount += 1; - if (observation.status === "observed") { - observedRuntimeCount += 1; + if (observation.mode === "openai_hosted") { + const allocationId = observation.instance.allocation_id; + if (allocationId === null || allocationId.length === 0) { + managedWithoutAllocation.push(observation); + } else { + const previous = latestManagedByAllocation.get(allocationId); + if (!previous || observation.resolved_at >= previous.resolved_at) { + latestManagedByAllocation.set(allocationId, observation); + } + if (observation.status === "observed") { + const previousObserved = latestObservedByAllocation.get(allocationId); + if (!previousObserved || ( + observation.observed_at ?? -1 + ) >= (previousObserved.observed_at ?? -1)) { + latestObservedByAllocation.set(allocationId, observation); + } + } + } + } + if (observation.status === "unavailable") { + unavailableRuntimeCount += 1; + } else if (observation.status === "unsupported") { + unsupportedRuntimeCount += 1; + } + + const sessionTokens = sessionRow.totalTokens ?? heldTokens.get(session.id) ?? null; + if (sessionTokens !== null) { + const next = safeAdd(totalTokens, sessionTokens); + if (next !== null) { + totalTokens = next; + tokensKnown = true; + } else tokensSafe = false; + tokenCoverageCount += 1; + } + rows.push({ + session: sessionRow, + observation, + computeUptimeSeconds: observation.status === "observed" + ? elapsedSeconds(canonicalTimestamp(observation.started_at), canonicalTimestamp(observation.observed_at)) + : null, + allocationAgeSeconds: observation.mode === "openai_hosted" && observation.reason !== "runtime_not_running" + ? elapsedSeconds(canonicalTimestamp(observation.allocation_created_at), resolvedAt) + : null, + }); + } + + const managedRuntimes = [...latestManagedByAllocation.values(), ...managedWithoutAllocation]; + managedRuntimeCount = managedRuntimes.length; + for (const observation of managedRuntimes) { + switch (observation.lifecycle_state) { + case "active": activeSandboxCount += 1; sandboxTotalCount += 1; break; + case "sleeping": sleepingSandboxCount += 1; sandboxTotalCount += 1; break; + case "transitioning": transitioningSandboxCount += 1; sandboxTotalCount += 1; break; + case "pending": pendingSandboxCount += 1; sandboxTotalCount += 1; break; + case "stopped": break; + } + } + + const observedRuntimes = [...latestObservedByAllocation.values()]; + observedRuntimeCount = observedRuntimes.length; + for (const observation of observedRuntimes) { const cpuUsage = safeFiniteNonNegative(observation.cpu?.usage_seconds_total); const cpuCapacity = safeFiniteNonNegative(observation.cpu?.capacity_cores); if (cpuUsage !== null) { @@ -370,31 +441,6 @@ export function buildRuntimeDashboardModel( } else memoryLimitSafe = false; } if (memoryUsage !== null) memoryCoverageCount += 1; - } else if (observation.status === "unavailable") { - unavailableRuntimeCount += 1; - } else { - unsupportedRuntimeCount += 1; - } - - const sessionTokens = sessionRow.totalTokens ?? heldTokens.get(session.id) ?? null; - if (sessionTokens !== null) { - const next = safeAdd(totalTokens, sessionTokens); - if (next !== null) { - totalTokens = next; - tokensKnown = true; - } else tokensSafe = false; - tokenCoverageCount += 1; - } - rows.push({ - session: sessionRow, - observation, - computeUptimeSeconds: observation.status === "observed" - ? elapsedSeconds(canonicalTimestamp(observation.started_at), canonicalTimestamp(observation.observed_at)) - : null, - allocationAgeSeconds: observation.mode === "openai_hosted" && observation.reason !== "runtime_not_running" - ? elapsedSeconds(canonicalTimestamp(observation.allocation_created_at), resolvedAt) - : null, - }); } const statusOrder = { observed: 0, unavailable: 1, unsupported: 2 } as const; @@ -406,6 +452,11 @@ export function buildRuntimeDashboardModel( summary: { sessionCount: rows.length, managedRuntimeCount, + sandboxTotalCount, + activeSandboxCount, + sleepingSandboxCount, + transitioningSandboxCount, + pendingSandboxCount, observedRuntimeCount, unavailableRuntimeCount, unsupportedRuntimeCount, diff --git a/apps/web/src/features/dashboard/runtime-history.test.ts b/apps/web/src/features/dashboard/runtime-history.test.ts index 58cbfcd3c..32bf7e8ca 100644 --- a/apps/web/src/features/dashboard/runtime-history.test.ts +++ b/apps/web/src/features/dashboard/runtime-history.test.ts @@ -125,26 +125,74 @@ describe("Runtime Durable Dashboard history", () => { expect(samples).toHaveLength(2); expect(samples[0]).toMatchObject({ sampledAt: 130_000, + activeSandboxCount: 1, memoryUsageBytes: 512, memoryLimitBytes: 1_024, inputTokensPerMinute: null, outputTokensPerMinute: null, - targets: [{ label: "Durable worker", cpuRatio: .25, uptimeSeconds: null }], }); - expect(samples[1]?.targets[0]?.uptimeSeconds).toBeNull(); + expect(samples[0]?.targets).toEqual([ + expect.objectContaining({ label: "Durable worker", cpuRatio: .25 }), + ]); expect(samples[1]).toMatchObject({ inputTokensPerMinute: 60, outputTokensPerMinute: 20 }); }); - it("does not derive compute uptime from retained allocation starts or unavailable observations", () => { + it("counts and aggregates distinct Runtime allocations within one Session", () => { + const source = history(); + const secondSeries = { + ...source.series[0]!, + allocation_id: "55555555-5555-4555-8555-555555555555", + points: source.series[0]!.points.map((point) => ({ + ...point, + memory: point.memory ? { ...point.memory, usage_bytes: 128, limit_bytes: 256 } : null, + })), + }; + const samples = runtimeDurableTrendSamples([session], [{ + ...source, + series: [source.series[0]!, secondSeries], + }]); + + expect(samples[0]).toMatchObject({ + activeSandboxCount: 2, + memoryUsageBytes: 640, + memoryLimitBytes: 1_280, + }); + }); + + it("deduplicates one Runtime allocation repeated across Session histories", () => { + const second = { ...session, id: "44444444-4444-4444-8444-444444444444" } as AgentSession; + const repeated = history({ + session_id: second.id, + series: [{ + ...history().series[0]!, + points: history().series[0]!.points.map((point) => ({ + ...point, + memory: point.memory ? { ...point.memory, usage_bytes: 128, limit_bytes: 256 } : null, + })), + }], + }); + const samples = runtimeDurableTrendSamples([session, second], [history(), repeated]); + + expect(samples[0]).toMatchObject({ + activeSandboxCount: 1, + memoryUsageBytes: 128, + memoryLimitBytes: 256, + }); + }); + + it("projects an unavailable retained observation as zero active Sandboxes", () => { const source = history(); source.series[0]!.points[1] = { ...source.series[0]!.points[1]!, observed_count: 0, unavailable_count: 1, cpu: null, memory: null, }; + source.coverage.buckets[1] = { + ...source.coverage.buckets[1]!, observed_count: 0, unavailable_count: 1, + }; const samples = runtimeDurableTrendSamples([session], [source]); - expect(samples.flatMap((sample) => sample.targets.map((target) => target.uptimeSeconds))).toEqual([null, null]); + expect(samples[1]?.activeSandboxCount).toBe(0); }); - it("keeps aggregate memory absent when any queried target has no memory value", () => { + it("aggregates observed memory without letting an unavailable target erase it", () => { const second = { ...session, id: "44444444-4444-4444-8444-444444444444" } as AgentSession; const secondHistory = history({ session_id: second.id, @@ -152,7 +200,8 @@ describe("Runtime Durable Dashboard history", () => { series: [], }); const samples = runtimeDurableTrendSamples([session, second], [history(), secondHistory]); - expect(samples.every((sample) => sample.memoryUsageBytes === null && sample.memoryLimitBytes === null)).toBe(true); + expect(samples.every((sample) => sample.memoryUsageBytes !== null && sample.memoryLimitBytes !== null)).toBe(true); + expect(samples[0]).toMatchObject({ memoryUsageBytes: 512, memoryLimitBytes: 1_024, activeSandboxCount: 1 }); }); it("keeps omitted buckets between distant observations as gaps", () => { @@ -183,13 +232,13 @@ describe("Runtime Durable Dashboard history", () => { expect(samples.map((sample) => sample.sampledAt)).toEqual( Array.from({ length: 11 }, (_, index) => (130 + index * 30) * 1_000), ); - expect(samples[0]?.targets[0]?.cpuRatio).toBe(.25); - expect(samples[10]?.targets[0]?.cpuRatio).toBe(.5); + expect(samples[0]?.targets.find((target) => target.cpuRatio !== null)?.cpuRatio).toBe(.25); + expect(samples[10]?.targets.find((target) => target.cpuRatio !== null)?.cpuRatio).toBe(.5); expect(samples[10]?.inputTokensPerMinute).toBeNull(); expect(samples[10]?.outputTokensPerMinute).toBeNull(); for (const sample of samples.slice(1, -1)) { expect(sample).toMatchObject({ - targets: [], memoryUsageBytes: null, memoryLimitBytes: null, + activeSandboxCount: null, targets: [], memoryUsageBytes: null, memoryLimitBytes: null, inputTokensPerMinute: null, outputTokensPerMinute: null, }); } @@ -203,6 +252,7 @@ describe("Runtime Durable Dashboard history", () => { expect(samples.map((sample) => sample.sampledAt)).toEqual([100_000, 130_000, 160_000, 190_000, 205_000]); expect(samples.map((sample) => sample.memoryUsageBytes)).toEqual([null, 512, 768, null, null]); expect(samples.map((sample) => sample.targets.length)).toEqual([0, 1, 1, 0, 0]); + expect(samples.map((sample) => sample.activeSandboxCount)).toEqual([null, 1, 1, null, null]); }); it("represents an entirely missing range without fabricating zero measurements", () => { @@ -219,7 +269,7 @@ describe("Runtime Durable Dashboard history", () => { expect(samples.map((sample) => sample.sampledAt)).toEqual([130_000, 160_000, 175_000]); for (const sample of samples) { expect(sample).toMatchObject({ - targets: [], memoryUsageBytes: null, memoryLimitBytes: null, + activeSandboxCount: null, targets: [], memoryUsageBytes: null, memoryLimitBytes: null, inputTokensPerMinute: null, outputTokensPerMinute: null, }); } diff --git a/apps/web/src/features/dashboard/runtime-history.ts b/apps/web/src/features/dashboard/runtime-history.ts index 9200bf92a..91098f53d 100644 --- a/apps/web/src/features/dashboard/runtime-history.ts +++ b/apps/web/src/features/dashboard/runtime-history.ts @@ -80,7 +80,9 @@ async function mapBounded( interface MutableBucket { sampledAt: number; + hasObservationCoverage: boolean; targets: Map; + activeSandboxes: Map; memory: Map; tokens: Map; } @@ -94,7 +96,7 @@ export function runtimeDurableTrendSamples( const bucket = (sampledAt: number): MutableBucket => { let value = buckets.get(sampledAt); if (!value) { - value = { sampledAt, targets: new Map(), memory: new Map(), tokens: new Map() }; + value = { sampledAt, hasObservationCoverage: false, targets: new Map(), activeSandboxes: new Map(), memory: new Map(), tokens: new Map() }; buckets.set(sampledAt, value); } return value; @@ -103,9 +105,13 @@ export function runtimeDurableTrendSamples( for (const history of histories) { const { start, end } = history.requested_range; for (let bucketStart = start; bucketStart < end; bucketStart += history.resolution_seconds) { - bucket(Math.min(bucketStart + history.resolution_seconds, end) * 1_000); + const bucketEnd = Math.min(bucketStart + history.resolution_seconds, end); + bucket(bucketEnd * 1_000); + } + for (const coverage of history.coverage.buckets) { + const value = bucket(coverage.end * 1_000); + value.hasObservationCoverage ||= coverage.observation_count > 0; } - for (const coverage of history.coverage.buckets) bucket(coverage.end * 1_000); for (const usage of history.token_usage) { bucket(usage.end * 1_000).tokens.set(history.session_id, { sampledAt: usage.sampled_at * 1_000, @@ -118,19 +124,25 @@ export function runtimeDurableTrendSamples( const label = titles.get(history.session_id) ?? "Runtime"; for (const point of series.points) { const value = bucket(point.end * 1_000); + value.hasObservationCoverage ||= point.observation_count > 0; const observedAt = point.last_observed_at; value.targets.set(targetID, { seriesId: targetID, label, cpuRatio: point.cpu?.utilization_ratio ?? null, - uptimeSeconds: null, }); + if (observedAt !== null && point.observed_count > 0) { + const previous = value.activeSandboxes.get(series.allocation_id); + if (!previous || observedAt >= previous.observedAt) { + value.activeSandboxes.set(series.allocation_id, { observedAt }); + } + } const usage = point.memory?.usage_bytes; const limit = point.memory?.limit_bytes; if (observedAt !== null && usage != null && limit != null) { - const previous = value.memory.get(history.session_id); + const previous = value.memory.get(series.allocation_id); if (!previous || observedAt >= previous.observedAt) { - value.memory.set(history.session_id, { observedAt, usage, limit }); + value.memory.set(series.allocation_id, { observedAt, usage, limit }); } } } @@ -138,16 +150,17 @@ export function runtimeDurableTrendSamples( } const samples = [...buckets.values()].sort((left, right) => left.sampledAt - right.sampledAt).map((value) => { - const completeMemory = sessions.length > 0 && value.memory.size === sessions.length; + const observedMemory = [...value.memory.values()]; return { sampledAt: value.sampledAt, + activeSandboxCount: value.hasObservationCoverage ? value.activeSandboxes.size : null, targets: [...value.targets.values()], cpuCandidates: [], - memoryUsageBytes: completeMemory - ? [...value.memory.values()].reduce((total, current) => total + current.usage, 0) + memoryUsageBytes: observedMemory.length > 0 + ? observedMemory.reduce((total, current) => total + current.usage, 0) : null, - memoryLimitBytes: completeMemory - ? [...value.memory.values()].reduce((total, current) => total + current.limit, 0) + memoryLimitBytes: observedMemory.length > 0 + ? observedMemory.reduce((total, current) => total + current.limit, 0) : null, tokenTotals: value.tokens.size === sessions.length ? [...value.tokens.entries()].map(([sessionId, usage]) => ({ sessionId, ...usage })) diff --git a/apps/web/src/features/dashboard/runtime-trends.test.ts b/apps/web/src/features/dashboard/runtime-trends.test.ts index 40af9e83a..ab6e339d8 100644 --- a/apps/web/src/features/dashboard/runtime-trends.test.ts +++ b/apps/web/src/features/dashboard/runtime-trends.test.ts @@ -9,6 +9,7 @@ import { runtimeTrendRange, runtimeTrendSample, tokenThroughput, + type RuntimeTrendSample, } from "./runtime-trends"; function snapshot(at: number, options: { @@ -72,6 +73,7 @@ function snapshot(at: number, options: { device_id: null, connection_generation: null, }, + lifecycle_state: "active", allocation_created_at: observedAt - 180, resolved_at: observedAt, observed_at: observedAt, @@ -88,10 +90,13 @@ function snapshot(at: number, options: { } describe("Runtime live-window trends", () => { + const cpuTarget = (sample: RuntimeTrendSample | undefined) => sample?.targets[0]; + it("projects only honest point-in-time and cumulative Session values", () => { const sample = runtimeTrendSample(snapshot(120_000)); expect(sample).toMatchObject({ sampledAt: 120_000, + activeSandboxCount: 1, tokenTotals: [{ sessionId: "11111111-1111-4111-8111-111111111111", inputTokens: 100, @@ -103,10 +108,70 @@ describe("Runtime live-window trends", () => { expect(sample.targets).toEqual([expect.objectContaining({ label: "Runtime worker", cpuRatio: .25, - uptimeSeconds: 120, })]); }); + it("projects a managed Session without an allocation as inactive", () => { + const pending = snapshot(120_000); + pending.observations = [{ + ...pending.observations[0]!, + instance: { kind: "managed_allocation", allocation_id: null, device_id: null, connection_generation: null }, + lifecycle_state: "pending", + status: "unavailable", + reason: "allocation_pending", + allocation_created_at: null, + observed_at: null, + started_at: null, + cpu: null, + memory: null, + } as RuntimeObservation]; + + expect(runtimeTrendSample(pending)).toMatchObject({ activeSandboxCount: 0, targets: [] }); + }); + + it("deduplicates live aggregate count and memory by Runtime allocation identity", () => { + const duplicate = snapshot(120_000); + const secondSession = { + ...duplicate.sessions[0]!, + id: "44444444-4444-4444-8444-444444444444", + } as AgentSession; + const secondObservation = { + ...duplicate.observations[0]!, + id: secondSession.id, + session_id: secondSession.id, + observed_at: (duplicate.observations[0]!.observed_at ?? 0) + 1, + memory: { usage_bytes: 128, limit_bytes: 256 }, + } as RuntimeObservation; + duplicate.sessions.push(secondSession); + duplicate.observations.push(secondObservation); + + expect(runtimeTrendSample(duplicate)).toMatchObject({ + activeSandboxCount: 1, + memoryUsageBytes: 128, + memoryLimitBytes: 256, + }); + }); + + it("keeps lifecycle-active allocation count when resource metrics are unavailable", () => { + const unavailable = snapshot(180_000); + unavailable.observations[0] = { + ...unavailable.observations[0]!, + status: "unavailable", + reason: "runtime_not_running", + observed_at: null, + started_at: null, + cpu: null, + memory: null, + } as RuntimeObservation; + + expect(runtimeTrendSample(unavailable)).toMatchObject({ + activeSandboxCount: 1, + memoryUsageBytes: null, + memoryLimitBytes: null, + targets: [], + }); + }); + it("deduplicates refreshes and bounds the rolling window", () => { let samples = appendRuntimeTrendSample([], snapshot(60_000), 120_000, 2); samples = appendRuntimeTrendSample(samples, snapshot(120_000)); @@ -151,7 +216,7 @@ describe("Runtime live-window trends", () => { let samples = appendRuntimeTrendSample([], cumulative(60_000, 10)); samples = appendRuntimeTrendSample(samples, cumulative(120_000, 70)); samples = appendRuntimeTrendSample(samples, cumulative(180_000, 130)); - expect(samples.map((sample) => sample.targets[0]?.cpuRatio ?? null)).toEqual([null, .5, .5]); + expect(samples.map((sample) => cpuTarget(sample)?.cpuRatio ?? null)).toEqual([null, .5, .5]); }); it("uses allocation identity for Live chart series while ignoring start-time jitter", () => { @@ -162,8 +227,8 @@ describe("Runtime live-window trends", () => { startedAt: 1, allocationId: "44444444-4444-4444-8444-444444444444", })); - expect(first.targets[0]?.seriesId).toBe(jittered.targets[0]?.seriesId); - expect(replaced.targets[0]?.seriesId).not.toBe(first.targets[0]?.seriesId); + expect(cpuTarget(first)?.seriesId).toBe(cpuTarget(jittered)?.seriesId); + expect(cpuTarget(replaced)?.seriesId).not.toBe(cpuTarget(first)?.seriesId); }); it("resets cumulative CPU on start changes, allocation changes or counter regressions", () => { @@ -173,14 +238,13 @@ describe("Runtime live-window trends", () => { const continued = appendRuntimeTrendSample(appendRuntimeTrendSample([], base), snapshot(120_000, { cpuRatio: null, cpuUsageCores: null, cpuUsageSecondsTotal: 160, startedAt: 1, })); - expect(continued.at(-1)?.targets[0]?.cpuRatio ?? null).toBeNull(); - expect(continued[1]?.targets[0]?.seriesId).toBe(continued[0]?.targets[0]?.seriesId); + expect(cpuTarget(continued.at(-1))?.cpuRatio ?? null).toBeNull(); for (const next of [ snapshot(120_000, { cpuRatio: null, cpuUsageCores: null, cpuUsageSecondsTotal: 160, startedAt: 0, allocationId: "44444444-4444-4444-8444-444444444444" }), snapshot(120_000, { cpuRatio: null, cpuUsageCores: null, cpuUsageSecondsTotal: 10, startedAt: 0 }), ]) { const samples = appendRuntimeTrendSample(appendRuntimeTrendSample([], base), next); - expect(samples.at(-1)?.targets[0]?.cpuRatio ?? null).toBeNull(); + expect(cpuTarget(samples.at(-1))?.cpuRatio ?? null).toBeNull(); } }); @@ -191,26 +255,25 @@ describe("Runtime live-window trends", () => { let samples = appendRuntimeTrendSample([], cumulative(60_000, 1, 0)); samples = appendRuntimeTrendSample(samples, cumulative(90_000, 20, 65)); samples = appendRuntimeTrendSample(samples, cumulative(120_000, 50, 65)); - expect(samples.map((sample) => sample.targets[0]?.cpuRatio ?? null)).toEqual([null, null, .5]); - expect(new Set(samples.map((sample) => sample.targets[0]?.seriesId)).size).toBe(1); + expect(samples.map((sample) => cpuTarget(sample)?.cpuRatio ?? null)).toEqual([null, null, .5]); for (const [priorStart, nextStart] of [[null, 0], [0, null], [null, null]] as const) { const missingFence = appendRuntimeTrendSample( appendRuntimeTrendSample([], cumulative(60_000, 1, priorStart)), cumulative(90_000, 20, nextStart), ); - expect(missingFence.at(-1)?.targets[0]?.cpuRatio ?? null).toBeNull(); + expect(cpuTarget(missingFence.at(-1))?.cpuRatio ?? null).toBeNull(); } }); it("keeps directly reported CPU continuous across start changes but not stale observations", () => { const base = snapshot(60_000, { cpuRatio: .25, startedAt: 0 }); const continued = appendRuntimeTrendSample(appendRuntimeTrendSample([], base), snapshot(120_000, { cpuRatio: .5, startedAt: 1 })); - expect(continued.at(-1)?.targets[0]?.cpuRatio ?? null).toBe(.5); + expect(cpuTarget(continued.at(-1))?.cpuRatio ?? null).toBe(.5); const stale = appendRuntimeTrendSample( appendRuntimeTrendSample([], base), snapshot(120_000, { cpuRatio: .5, startedAt: 0, observedAt: 60 }), ); - expect(stale.at(-1)?.targets[0]?.cpuRatio ?? null).toBeNull(); + expect(cpuTarget(stale.at(-1))?.cpuRatio ?? null).toBeNull(); }); it("rejects non-finite CPU ratios produced by finite provider inputs", () => { @@ -219,7 +282,7 @@ describe("Runtime live-window trends", () => { cpuUsageCores: Number.MAX_VALUE, cpuCapacity: Number.MIN_VALUE, })); - expect(direct.targets[0]?.cpuRatio ?? null).toBeNull(); + expect(cpuTarget(direct)?.cpuRatio ?? null).toBeNull(); const cumulative = (at: number, usage: number) => snapshot(at, { cpuRatio: null, @@ -232,7 +295,7 @@ describe("Runtime live-window trends", () => { appendRuntimeTrendSample([], cumulative(60_000, 0)), cumulative(120_000, Number.MAX_VALUE), ); - expect(samples.at(-1)?.targets[0]?.cpuRatio ?? null).toBeNull(); + expect(cpuTarget(samples.at(-1))?.cpuRatio ?? null).toBeNull(); }); it("leaves a gap while a Session's public usage is null and spreads the next report", () => { @@ -290,6 +353,7 @@ describe("Runtime live-window trends", () => { ...many.observations[0]!, id: session.id, session_id: session.id, + instance: { ...many.observations[0]!.instance, allocation_id: `allocation-${index}` }, cpu: { ...many.observations[0]!.cpu!, utilization_ratio: index / 10 }, started_at: 60 - index, } as RuntimeObservation)); diff --git a/apps/web/src/features/dashboard/runtime-trends.ts b/apps/web/src/features/dashboard/runtime-trends.ts index 433151439..342c90d10 100644 --- a/apps/web/src/features/dashboard/runtime-trends.ts +++ b/apps/web/src/features/dashboard/runtime-trends.ts @@ -17,7 +17,6 @@ export interface RuntimeTrendTarget { seriesId: string; label: string; cpuRatio: number | null; - uptimeSeconds: number | null; } export interface RuntimeTrendCPUCandidate extends RuntimeTrendTarget { @@ -31,6 +30,7 @@ export interface RuntimeTrendCPUCandidate extends RuntimeTrendTarget { export interface RuntimeTrendSample { sampledAt: number; + activeSandboxCount: number | null; targets: RuntimeTrendTarget[]; cpuCandidates: RuntimeTrendCPUCandidate[]; memoryUsageBytes: number | null; @@ -81,22 +81,13 @@ function reportedCpuRatio(observation: RuntimeObservation): number | null { } function allocationKey(observation: RuntimeObservation): string | null { - if (observation.status !== "observed") return null; + if (observation.mode !== "openai_hosted") return null; const allocationId = observation.instance.allocation_id; return typeof allocationId === "string" && allocationId.length > 0 ? `${observation.instance.kind}:${allocationId}` : null; } -function uptimeSeconds(observation: RuntimeObservation): number | null { - if (observation.status !== "observed") return null; - const startedAt = safeInteger(observation.started_at); - const observedAt = safeInteger(observation.observed_at); - return startedAt !== null && observedAt !== null && observedAt >= startedAt - ? observedAt - startedAt - : null; -} - function tokenTotals(sessions: readonly AgentSession[], sampledAt: number): RuntimeTrendTokenTotal[] { return sessions.flatMap((session): RuntimeTrendTokenTotal[] => { const inputTokens = safeInteger(session.usage?.input_tokens); @@ -120,15 +111,18 @@ function carryTokenTotals(previous: RuntimeTrendSample, next: RuntimeTrendSample export function runtimeTrendSample(snapshot: RuntimeDashboardSnapshot): RuntimeTrendSample { const sessions = new Map(snapshot.sessions.map((session) => [session.id, session])); - const observed = snapshot.observations.flatMap((observation) => { + const managed = snapshot.observations.flatMap((observation) => { const session = sessions.get(observation.session_id); - if (!session || observation.status !== "observed") return []; + if (!session || observation.mode !== "openai_hosted") return []; const key = allocationKey(observation); - if (key === null) return []; + const allocationId = observation.instance.allocation_id; + if (key === null || typeof allocationId !== "string" || allocationId.length === 0) return []; return [{ seriesId: `${observation.session_id}:${key}`, + allocationId, label: sessionTitle(session), cpuRatio: reportedCpuRatio(observation), + resolvedAt: safeInteger(observation.resolved_at), observedAt: safeInteger(observation.observed_at), startedAt: safeInteger(observation.started_at), allocationKey: allocationKey(observation), @@ -136,41 +130,51 @@ export function runtimeTrendSample(snapshot: RuntimeDashboardSnapshot): RuntimeT capacityCores: finiteNonNegative(observation.cpu?.capacity_cores), memoryUsageBytes: finiteNonNegative(observation.memory?.usage_bytes), memoryLimitBytes: finiteNonNegative(observation.memory?.limit_bytes), - uptimeSeconds: uptimeSeconds(observation), + lifecycleState: observation.lifecycle_state, + observed: observation.status === "observed", }]; }); - const targetIds = new Set([ - ...observed.filter((target) => target.cpuRatio !== null) - .sort((left, right) => (right.cpuRatio ?? 0) - (left.cpuRatio ?? 0)) - .slice(0, RUNTIME_TREND_SERIES_LIMIT) - .map((target) => target.seriesId), - ...observed.filter((target) => target.uptimeSeconds !== null) - .sort((left, right) => (right.uptimeSeconds ?? 0) - (left.uptimeSeconds ?? 0)) - .slice(0, RUNTIME_TREND_SERIES_LIMIT) - .map((target) => target.seriesId), - ]); - const targets = observed.filter((target) => targetIds.has(target.seriesId)).map((target) => ({ - seriesId: target.seriesId, - label: target.label, - cpuRatio: target.cpuRatio, - uptimeSeconds: target.uptimeSeconds, - })); + const latestByAllocation = new Map(); + for (const target of managed) { + const previous = latestByAllocation.get(target.allocationId); + if (!previous || (target.resolvedAt ?? -1) >= (previous.resolvedAt ?? -1)) { + latestByAllocation.set(target.allocationId, target); + } + } + const allocations = [...latestByAllocation.values()]; + const latestObservedByAllocation = new Map(); + for (const target of managed) { + if (!target.observed) continue; + const previous = latestObservedByAllocation.get(target.allocationId); + if (!previous || (target.observedAt ?? -1) >= (previous.observedAt ?? -1)) { + latestObservedByAllocation.set(target.allocationId, target); + } + } + const observed = [...latestObservedByAllocation.values()]; + const targets: RuntimeTrendTarget[] = observed.filter((target) => target.cpuRatio !== null) + .sort((left, right) => (right.cpuRatio ?? 0) - (left.cpuRatio ?? 0)) + .slice(0, RUNTIME_TREND_SERIES_LIMIT) + .map((target) => ({ + seriesId: target.seriesId, + label: target.label, + cpuRatio: target.cpuRatio, + })); const pairedMemory = observed.filter((target) => ( target.memoryUsageBytes !== null && target.memoryLimitBytes !== null )); return { sampledAt: snapshot.loadedAt, + activeSandboxCount: allocations.filter((target) => target.lifecycleState === "active").length, targets, cpuCandidates: observed.flatMap((target): RuntimeTrendCPUCandidate[] => ( - target.cpuRatio !== null || ( + target.seriesId !== null && (target.cpuRatio !== null || ( target.observedAt !== null && target.allocationKey !== null && target.usageSecondsTotal !== null && target.capacityCores !== null && target.capacityCores > 0 - ) + )) ? [{ - seriesId: target.seriesId, + seriesId: target.seriesId!, label: target.label, cpuRatio: target.cpuRatio, - uptimeSeconds: target.uptimeSeconds, observedAt: target.observedAt, startedAt: target.startedAt, allocationKey: target.allocationKey, @@ -224,25 +228,16 @@ function cpuRatios(previous: RuntimeTrendSample, next: RuntimeTrendSample): Map< } function applyCPURatios(sample: RuntimeTrendSample, ratios: ReadonlyMap): void { - const uptime = sample.targets.filter((target) => target.uptimeSeconds !== null) - .sort((left, right) => (right.uptimeSeconds ?? 0) - (left.uptimeSeconds ?? 0)) - .slice(0, RUNTIME_TREND_SERIES_LIMIT) - .map((target) => ({ ...target, cpuRatio: null })); const cpu = sample.cpuCandidates.flatMap((candidate): RuntimeTrendTarget[] => { const ratio = ratios.get(candidate.seriesId); return ratio === undefined ? [] : [{ seriesId: candidate.seriesId, label: candidate.label, cpuRatio: ratio, - uptimeSeconds: candidate.uptimeSeconds, }]; }).sort((left, right) => (right.cpuRatio ?? 0) - (left.cpuRatio ?? 0)) .slice(0, RUNTIME_TREND_SERIES_LIMIT); - const selected = new Map( - uptime.map((target) => [target.seriesId, target]), - ); - for (const target of cpu) selected.set(target.seriesId, target); - sample.targets = [...selected.values()]; + sample.targets = cpu; } function tokenRate( diff --git a/apps/web/src/features/sessions/SessionsView.tsx b/apps/web/src/features/sessions/SessionsView.tsx index 53b003169..fe13712e0 100644 --- a/apps/web/src/features/sessions/SessionsView.tsx +++ b/apps/web/src/features/sessions/SessionsView.tsx @@ -956,8 +956,8 @@ export function SessionsView({ loadRuntimeHistory={loadRuntimeHistory} headingId={`session-runtime-trends-heading-${sessionId}`} title={t("metrics.resourceTrends")} - showDurableUptimePlaceholder allowSourceSelection + activeDisplay="binary" />
) : null; diff --git a/apps/web/src/i18n/locales/en/dashboard.ts b/apps/web/src/i18n/locales/en/dashboard.ts index 01ea7643b..449e5a735 100644 --- a/apps/web/src/i18n/locales/en/dashboard.ts +++ b/apps/web/src/i18n/locales/en/dashboard.ts @@ -10,7 +10,7 @@ export const dashboard = { status: { observed: "Observed", unsupported: "Unsupported", allocation_pending: "Allocation pending", runtime_not_running: "Not running", source_not_configured: "Source unavailable", sample_timeout: "Sample timeout", sample_unavailable: "Sample unavailable", idle: "Idle", in_progress: "In progress", requires_action: "Requires action", failed: "Failed", unknown: "Unavailable" }, managedProvider: "Managed {{provider}}", managed: "Managed", cores: "{{value}} cores", capacityUnknown: "Capacity unknown", limitUnknown: "Limit unknown", ofLimit: "of {{limit}}", memoryUsed: "{{percent}}% memory used", allocationUnknown: "Allocation age unknown", allocated: "{{duration}} allocated", notReported: "Not reported", sessionReported: "Session reported", - metrics: { active: "Active Runtimes", activeDetail: "{{managed}} managed · {{unavailable}} unavailable", cpu: "Cumulative CPU / capacity", cpuDetail: "{{covered}}/{{total}} observed Runtimes report CPU time", memory: "Memory now", memoryDetail: "{{covered}}/{{total}} observed Runtimes report usage", tokens: "Reported tokens", tokenDetail: "{{covered}}/{{total}} Sessions report usage", noSample: "No current sample" }, + metrics: { sandboxState: "Sandbox state", sandboxStateValue: "{{active}} active · {{sleeping}} sleeping", sandboxStateDetail: "{{total}} total · {{transitioning}} transitioning or pending", cpu: "Cumulative CPU / capacity", cpuDetail: "{{covered}}/{{total}} observed Runtimes report CPU time", memory: "Memory now", memoryDetail: "{{covered}}/{{total}} observed Runtimes report usage", tokens: "Reported tokens", tokenDetail: "{{covered}}/{{total}} Sessions report usage", noSample: "No current sample" }, explorerHint: "Search and inspect exact observations · unknown remains unknown, never zero", targetCount: "{{value}} targets · {{snapshot}}", retainedSnapshot: "retained snapshot", currentSnapshot: "current snapshot", }, trends: { @@ -27,8 +27,8 @@ export const dashboard = { pinned: "Pinned", hover: "Hover", unavailable: "Unavailable", allHidden: "All series hidden", sparse: "Sparse samples", showLegend: "Use the legend to show a series", sparseDetail: "{{count}} valid points · a line requires consecutive buckets", emptyDetail: "{{valid}}/2 valid points · {{count}} snapshots · no history is synthesized", table: { series: "Series", latest: "Latest value", missing: "Missing samples" }, runtime: "Runtime", usage: "usage", used: "used", configuredLimit: "configured limit", input: "input", output: "output", gridDurable: "Runtime durable-history charts", gridLive: "Runtime live-window charts", cpu: { title: "CPU usage", durable: "bucketed cumulative-delta utilization · durable history", live: "reported or cumulative-delta utilization · live window", empty: "No retained CPU samples" }, - memory: { title: "Memory usage", durable: "complete target aggregate / configured limit · durable history", live: "working set / configured limit · live window", empty: "No complete retained memory samples" }, - uptime: { title: "Compute uptime", durable: "current Runtime measurement · not retained in durable history", live: "provider started_at → observed_at · allocation series", empty: "Live-only metric", detail: "Select Live to inspect current Runtime uptime" }, + memory: { title: "Memory usage", durable: "observed Sandbox aggregate / configured limit · durable history", live: "observed Sandbox working set / configured limit · live window", empty: "No retained observed memory samples" }, + active: { series: "active", sandboxTitle: "Active sandboxes", runtimeTitle: "Runtime active", sumDurable: "observed allocations per retained bucket · durable history", sumLive: "lifecycle state active allocations per snapshot · live window", binaryDurable: "observed allocation in retained bucket · 1 active / 0 inactive", binaryLive: "lifecycle state active · 1 active / 0 inactive", active: "Active", inactive: "Inactive", empty: "No retained active Sandbox samples" }, tokens: { title: "Token throughput", durable: "canonical Session Usage deltas · durable history", live: "Session Usage deltas · missing usage excluded", empty: "No retained token samples", perMinute: "{{value}}/min" }, }, } as const; diff --git a/apps/web/src/i18n/locales/zh-CN/dashboard.ts b/apps/web/src/i18n/locales/zh-CN/dashboard.ts index 15cccb818..995f1720b 100644 --- a/apps/web/src/i18n/locales/zh-CN/dashboard.ts +++ b/apps/web/src/i18n/locales/zh-CN/dashboard.ts @@ -10,7 +10,7 @@ export const dashboard = { status: { observed: "已观测", unsupported: "不支持", allocation_pending: "等待分配", runtime_not_running: "未运行", source_not_configured: "数据源不可用", sample_timeout: "采样超时", sample_unavailable: "采样不可用", idle: "空闲", in_progress: "进行中", requires_action: "需要操作", failed: "失败", unknown: "不可用" }, managedProvider: "托管 {{provider}}", managed: "托管", cores: "{{value}} 核", capacityUnknown: "容量未知", limitUnknown: "上限未知", ofLimit: "上限 {{limit}}", memoryUsed: "已使用 {{percent}}% 内存", allocationUnknown: "分配时间未知", allocated: "已分配 {{duration}}", notReported: "未报告", sessionReported: "会话已报告", - metrics: { active: "活动 Runtime", activeDetail: "{{managed}} 个托管 · {{unavailable}} 个不可用", cpu: "累计 CPU / 容量", cpuDetail: "{{covered}}/{{total}} 个已观测 Runtime 报告了 CPU 时间", memory: "当前内存", memoryDetail: "{{covered}}/{{total}} 个已观测 Runtime 报告了用量", tokens: "已报告 Token", tokenDetail: "{{covered}}/{{total}} 个会话报告了用量", noSample: "无当前采样" }, + metrics: { sandboxState: "Sandbox 状态", sandboxStateValue: "{{active}} 个活动 · {{sleeping}} 个休眠", sandboxStateDetail: "共 {{total}} 个 · {{transitioning}} 个正在转换或等待", cpu: "累计 CPU / 容量", cpuDetail: "{{covered}}/{{total}} 个已观测 Runtime 报告了 CPU 时间", memory: "当前内存", memoryDetail: "{{covered}}/{{total}} 个已观测 Runtime 报告了用量", tokens: "已报告 Token", tokenDetail: "{{covered}}/{{total}} 个会话报告了用量", noSample: "无当前采样" }, explorerHint: "搜索并检查准确观测值 · 未知始终保持未知,不会视为零", targetCount: "{{value}} 个目标 · {{snapshot}}", retainedSnapshot: "保留快照", currentSnapshot: "当前快照", }, trends: { @@ -27,8 +27,8 @@ export const dashboard = { pinned: "已固定", hover: "悬停", unavailable: "不可用", allHidden: "已隐藏所有序列", sparse: "采样稀疏", showLegend: "使用图例显示序列", sparseDetail: "{{count}} 个有效点 · 绘制连线需要连续时间桶", emptyDetail: "{{valid}}/2 个有效点 · {{count}} 个快照 · 不会合成历史数据", table: { series: "序列", latest: "最新值", missing: "缺失采样" }, runtime: "Runtime", usage: "使用率", used: "已使用", configuredLimit: "配置上限", input: "输入", output: "输出", gridDurable: "Runtime 持久历史图表", gridLive: "Runtime 实时窗口图表", cpu: { title: "CPU 使用率", durable: "按时间桶聚合的累计差值使用率 · 持久历史", live: "已报告或累计差值使用率 · 实时窗口", empty: "无保留的 CPU 采样" }, - memory: { title: "内存使用", durable: "完整目标聚合 / 配置上限 · 持久历史", live: "工作集 / 配置上限 · 实时窗口", empty: "无完整的保留内存采样" }, - uptime: { title: "计算运行时长", durable: "当前 Runtime 测量 · 持久历史不保留", live: "Provider started_at → observed_at · 分配序列", empty: "仅实时指标", detail: "选择“实时”以检查当前 Runtime 运行时长" }, + memory: { title: "内存使用", durable: "已观测 Sandbox 聚合 / 配置上限 · 持久历史", live: "已观测 Sandbox 工作集 / 配置上限 · 实时窗口", empty: "无保留的已观测内存采样" }, + active: { series: "活动", sandboxTitle: "活动 Sandbox", runtimeTitle: "Runtime 活动状态", sumDurable: "每个保留时间桶中的已观测分配数 · 持久历史", sumLive: "每个快照中生命周期状态为活动的分配数 · 实时窗口", binaryDurable: "保留时间桶中存在已观测分配 · 1 活动 / 0 非活动", binaryLive: "生命周期状态为活动 · 1 活动 / 0 非活动", active: "活动", inactive: "非活动", empty: "无保留的活动 Sandbox 采样" }, tokens: { title: "Token 吞吐量", durable: "规范会话用量差值 · 持久历史", live: "会话用量差值 · 排除缺失用量", empty: "无保留的 Token 采样", perMinute: "{{value}}/分钟" }, }, } as const; diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index b0276a5a6..b7b463bbd 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -1698,6 +1698,15 @@ definitions: type: string instance: $ref: '#/definitions/v1.RuntimeInstance' + lifecycle_state: + enum: + - active + - sleeping + - transitioning + - pending + - stopped + type: string + x-nullable: true memory: allOf: - $ref: '#/definitions/v1.RuntimeMemoryObservation' @@ -1751,6 +1760,7 @@ definitions: - environment_id - id - instance + - lifecycle_state - memory - mode - object diff --git a/contracts/agents-api/runtime-history-api.md b/contracts/agents-api/runtime-history-api.md index efe4d278f..68c4d8953 100644 --- a/contracts/agents-api/runtime-history-api.md +++ b/contracts/agents-api/runtime-history-api.md @@ -176,3 +176,9 @@ or malformed data reject the entire response with a 502 client projection error. Compute uptime is available from current observations only. Retained allocation series can span compute restarts and unavailable intervals; their earliest start is not a per-bucket compute start and must not be used to draw an uptime history. +Clients may project a Dashboard active-Sandbox count by counting distinct +allocation identities with `observed_count > 0` in each bucket and deduplicating +the same allocation across Session histories. The single-Session presentation +collapses any positive count to `1`; a missing or unavailable bucket is currently +rendered as `0`. This temporary zero-fill policy does not distinguish a sleeping +Runtime from missing collection coverage. diff --git a/contracts/agents-api/runtime-observability-design.md b/contracts/agents-api/runtime-observability-design.md index 228162154..a3fd376c9 100644 --- a/contracts/agents-api/runtime-observability-design.md +++ b/contracts/agents-api/runtime-observability-design.md @@ -183,14 +183,20 @@ measurement or lifecycle state. | --- | --- | --- | | Allocation age | allocation `created_at` to `released_at` or now | Age of Core's allocation record. | | Compute uptime | provider `started_at` to sample `observed_at` | Age of the current compute incarnation. | +| Active sandboxes | distinct active allocations in the selected snapshot or bucket | Count across Sessions on the Dashboard; naturally 0/1 in a single-Session view. | | Busy duration | Turn `started_at` to `completed_at` or now | Time model work has been active. | | Idle duration | future durable `idle_since` | Not available in the current design. | Container restart resets compute uptime but not allocation age. Live CPU deltas -require the same known compute start as well as the same allocation. Retained -charts show CPU, memory and tokens; uptime stays in the current/Live view because -the history contract does not supply each bucket's compute start. Dashboard labels -must not collapse these values into one generic Runtime duration. +require the same known compute start as well as the same allocation. Trend charts +show CPU, memory, active Sandbox count, and tokens. Live active count uses the +provider-neutral lifecycle state and deduplicates allocation identities; retained +history counts observed allocation identities in each bucket because lifecycle +state is not retained yet. A single-Session view therefore remains binary while +the Dashboard shows the sum across Sessions. Compute uptime stays in current +target details because the history contract does not supply each bucket's compute +start. Dashboard labels must not collapse these values into one generic Runtime +duration. ## 9. Collection behavior @@ -351,7 +357,9 @@ Every bucket reports explicit observation coverage and nullable CPU/memory values. CPU utilization may be derived only from ordered cumulative counters inside one fence; successive intervals are assigned to the bucket containing their right endpoint and combined by CPU-capacity time. Memory uses the final -observed value in the bucket. Empty +observed value in the bucket. Dashboard memory totals aggregate only allocations with +a complete observed usage/limit pair in that bucket; an unavailable or released +target does not erase measurements from active targets. Empty buckets remain gaps. The service rejects cross-scope rows, duplicate series, overlapping or out-of-range buckets, unsafe provider labels, invalid numeric values, and results exceeding the total point budget. @@ -378,6 +386,12 @@ acceptance. - Observed CPU usage and known configured capacity. - Observed memory usage and known limits. - Reported Session tokens, together with the reporting Session count. +- Confirmed active Sandbox count over time. Each bucket counts managed allocations + with an observed provider sample; unavailable or timed-out samples are not + presented as confirmed active. A bucket with collection coverage but no observed + allocation is zero. The history model retains missing coverage as null; the + current Dashboard presentation renders that null as zero until sleeping and + collection-failure history are represented separately. - Data freshness and source coverage. Aggregates include only present measurements. Each total states its denominator, diff --git a/contracts/agents-api/runtime-observability.md b/contracts/agents-api/runtime-observability.md index 5a1f07ad6..143c45b3b 100644 --- a/contracts/agents-api/runtime-observability.md +++ b/contracts/agents-api/runtime-observability.md @@ -41,6 +41,12 @@ rendered or aggregated as zero. A whole observation has one of three states: `observed`, `unsupported`, or `unavailable`. Provider and permission failures are errors, not ordinary unavailability. +Managed observations also expose a provider-neutral `lifecycle_state` derived +from Core's allocation and compute lifecycle: `active`, `sleeping`, +`transitioning`, `pending`, or `stopped`. Non-managed modes return `null`. +This field is current control-plane state; it is not inferred from a failed +provider sample. + Docker reports cumulative cgroup CPU time and current cgroup memory usage. CPU and memory capacity come from the inspected container configuration. Inspect and Stats are read-only; observation must not renew, restart, create, or stop the container. @@ -73,6 +79,14 @@ This phase supplies compute uptime evidence and retains the existing durable allocation and Turn timestamps. It does not infer idle time. CPU quietness, heartbeat age, connection status, and `kept_at` are not authoritative idle state. +Web projects active Runtime state differently by scope. The Dashboard shows one +summed series of distinct allocation identities: live snapshots count +`lifecycle_state: active`, while retained buckets count successfully observed +allocations because lifecycle state is not retained yet. The single-Session view +collapses the same value to `1` or `0`. Missing or unavailable retained values are +currently rendered as zero, so this presentation intentionally does not yet +distinguish sleeping from collection failure. + Future automatic suspension requires a separate durable control model, including an activity revision and timestamps such as `idle_since` and `shutdown_requested_at`. Metrics, an in-memory cache, or a monitoring backend must diff --git a/contracts/agents-api/v1/runtime_observations.go b/contracts/agents-api/v1/runtime_observations.go index d82d8d514..56d42834f 100644 --- a/contracts/agents-api/v1/runtime_observations.go +++ b/contracts/agents-api/v1/runtime_observations.go @@ -8,6 +8,7 @@ type RuntimeObservation struct { Mode string `json:"mode" enums:"none,self_hosted,openai_hosted" binding:"required"` ProviderType *string `json:"provider_type" extensions:"x-nullable" binding:"required" pattern:"^[a-z][a-z0-9_]{0,31}$"` Instance RuntimeInstance `json:"instance" binding:"required"` + LifecycleState *string `json:"lifecycle_state" extensions:"x-nullable" binding:"required" enums:"active,sleeping,transitioning,pending,stopped"` Status string `json:"status" enums:"observed,unsupported,unavailable" binding:"required"` Reason *string `json:"reason" extensions:"x-nullable" binding:"required" enums:"runtime_mode_not_observable,allocation_pending,runtime_not_running,source_not_configured,sample_timeout,sample_unavailable"` AllocationCreatedAt *int64 `json:"allocation_created_at" extensions:"x-nullable" binding:"required" minimum:"0"` diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts index d879ecce6..016c7dd44 100644 --- a/packages/agents-client/src/client.test.ts +++ b/packages/agents-client/src/client.test.ts @@ -122,6 +122,7 @@ function runtimeObservation(overrides: Record = {}): Record { mode: "none", provider_type: null, instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null }, + lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", allocation_created_at: null, @@ -2857,6 +2859,7 @@ describe("OpenAIAgentsClient", () => { ["unknown field", () => ({ ...runtimeObservation(), provider_native_id: "hidden" })], ["foreign Session", () => ({ ...runtimeObservation(), session_id: "55555555-5555-4555-8555-555555555555" })], ["invalid status/reason", () => ({ ...runtimeObservation(), status: "observed", reason: "sample_timeout" })], + ["invalid lifecycle state", () => ({ ...runtimeObservation(), lifecycle_state: "paused" })], ["invalid mode/instance", () => ({ ...runtimeObservation(), mode: "none" })], ["negative CPU", () => ({ ...runtimeObservation(), cpu: { usage_seconds_total: -1, capacity_cores: 2, usage_cores: null, utilization_ratio: null, diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts index 6b4f2d47a..c42228f7a 100644 --- a/packages/agents-client/src/client.ts +++ b/packages/agents-client/src/client.ts @@ -343,7 +343,7 @@ const unsafeUnknownEventFields = new Set([ ]); const runtimeObservationFields = new Set([ "id", "object", "session_id", "environment_id", "mode", "provider_type", "instance", "status", "reason", - "allocation_created_at", "resolved_at", "observed_at", "started_at", "cpu", "memory", + "lifecycle_state", "allocation_created_at", "resolved_at", "observed_at", "started_at", "cpu", "memory", ]); const runtimeInstanceFields = new Set(["kind", "allocation_id", "device_id", "connection_generation"]); const runtimeCPUFields = new Set(["usage_seconds_total", "capacity_cores", "usage_cores", "utilization_ratio"]); @@ -353,6 +353,7 @@ const runtimeObservationReasons = new Set([ "source_not_configured", "sample_timeout", "sample_unavailable", ]); const runtimeProviderTypePattern = /^[a-z][a-z0-9_]{0,31}$/; +const runtimeLifecycleStates = new Set(["active", "sleeping", "transitioning", "pending", "stopped"]); function utf8Length(value: string): number { return new TextEncoder().encode(value).length; } @@ -1124,14 +1125,16 @@ function projectRuntimeObservation(value: unknown, expectedSessionId?: string): if ( (isNone && ( value.instance.kind !== "none" || environmentId !== null || value.provider_type !== null || - allocationId !== null || deviceId !== null || connectionGeneration !== null || allocationCreatedAt !== null + allocationId !== null || deviceId !== null || connectionGeneration !== null || allocationCreatedAt !== null || + value.lifecycle_state !== null )) || (isSelfHosted && ( value.instance.kind !== "self_hosted_connection" || environmentId === null || - allocationId !== null || allocationCreatedAt !== null + allocationId !== null || allocationCreatedAt !== null || value.lifecycle_state !== null )) || (isManaged && ( value.instance.kind !== "managed_allocation" || environmentId === null || connectionGeneration !== null || + !runtimeLifecycleStates.has(String(value.lifecycle_state)) || (allocationId === null && (deviceId !== null || allocationCreatedAt !== null)) )) ) return invalidRuntimeObservation(); @@ -1194,6 +1197,7 @@ function projectRuntimeObservation(value: unknown, expectedSessionId?: string): allocation_id: allocationId, device_id: deviceId, connection_generation: connectionGeneration, }, status: value.status, reason: value.reason as RuntimeObservation["reason"], + lifecycle_state: value.lifecycle_state as RuntimeObservation["lifecycle_state"], allocation_created_at: allocationCreatedAt, resolved_at: value.resolved_at, observed_at: observedAt, started_at: startedAt, cpu, memory, } as RuntimeObservation; diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index f09f65ee2..098108e28 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -780,6 +780,7 @@ export interface CreateSessionStreamOptions extends StreamOptions { } export type RuntimeObservationStatus = "observed" | "unsupported" | "unavailable"; +export type RuntimeLifecycleState = "active" | "sleeping" | "transitioning" | "pending" | "stopped"; export type RuntimeObservationReason = | "runtime_mode_not_observable" | "allocation_pending" @@ -819,6 +820,7 @@ export interface RuntimeObservedObservation extends RuntimeObservationBase { device_id: string | null; connection_generation: null; }; + lifecycle_state: RuntimeLifecycleState; status: "observed"; reason: null; allocation_created_at: number | null; @@ -838,6 +840,7 @@ export interface RuntimeUnavailableObservation extends RuntimeObservationBase { device_id: string | null; connection_generation: null; }; + lifecycle_state: RuntimeLifecycleState; status: "unavailable"; reason: RuntimeUnavailableReason; allocation_created_at: number | null; @@ -852,6 +855,7 @@ export interface RuntimeNoneObservation extends RuntimeObservationBase { mode: "none"; provider_type: null; instance: { kind: "none"; allocation_id: null; device_id: null; connection_generation: null }; + lifecycle_state: null; status: "unsupported"; reason: "runtime_mode_not_observable"; allocation_created_at: null; @@ -871,6 +875,7 @@ export interface RuntimeSelfHostedObservation extends RuntimeObservationBase { device_id: string | null; connection_generation: string | null; }; + lifecycle_state: null; status: "unsupported"; reason: "runtime_mode_not_observable"; allocation_created_at: null; diff --git a/services/agents-api/internal/api/runtime_observations.go b/services/agents-api/internal/api/runtime_observations.go index 54d061a2d..c79694c5a 100644 --- a/services/agents-api/internal/api/runtime_observations.go +++ b/services/agents-api/internal/api/runtime_observations.go @@ -179,6 +179,11 @@ func runtimeObservationResponse(observation runtimeobs.Observation) (v1.RuntimeO switch observation.Target.Mode { case runtimeobs.ModeManaged: result.Instance.Kind = "managed_allocation" + lifecycleState, err := runtimeLifecycleState(observation.Target.Instance) + if err != nil { + return v1.RuntimeObservation{}, err + } + result.LifecycleState = &lifecycleState if observation.Target.Instance.AllocationID != "" { result.Instance.AllocationID = &observation.Target.Instance.AllocationID } @@ -219,3 +224,30 @@ func runtimeObservationResponse(observation runtimeobs.Observation) (v1.RuntimeO } return result, nil } + +func runtimeLifecycleState(instance runtimeobs.Instance) (string, error) { + switch instance.AllocationState { + case "": + if instance.AllocationID == "" { + return "pending", nil + } + return "", errors.New("invalid Runtime allocation state") + case "creating": + return "pending", nil + case "cleanup_pending", "released": + return "stopped", nil + case "running": + switch instance.ComputePhase { + case "suspended": + return "sleeping", nil + case "quiescing", "suspending", "restoring", "waking": + return "transitioning", nil + case "disabled", "running": + return "active", nil + default: + return "", errors.New("invalid Runtime compute phase") + } + default: + return "", errors.New("invalid Runtime allocation state") + } +} diff --git a/services/agents-api/internal/api/runtime_observations_test.go b/services/agents-api/internal/api/runtime_observations_test.go index 90dd4ab7f..cbbc952d3 100644 --- a/services/agents-api/internal/api/runtime_observations_test.go +++ b/services/agents-api/internal/api/runtime_observations_test.go @@ -94,15 +94,38 @@ func TestRuntimeObservationResponsePreservesObservedZero(t *testing.T) { now := time.Date(2026, 9, 22, 8, 0, 0, 0, time.UTC) sessionID, environmentID := uuid.NewString(), uuid.NewString() value, err := runtimeObservationResponse(runtimeobs.Observation{ - Target: runtimeobs.Target{SessionID: sessionID, EnvironmentID: environmentID, Mode: runtimeobs.ModeManaged, Instance: runtimeobs.Instance{AllocationID: uuid.NewString(), DeviceID: uuid.NewString(), AllocationCreatedAt: now.Add(-time.Hour)}}, + Target: runtimeobs.Target{SessionID: sessionID, EnvironmentID: environmentID, Mode: runtimeobs.ModeManaged, Instance: runtimeobs.Instance{AllocationID: uuid.NewString(), DeviceID: uuid.NewString(), AllocationState: "running", ComputePhase: "running", AllocationCreatedAt: now.Add(-time.Hour)}}, Status: runtimeobs.StatusObserved, ProviderType: "docker", ResolvedAt: now, Sample: &runtimeobs.Sample{ObservedAt: now, CPUUsageSecondsTotal: &zeroCPU, MemoryUsageBytes: &zeroMemory}, }) - if err != nil || value.CPU == nil || value.CPU.UsageSecondsTotal == nil || *value.CPU.UsageSecondsTotal != 0 || value.Memory == nil || value.Memory.UsageBytes == nil || *value.Memory.UsageBytes != 0 { + if err != nil || value.LifecycleState == nil || *value.LifecycleState != "active" || value.CPU == nil || value.CPU.UsageSecondsTotal == nil || *value.CPU.UsageSecondsTotal != 0 || value.Memory == nil || value.Memory.UsageBytes == nil || *value.Memory.UsageBytes != 0 { t.Fatalf("observed zero was lost: %+v %v", value, err) } } +func TestRuntimeLifecycleStateProjectsProviderNeutralPhases(t *testing.T) { + for _, item := range []struct { + state, phase, want string + }{ + {state: "", phase: "", want: "pending"}, + {state: "creating", phase: "disabled", want: "pending"}, + {state: "running", phase: "disabled", want: "active"}, + {state: "running", phase: "running", want: "active"}, + {state: "running", phase: "quiescing", want: "transitioning"}, + {state: "running", phase: "suspending", want: "transitioning"}, + {state: "running", phase: "suspended", want: "sleeping"}, + {state: "running", phase: "restoring", want: "transitioning"}, + {state: "running", phase: "waking", want: "transitioning"}, + {state: "cleanup_pending", phase: "disabled", want: "stopped"}, + {state: "released", phase: "disabled", want: "stopped"}, + } { + got, err := runtimeLifecycleState(runtimeobs.Instance{AllocationState: item.state, ComputePhase: item.phase}) + if err != nil || got != item.want { + t.Fatalf("state=%s phase=%s got=%s want=%s err=%v", item.state, item.phase, got, item.want, err) + } + } +} + func TestRuntimeObservationResponseRejectsTimesOutsidePublicContract(t *testing.T) { now := time.Date(2026, 9, 22, 8, 0, 0, 0, time.UTC) preEpoch := time.Unix(-1, 0).UTC()