diff --git a/apps/web/e2e/unified-settings.spec.ts b/apps/web/e2e/unified-settings.spec.ts index 6f608f234..8b2a860e3 100644 --- a/apps/web/e2e/unified-settings.spec.ts +++ b/apps/web/e2e/unified-settings.spec.ts @@ -105,7 +105,7 @@ test("settings content width does not constrain reset and node dialog backdrops" await page.getByRole("button", { name: action, exact: true }).first().click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); - const backdrop = page.locator(".modal-backdrop"); + const backdrop = page.locator(".modal-backdrop:not(.closing)"); const bounds = await backdrop.boundingBox(); expect(bounds?.x).toBe(0); expect(bounds?.y).toBe(0); diff --git a/apps/web/src/features/dashboard/runtime-history.test.ts b/apps/web/src/features/dashboard/runtime-history.test.ts index a07c4397c..df9454bd4 100644 --- a/apps/web/src/features/dashboard/runtime-history.test.ts +++ b/apps/web/src/features/dashboard/runtime-history.test.ts @@ -277,20 +277,30 @@ describe("Runtime Durable Dashboard history", () => { expect(samples[1]).toMatchObject({ inputTokensPerMinute: null, outputTokensPerMinute: 20 }); }); - it("loads each hosted Session's history through its project with a bounded common range", async () => { - const calls: Array<{ sessionID: string; start: number; end: number; maxPoints?: number }> = []; + it("keeps only the buckets every Session's history covers when Core's clock ended them a bucket apart", () => { + const otherID = "44444444-4444-4444-8444-444444444444"; + const times = new Set(["start", "end", "first_observed_at", "last_observed_at", "sampled_at"]); + const later: RuntimeHistory = JSON.parse(JSON.stringify(history()), (key, value) => (times.has(key) && typeof value === "number" ? value + 30 : value)); + later.session_id = otherID; + later.series[0]!.allocation_id = "55555555-5555-4555-8555-555555555555"; + const samples = runtimeDurableTrendSamples([session, { ...session, id: otherID } as AgentSession], [history(), later]); + expect(samples.map((sample) => [sample.sampledAt, sample.activeSandboxCount])).toEqual([[160_000, 2]]); + }); + + it("loads each hosted Session's history through its project for the named range", async () => { + const calls: Array<{ sessionID: string; range: string }> = []; const reader: RuntimeHistoryReader = { - retrieveRuntimeHistory: async (id, query) => { - calls.push({ sessionID: id, start: query.start, end: query.end, maxPoints: query.maxPoints }); - return history({ requested_range: { start: query.start, end: query.end } }); + retrieveRuntimeHistory: async (id, range) => { + calls.push({ sessionID: id, range }); + return history(); }, }; const readers: string[] = []; const snapshot: RuntimeDashboardSnapshot = { sessions: [session], observations: [observation], owners: new Map([[sessionID, "proj_a"]]), loadedAt: 1 }; - const result = await loadRuntimeDurableSnapshot((id) => { readers.push(snapshot.owners?.get(id) ?? ""); return reader; }, snapshot, 60 * 60_000, undefined, () => 7_200_000); + const result = await loadRuntimeDurableSnapshot((id) => { readers.push(snapshot.owners?.get(id) ?? ""); return reader; }, snapshot, "1h"); expect(readers).toEqual(["proj_a"]); - expect(calls).toEqual([{ sessionID, start: 3_600, end: 7_200, maxPoints: 120 }]); - expect(result).toMatchObject({ rangeStart: 3_600_000, rangeEnd: 7_200_000, targetCount: 1, sampleCount: 2 }); + expect(calls).toEqual([{ sessionID, range: "1h" }]); + expect(result).toMatchObject({ targetCount: 1, sampleCount: 2 }); }); it("queries each managed Session only once when observations contain duplicates", async () => { @@ -306,7 +316,7 @@ describe("Runtime Durable Dashboard history", () => { observations: [observation, { ...observation }], loadedAt: 1, }; - await loadRuntimeDurableSnapshot(() => reader, snapshot, 60 * 60_000); + await loadRuntimeDurableSnapshot(() => reader, snapshot, "1h"); expect(queried).toEqual([sessionID]); }); @@ -315,7 +325,7 @@ describe("Runtime Durable Dashboard history", () => { retrieveRuntimeHistory: async () => { throw new AgentCoreError("No history.", 404); }, }; const snapshot: RuntimeDashboardSnapshot = { sessions: [session], observations: [observation], loadedAt: 1 }; - await expect(loadRuntimeDurableSnapshot(() => reader, snapshot, 60 * 60_000)).resolves.toBeNull(); + await expect(loadRuntimeDurableSnapshot(() => reader, snapshot, "1h")).resolves.toBeNull(); }); it("leaves out a Session without history and keeps the others", async () => { @@ -332,7 +342,7 @@ describe("Runtime Durable Dashboard history", () => { observations: [observation, { ...observation, id: otherID, session_id: otherID }], loadedAt: 1, }; - await expect(loadRuntimeDurableSnapshot(() => reader, snapshot, 60 * 60_000)).resolves.toMatchObject({ targetCount: 1 }); + await expect(loadRuntimeDurableSnapshot(() => reader, snapshot, "1h")).resolves.toMatchObject({ targetCount: 1 }); }); it("propagates other history failures", async () => { @@ -340,13 +350,13 @@ describe("Runtime Durable Dashboard history", () => { retrieveRuntimeHistory: async () => { throw new AgentCoreError("Unavailable.", 503); }, }; const snapshot: RuntimeDashboardSnapshot = { sessions: [session], observations: [observation], loadedAt: 1 }; - await expect(loadRuntimeDurableSnapshot(() => reader, snapshot, 60 * 60_000)).rejects.toMatchObject({ status: 503 }); + await expect(loadRuntimeDurableSnapshot(() => reader, snapshot, "1h")).rejects.toMatchObject({ status: 503 }); }); it("fails closed instead of publishing a partial oversized Dashboard", async () => { const reader: RuntimeHistoryReader = { retrieveRuntimeHistory: async () => history() }; const snapshot: RuntimeDashboardSnapshot = { sessions: [session], observations: [observation], loadedAt: 1 }; - await expect(loadRuntimeDurableSnapshot(() => reader, snapshot, 60 * 60_000, undefined, Date.now, 0)) + await expect(loadRuntimeDurableSnapshot(() => reader, snapshot, "1h", undefined, 0)) .rejects.toBeInstanceOf(RuntimeDurableHistoryIncompleteError); }); }); diff --git a/apps/web/src/features/dashboard/runtime-history.ts b/apps/web/src/features/dashboard/runtime-history.ts index 4a19e37ed..7d11b08c8 100644 --- a/apps/web/src/features/dashboard/runtime-history.ts +++ b/apps/web/src/features/dashboard/runtime-history.ts @@ -1,33 +1,27 @@ -import { AgentCoreError, type AgentSession, type RuntimeHistory } from "@oac/agents-client"; +import { AgentCoreError, type AgentSession, type RuntimeHistory, type RuntimeHistoryRange } from "@oac/agents-client"; import type { ProjectClient } from "../../lib/projects"; import type { RuntimeDashboardSnapshot } from "./runtime-snapshot"; import { deriveTokenThroughput, type RuntimeTrendSample, type RuntimeTrendTarget } from "./runtime-trends"; export const RUNTIME_DURABLE_TARGET_LIMIT = 24; -export const RUNTIME_DURABLE_MAX_POINTS = 120; -export const RUNTIME_DURABLE_RANGES = [ - { label: "1h", milliseconds: 60 * 60 * 1_000 }, - { label: "6h", milliseconds: 6 * 60 * 60 * 1_000 }, - { label: "24h", milliseconds: 24 * 60 * 60 * 1_000 }, -] as const; +/** Ranges Core ends on its own clock, so the browser's clock never enters a read. */ +export const RUNTIME_DURABLE_RANGES = ["1h", "6h", "24h"] as const satisfies readonly RuntimeHistoryRange[]; -export type RuntimeDurableRange = typeof RUNTIME_DURABLE_RANGES[number]["milliseconds"]; +export type RuntimeDurableRange = typeof RUNTIME_DURABLE_RANGES[number]; export interface RuntimeDurableSnapshot { samples: RuntimeTrendSample[]; - rangeStart: number; - rangeEnd: number; resolutionSeconds: number; targetCount: number; sampleCount: number; expectedSampleCount: number; - loadedAt: number; } +/** Web's own read bound: more Sessions than `limit`, or, when it is null, histories of different bucket widths. */ export class RuntimeDurableHistoryIncompleteError extends Error { - constructor(message: string) { - super(message); + constructor(readonly limit: number | null) { + super(limit === null ? "Durable Runtime histories returned inconsistent resolutions." : `Durable Runtime history exceeds the ${limit} target Web query budget.`); this.name = "RuntimeDurableHistoryIncompleteError"; } } @@ -138,7 +132,11 @@ export function runtimeDurableTrendSamples( } } - const samples = [...buckets.values()].sort((left, right) => left.sampledAt - right.sampledAt).map((value) => { + // Core ends each range at its last complete bucket, so reads on either side of + // a bucket boundary differ by one bucket: keep the buckets every history covers. + const from = Math.max(...histories.map((history) => history.requested_range.start)) * 1_000; + const to = Math.min(...histories.map((history) => history.requested_range.end)) * 1_000; + const samples = [...buckets.values()].filter((value) => value.sampledAt > from && value.sampledAt <= to).sort((left, right) => left.sampledAt - right.sampledAt).map((value) => { const observedMemory = [...value.memory.values()]; return { sampledAt: value.sampledAt, @@ -178,27 +176,17 @@ export async function loadRuntimeDurableSnapshot( snapshot: RuntimeDashboardSnapshot, range: RuntimeDurableRange, signal?: AbortSignal, - now: () => number = Date.now, targetLimit = RUNTIME_DURABLE_TARGET_LIMIT, ): Promise { const targets = durableTargets(snapshot); if (targets.length > targetLimit) { - throw new RuntimeDurableHistoryIncompleteError( - `Durable Runtime history exceeds the ${targetLimit} target Web query budget.`, - ); + throw new RuntimeDurableHistoryIncompleteError(targetLimit); } - const rangeEndSeconds = Math.floor(now() / 1_000); - const rangeStartSeconds = rangeEndSeconds - range / 1_000; const results = await mapBounded(targets, 4, async (session): Promise => { const reader = readerFor(session.id); if (!reader) return null; try { - return await reader.retrieveRuntimeHistory(session.id, { - start: rangeStartSeconds, - end: rangeEndSeconds, - maxPoints: RUNTIME_DURABLE_MAX_POINTS, - signal, - }); + return await reader.retrieveRuntimeHistory(session.id, range, { signal }); } catch (error) { if (isNotFound(error)) return null; throw error; @@ -210,16 +198,13 @@ export async function loadRuntimeDurableSnapshot( const covered = targets.filter((_, index) => results[index] !== null); const resolutions = new Set(histories.map((history) => history.resolution_seconds)); if (resolutions.size > 1) { - throw new RuntimeDurableHistoryIncompleteError("Durable Runtime histories returned inconsistent resolutions."); + throw new RuntimeDurableHistoryIncompleteError(null); } return { samples: runtimeDurableTrendSamples(covered, histories), - rangeStart: rangeStartSeconds * 1_000, - rangeEnd: rangeEndSeconds * 1_000, resolutionSeconds: histories[0]?.resolution_seconds ?? 30, targetCount: covered.length, sampleCount: histories.reduce((total, history) => total + history.coverage.sample_count, 0), expectedSampleCount: histories.reduce((total, history) => total + history.coverage.expected_sample_count, 0), - loadedAt: now(), }; } diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index 8401fd815..a937f2af0 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -25,9 +25,10 @@ import { ListToolbar, listSummary, NameCell, SearchField } from "../../component import { useConsoleNavigation } from "../../lib/console-navigation"; import { formatBytes, formatClock, formatCompact, formatCores, formatDateTime, formatDuration, formatInteger, formatPercent, formatRelative, MISSING } from "../../lib/format"; import { projectClient, ProjectName, useProjects } from "../../lib/projects"; +import { readError } from "../../lib/read-error"; import { sandboxStateLabel } from "../../lib/sandbox-labels"; import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; -import { loadRuntimeDurableSnapshot, RUNTIME_DURABLE_RANGES, type RuntimeDurableRange } from "../dashboard/runtime-history"; +import { loadRuntimeDurableSnapshot, RUNTIME_DURABLE_RANGES, RuntimeDurableHistoryIncompleteError, type RuntimeDurableRange } from "../dashboard/runtime-history"; import type { RuntimeDashboardSnapshot } from "../dashboard/runtime-snapshot"; import { RUNTIME_SNAPSHOT_REFRESH_MS } from "../dashboard/runtime-snapshot"; import { DiagnosticTip } from "../fleet/DiagnosticTip"; @@ -104,7 +105,7 @@ export function SandboxMetricsPage() { const refreshing = runtimeState.status === "loading" || (fleetState.status === "ready" && fleetState.refreshing); const updatedAt = runtimeState.load?.loadedAt ?? fleet?.loadedAt ?? null; const message = fleetMessage(fleetState, t); - const [range, setRange] = useState(RUNTIME_DURABLE_RANGES[0].milliseconds); + const [range, setRange] = useState(RUNTIME_DURABLE_RANGES[0]); // A node or a sandbox opens in a dialog with its own figures, instead of leaving the page. const [openNode, setOpenNode] = useState(null); const [openRuntime, setOpenRuntime] = useState(null); @@ -123,9 +124,9 @@ export function SandboxMetricsPage() { actions={<> ({ value: String(entry.milliseconds), label: t(`range.${entry.label}`) }))} - onChange={(value) => setRange(Number(value) as RuntimeDurableRange)} + value={range} + options={RUNTIME_DURABLE_RANGES.map((entry) => ({ value: entry, label: t(`range.${entry}`) }))} + onChange={setRange} /> { refreshRuntime(); refreshFleet(); }} /> } @@ -286,11 +287,21 @@ function useRuntimeHistory(snapshot: RuntimeDashboardSnapshot | null, range: Run placeholderData: keepPreviousData, refetchInterval: 30_000, refetchIntervalInBackground: false, + // One read per Session per poll: the 30-second poll is the retry, and a + // retry here would read every Session again. + retry: false, }); } +/** Core's typed errors and Web's own read bound, in the console's language. */ +function historyFailure(error: unknown, t: TFunction<"metrics">, tCommon: TFunction<"common">): string { + if (!(error instanceof RuntimeDurableHistoryIncompleteError)) return readError(error, tCommon); + return error.limit === null ? t("sandbox.charts.historyMixed") : t("sandbox.charts.historyBudget", { limit: error.limit }); +} + function HostedRuntimeSection({ state, stale, fleet, range, onOpen }: { state: RuntimeState; stale: string | null; fleet: ReturnType; range: RuntimeDurableRange; onOpen: (sessionId: string) => void }) { const { t, i18n } = useTranslation("metrics"); + const { t: tCommon } = useTranslation("common"); const locale = i18n.resolvedLanguage; const load = state.load; const usage = useMemo(() => (load ? hostedRuntimeUsage(load.observations) : null), [load]); @@ -311,7 +322,7 @@ function HostedRuntimeSection({ state, stale, fleet, range, onOpen }: { state: R body = ( <> {durable ? - : history.isError ?

{t("sandbox.charts.historyFailed", { reason: history.error instanceof Error ? history.error.message : "" })}

+ : history.isError ?

{t("sandbox.charts.historyFailed", { reason: historyFailure(history.error, t, tCommon) })}

: history.isFetched ?

{t("sandbox.charts.historyUnavailable")}

: null} @@ -464,6 +475,7 @@ function NodeDialog({ node, rows, load, range, stale, suspends, onClose }: { onClose: () => void; }) { const { t, i18n } = useTranslation("metrics"); + const { t: tCommon } = useTranslation("common"); const locale = i18n.resolvedLanguage; const { navigate } = useConsoleNavigation(); const shown = useLast(node); @@ -474,9 +486,8 @@ function NodeDialog({ node, rows, load, range, stale, suspends, onClose }: { ); const history = useRuntimeHistory(snapshot, range); // The machine itself: its last heartbeat and host history over the page's range. - const hostRange = RUNTIME_DURABLE_RANGES.find((entry) => entry.milliseconds === range)?.label ?? "1h"; const detail = useQuery({ - ...nodeDetailQuery(shown?.id ?? "", hostRange), + ...nodeDetailQuery(shown?.id ?? "", range), enabled: node !== null, placeholderData: keepPreviousData, refetchInterval: RUNTIME_SNAPSHOT_REFRESH_MS, @@ -530,7 +541,7 @@ function NodeDialog({ node, rows, load, range, stale, suspends, onClose }: { > {!sessionIds.size ? : history.data ? - : history.isError ?

{t("sandbox.charts.historyFailed", { reason: history.error instanceof Error ? history.error.message : "" })}

+ : history.isError ?

{t("sandbox.charts.historyFailed", { reason: historyFailure(history.error, t, tCommon) })}

: history.isFetched ?

{t("sandbox.charts.historyUnavailable")}

: } diff --git a/apps/web/src/features/sessions/SessionRuntimeSection.tsx b/apps/web/src/features/sessions/SessionRuntimeSection.tsx index aeadb58ff..371af9363 100644 --- a/apps/web/src/features/sessions/SessionRuntimeSection.tsx +++ b/apps/web/src/features/sessions/SessionRuntimeSection.tsx @@ -7,6 +7,7 @@ import { failedLast, useFailureToast } from "../../components/Toast"; import { DashboardSkeleton } from "../../components/Skeleton"; import { Kpi, KpiStrip, Section, SegmentedControl, type Tone } from "../../components/console-ui"; import { formatBytes, formatCores, formatDateTime, formatPercent, formatRelative, MISSING } from "../../lib/format"; +import { readError } from "../../lib/read-error"; import { RuntimeCharts } from "../metrics/RuntimeCharts"; import type { RuntimeTrendSample } from "../dashboard/runtime-trends"; @@ -20,7 +21,6 @@ function sampleSpacing(samples: readonly RuntimeTrendSample[]): number { } const HISTORY_REFRESH_MS = 30_000; -type RangeLabel = (typeof SESSION_RUNTIME_RANGES)[number]["label"]; function message(error: unknown): string { return error instanceof Error ? error.message : String(error); @@ -57,9 +57,9 @@ export function SessionRuntimeSection({ refreshToken: number; }) { const { t, i18n } = useTranslation("sessions"); + const { t: tCommon } = useTranslation("common"); const locale = i18n.resolvedLanguage; - const [rangeLabel, setRangeLabel] = useState(SESSION_RUNTIME_RANGES[0].label); - const range = SESSION_RUNTIME_RANGES.find((entry) => entry.label === rangeLabel)!.milliseconds as SessionRuntimeRange; + const [range, setRange] = useState(SESSION_RUNTIME_RANGES[0]); const observationRead = useQuery(sessionObservationQuery(projectId, session.id)); useOnChange(revision, observationRead.refetch); @@ -76,7 +76,7 @@ export function SessionRuntimeSection({ const history: { state: "loading" | "ready" | "unavailable" | "failed"; value: SessionRuntimeHistory | null; error: string | null } = { state: historyRead.isError ? "failed" : historyRead.data === undefined ? "loading" : historyRead.data === null ? "unavailable" : "ready", value: historyRead.data ?? null, - error: historyRead.isError ? message(historyRead.error) : null, + error: historyRead.isError ? readError(historyRead.error, tCommon) : null, }; // Both reads repeat while the Session runs; a failure is reported once while it lasts. useFailureToast(failedLast(observationRead), t("runtime.observationFailed", { reason: observationError ?? "" }), "session-runtime-observation"); @@ -104,9 +104,9 @@ export function SessionRuntimeSection({ actions={( ({ value: entry.label, label: t(`runtime.ranges.${entry.label}`) }))} - onChange={setRangeLabel} + value={range} + options={SESSION_RUNTIME_RANGES.map((entry) => ({ value: entry, label: t(`runtime.ranges.${entry}`) }))} + onChange={setRange} /> )} > diff --git a/apps/web/src/features/sessions/session-history.test.ts b/apps/web/src/features/sessions/session-history.test.ts index ae931653c..5e53c273c 100644 --- a/apps/web/src/features/sessions/session-history.test.ts +++ b/apps/web/src/features/sessions/session-history.test.ts @@ -127,9 +127,9 @@ describe("Session deletion and runtime answers", () => { it("treats a Session without retained runtime history as no history, not an error", async () => { const client = { retrieveRuntimeHistory: async () => { throw new AgentCoreError("No hosted runtime.", 404); } }; - await expect(loadSessionRuntimeHistory(client, session, 3_600_000)).resolves.toBeNull(); + await expect(loadSessionRuntimeHistory(client, session, "1h")).resolves.toBeNull(); const failing = { retrieveRuntimeHistory: async () => { throw new AgentCoreError("down", 503); } }; - await expect(loadSessionRuntimeHistory(failing, session, 3_600_000)).rejects.toThrow("down"); + await expect(loadSessionRuntimeHistory(failing, session, "1h")).rejects.toThrow("down"); }); }); diff --git a/apps/web/src/features/sessions/session-queries.ts b/apps/web/src/features/sessions/session-queries.ts index 2bf0edf98..c1515a01c 100644 --- a/apps/web/src/features/sessions/session-queries.ts +++ b/apps/web/src/features/sessions/session-queries.ts @@ -92,7 +92,7 @@ export function sessionObservationQuery(projectId: string, sessionId: string) { }); } -/** Retained runtime samples for the range ending now; null when Core keeps none. */ +/** Retained runtime samples for the range Core's clock ends; null when Core keeps none. */ export function sessionRuntimeHistoryQuery(projectId: string, session: AgentSession, range: SessionRuntimeRange) { return queryOptions({ queryKey: [...sessionKey(projectId, session.id), "runtime", "history", range], diff --git a/apps/web/src/features/sessions/session-runtime.ts b/apps/web/src/features/sessions/session-runtime.ts index 09a3b57bf..b3368d367 100644 --- a/apps/web/src/features/sessions/session-runtime.ts +++ b/apps/web/src/features/sessions/session-runtime.ts @@ -1,7 +1,7 @@ import { AgentCoreError, type AgentSession } from "@oac/agents-client"; import type { ProjectClient } from "../../lib/projects"; -import { RUNTIME_DURABLE_MAX_POINTS, RUNTIME_DURABLE_RANGES, runtimeDurableTrendSamples, type RuntimeDurableRange } from "../dashboard/runtime-history"; +import { RUNTIME_DURABLE_RANGES, runtimeDurableTrendSamples, type RuntimeDurableRange } from "../dashboard/runtime-history"; import type { RuntimeTrendSample } from "../dashboard/runtime-trends"; /** Runtime history ranges offered for one Session (1 h, 6 h, 24 h). */ @@ -10,9 +10,6 @@ export type SessionRuntimeRange = RuntimeDurableRange; export interface SessionRuntimeHistory { samples: RuntimeTrendSample[]; - /** Milliseconds. */ - rangeStart: number; - rangeEnd: number; } /** Only Core-managed hosted sandboxes report CPU and memory. */ @@ -22,20 +19,18 @@ export function hasObservableRuntime(session: AgentSession): boolean { /** * Reads the retained runtime samples of one hosted Session for the range - * ending now. Resolves to null when Core keeps no history for it (404). + * ending at Core's last complete bucket. Resolves to null when Core keeps no + * history for it (404). */ export async function loadSessionRuntimeHistory( client: Pick, session: AgentSession, range: SessionRuntimeRange, signal?: AbortSignal, - now: () => number = Date.now, ): Promise { - const end = Math.floor(now() / 1_000); - const start = end - range / 1_000; try { - const history = await client.retrieveRuntimeHistory(session.id, { start, end, maxPoints: RUNTIME_DURABLE_MAX_POINTS, signal }); - return { samples: runtimeDurableTrendSamples([session], [history]), rangeStart: start * 1_000, rangeEnd: end * 1_000 }; + const history = await client.retrieveRuntimeHistory(session.id, range, { signal }); + return { samples: runtimeDurableTrendSamples([session], [history]) }; } catch (error) { if (error instanceof AgentCoreError && error.status === 404) return null; throw error; diff --git a/apps/web/src/i18n/locales/en/metrics.ts b/apps/web/src/i18n/locales/en/metrics.ts index 4b37b5248..92a3dc989 100644 --- a/apps/web/src/i18n/locales/en/metrics.ts +++ b/apps/web/src/i18n/locales/en/metrics.ts @@ -304,6 +304,8 @@ export const metrics = { memoryLimit: "Configured limit", historyUnavailable: "Runtime history is not available.", historyFailed: "Runtime history could not be read. {{reason}}", + historyBudget: "Charts cover at most {{limit}} Sessions.", + historyMixed: "Sessions returned different bucket widths.", }, runtimeMeta: "{{n}} · CPU {{cpu}} · memory {{memory}}", runtimeMetaSuspended: "{{n}} · {{sleeping}} suspended · CPU {{cpu}} · memory {{memory}}", diff --git a/apps/web/src/i18n/locales/zh-CN/metrics.ts b/apps/web/src/i18n/locales/zh-CN/metrics.ts index e82d41ec0..285cd6933 100644 --- a/apps/web/src/i18n/locales/zh-CN/metrics.ts +++ b/apps/web/src/i18n/locales/zh-CN/metrics.ts @@ -304,6 +304,8 @@ export const metrics = { memoryLimit: "配额上限", historyUnavailable: "运行时历史不可用。", historyFailed: "无法读取运行时历史。{{reason}}", + historyBudget: "图表最多覆盖 {{limit}} 个 Session。", + historyMixed: "各 Session 返回的时间桶宽度不一致。", }, runtimeMeta: "{{n}} 个 · CPU {{cpu}} · 内存 {{memory}}", runtimeMetaSuspended: "{{n}} 个 · {{sleeping}} 个挂起 · CPU {{cpu}} · 内存 {{memory}}", diff --git a/apps/web/src/lib/projects.tsx b/apps/web/src/lib/projects.tsx index c5dc0e10f..a744d53ba 100644 --- a/apps/web/src/lib/projects.tsx +++ b/apps/web/src/lib/projects.tsx @@ -1,6 +1,6 @@ import type { AdminKeyProvenance, AdminProject, AdminResourceType, AgentDeleted, AgentSession, AgentTurn, EnvironmentTemplateDeleted, EnvironmentTemplateList, - EnvironmentTemplateResource, ListPage, PageOptions, ReadOptions, RuntimeHistory, RuntimeHistoryQuery, RuntimeObservation, SavedAgent, SessionDeleted, + EnvironmentTemplateResource, ListPage, PageOptions, ReadOptions, RuntimeHistory, RuntimeHistoryRange, RuntimeObservation, SavedAgent, SessionDeleted, SessionItem, SessionListOptions, Skill, SkillContent, SkillDeleted, SkillList, SkillListOptions, SkillVersionDeleted, SkillVersionList, SourceFileDeleted, SourceFileList, SourceFileListOptions, Vault, VaultCredentialDeleted, VaultCredentialList, VaultDeleted, VaultList, VaultListOptions, } from "@oac/agents-client"; @@ -148,7 +148,7 @@ export interface ProjectClient { listTurns(sessionId: string, options?: PageOptions): Promise>; listItems(sessionId: string, options?: PageOptions): Promise>; retrieveRuntimeObservation(sessionId: string, options?: ReadOptions): Promise; - retrieveRuntimeHistory(sessionId: string, query: RuntimeHistoryQuery): Promise; + retrieveRuntimeHistory(sessionId: string, range: RuntimeHistoryRange, options?: ReadOptions): Promise; } async function content(result: Promise<{ blob: Blob; contentType: string | null; contentDisposition: string | null }>) { @@ -192,7 +192,7 @@ function createProjectClient(projectId: string): ProjectClient { listTurns: (sessionId, options) => admin.listTurns(projectId, sessionId, options), listItems: (sessionId, options) => admin.listItems(projectId, sessionId, options), retrieveRuntimeObservation: (sessionId, options) => admin.retrieveRuntimeObservation(projectId, sessionId, options), - retrieveRuntimeHistory: (sessionId, query) => admin.retrieveRuntimeHistory(projectId, sessionId, query), + retrieveRuntimeHistory: (sessionId, range, options) => admin.retrieveRuntimeHistory(projectId, sessionId, range, options), }; } diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index f0b2184a1..f38f1bc92 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -5961,33 +5961,21 @@ paths: - Items /core/v1/projects/{project_id}/sessions/{session_id}/runtime-history: get: - description: Core key only; the Project ID selects the target space and does not authenticate. Returns stored Runtime observations for one Session. End is exclusive; the server selects a bounded resolution. Responses contain at most 1,000 series, 10,000 points per coverage/series array, and 100,000 total coverage plus series points. It never reads or changes live compute. + description: Core key only; the Project ID selects the target space and does not authenticate. Returns stored Runtime observations for one Session over the range ending at the last complete bucket on Core's clock; requested_range reports it. The server selects a bounded resolution. Responses contain at most 1,000 series, 10,000 points per coverage/series array, and 100,000 total coverage plus series points. It never reads or changes live compute. parameters: - description: Session ID in: path name: session_id required: true type: string - - description: Inclusive Unix-second start - in: query - maximum: 9007199254740991 - minimum: 0 - name: start - required: true - type: integer - - description: Exclusive Unix-second end - in: query - maximum: 9007199254740991 - minimum: 1 - name: end - required: true - type: integer - - description: Maximum points per series; defaults to the lower of 120 and the advertised service maximum + - description: Time range (default 1h) + enum: + - 1h + - 6h + - 24h in: query - maximum: 10000 - minimum: 2 - name: max_points - type: integer + name: range + type: string - description: Project ID in: path name: project_id @@ -6016,6 +6004,10 @@ paths: description: Conflict schema: $ref: '#/definitions/api.CoreErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.CoreErrorResponse' "503": description: Service Unavailable schema: diff --git a/contracts/agents-api/runtime-observability-api.md b/contracts/agents-api/runtime-observability-api.md index b4bc701d1..1cfab9864 100644 --- a/contracts/agents-api/runtime-observability-api.md +++ b/contracts/agents-api/runtime-observability-api.md @@ -167,17 +167,11 @@ An ownership mismatch, malformed durable identity or invalid provider evidence f ## Session Runtime history ```http -GET /core/v1/projects/{project_id}/sessions/{session_id}/runtime-history?start=1789951200&end=1789954800&max_points=120 +GET /core/v1/projects/{project_id}/sessions/{session_id}/runtime-history?range=1h Authorization: Bearer ``` -| Parameter | Rules | -| --- | --- | -| `start` | Required. Inclusive Unix second, 0 or more. | -| `end` | Required. Exclusive Unix second, after `start`, at most 24 hours after it and at most one second in the future. | -| `max_points` | Optional. Buckets per array, 2 to 1000; default 120. | - -Each parameter may appear once. Core chooses the bucket width: the range divided by `max_points`, rounded up to whole seconds, and at least 30 seconds or the sampling interval, whichever is longer. Buckets start at `start`; the last one ends at `end`. +`range` is `1h` (the default), `6h` or `24h`, and is the only parameter. The request carries no time: Core ends the range at the last bucket boundary on its own clock, counted in whole buckets from the Unix epoch, so a caller's clock cannot reject or shift the read, and reads within one bucket return the same range. `requested_range` reports the range in Unix seconds; `start` is inclusive and `end` exclusive. The bucket width is the range divided by 120, rounded up to whole seconds, and at least 30 seconds or the sampling interval, whichever is longer. Buckets start at `requested_range.start`; the last one ends at `requested_range.end`. Core resolves the Project, then the Session and its Environment, before it reads storage; allocation and provider identities are results, never query inputs. History exists only for `openai_hosted` Sessions. @@ -187,7 +181,7 @@ Core resolves the Project, then the Session and its Environment, before it reads "source": "durable", "session_id": "6c77d3a2-71d6-4ed5-884f-687aecda02a3", "requested_range": { "start": 1789951200, "end": 1789954800 }, - "resolution_seconds": 60, + "resolution_seconds": 30, "generated_at": 1789954801, "coverage": { "retained_start": 1789951200, @@ -206,7 +200,7 @@ Core resolves the Project, then the Session and its Environment, before it reads ### Coverage -`coverage` counts every stored sample of the Session in the range, including unavailable ones that belong to no allocation. `retained_start` is the later of `start` and seven days before `generated_at`. `expected_sample_count` is the number of sampling intervals between `retained_start` and `end`, rounded up. Each bucket has `start`, `end`, `first_observed_at`, `last_observed_at`, `observation_count`, `observed_count` and `unavailable_count`. +`coverage` counts every stored sample of the Session in the range, including unavailable ones that belong to no allocation. `retained_start` is the later of `requested_range.start` and seven days before `generated_at`. `expected_sample_count` is the number of sampling intervals between `retained_start` and `requested_range.end`, rounded up. Each bucket has `start`, `end`, `first_observed_at`, `last_observed_at`, `observation_count`, `observed_count` and `unavailable_count`. ### Series @@ -227,18 +221,17 @@ Disk is not kept in history. | HTTP | Code | When | | --- | --- | --- | -| 400 | `unsupported_parameter` | A parameter other than `start`, `end` and `max_points`, or one supplied twice. | -| 400 | `invalid_request` | An invalid range or `max_points`. | +| 400 | `invalid_request` | A parameter other than `range`, a repeated `range`, or a `range` other than `1h`, `6h` and `24h`. | | 404 | `not_found_error` | A missing Project, or a Session missing from it. | | 409 | `runtime_history_unsupported` | The Session is not `openai_hosted`. | | 500 | `internal_error` | Inconsistent stored identity. | | 503 | `runtime_history_unavailable` | The read failed, timed out or produced a result outside the bounds. | -A response holds at most `max_points` buckets per array, 64 series and 10,000 coverage and series points in total. Storage error text is neither returned nor logged. +A response holds at most 120 buckets per array, 64 series and 10,000 coverage and series points in total. Storage error text is neither returned nor logged. ### Client -`AdminClient.retrieveRuntimeHistory(projectId, sessionId, {start, end, maxPoints, signal})` validates the query before sending it. It then checks the exact fields, the echoed range and Session, bucket order within the range, coverage totals, allocation identity, contributor counts, token usage order, nullability, numbers and response size. Any violation rejects the whole response with a 502 `invalid_admin_response` error. +`AdminClient.retrieveRuntimeHistory(projectId, sessionId, range, {signal})` validates the Session ID and `range` before sending them. It then checks the exact fields, the echoed range's length and Session, bucket order within the range, coverage totals, allocation identity, contributor counts, token usage order, nullability, numbers and response size. Any violation rejects the whole response with a 502 `invalid_admin_response` error. ## Node host observations and history diff --git a/contracts/agents-api/zh/runtime-observability-api.md b/contracts/agents-api/zh/runtime-observability-api.md index 660c4a7d9..89ce44997 100644 --- a/contracts/agents-api/zh/runtime-observability-api.md +++ b/contracts/agents-api/zh/runtime-observability-api.md @@ -1,7 +1,7 @@ --- title: "Runtime 遥测 API" source: contracts/agents-api/runtime-observability-api.md -source_hash: 05ee25e01c2a8e9ce0f85c325a4a0e0e2f11eb76861e54740efa8975d9c5a999 +source_hash: 4a9c00d1eed9fe51f9e8f2f08e00f1709ddc55eb1c650ed3a5380f44a5b71e1c --- Core 通过 `/core/v1` 下的只读管理员路由报告托管 Runtime 和沙箱节点所使用的信息:当前 Runtime 观测值、单个 Session 的已存储 Runtime 历史记录,以及沙箱节点的主机观测值和历史记录。读取操作绝不创建、唤醒、续期或更改计算资源,也绝不向历史记录添加样本。[Runtime observability](runtime-observability.md) 定义了 Core 如何采集和保留这些值;[Console API usage](../../../docs/zh/web/console-api-usage.md) 列出了读取这些值的 Web 页面。 @@ -169,17 +169,11 @@ Authorization: Bearer ## Session Runtime 历史记录 {#session-runtime-history} ```http -GET /core/v1/projects/{project_id}/sessions/{session_id}/runtime-history?start=1789951200&end=1789954800&max_points=120 +GET /core/v1/projects/{project_id}/sessions/{session_id}/runtime-history?range=1h Authorization: Bearer ``` -| 参数 | 规则 | -| --- | --- | -| `start` | 必填。包含端点的 Unix 秒值,必须大于或等于 0。 | -| `end` | 必填。排除端点的 Unix 秒值,必须晚于 `start`,最多比 `start` 晚 24 小时,并且最多只能比当前时间晚 1 秒。 | -| `max_points` | 可选。每个数组的桶数,2 到 1000;默认 120。 | - -每个参数最多只能出现一次。Core 会选择桶宽:将范围除以 `max_points`,向上取整为整数秒,并取 30 秒或采样间隔中的较长者作为最小值。桶从 `start` 开始;最后一个桶在 `end` 结束。 +`range` 可以是 `1h`(默认值)、`6h` 或 `24h`,并且是唯一的参数。请求不携带任何时间:Core 按自己的时钟,将范围的结束点定在最近一个桶边界上,桶边界从 Unix 纪元起按整桶计算。因此调用方的时钟无法使读取被拒绝或发生偏移,同一个桶内的多次读取会得到相同的范围。`requested_range` 以 Unix 秒报告该范围;`start` 包含在内,`end` 不包含在内。桶宽是范围除以 120,向上取整为整数秒,并取 30 秒或采样间隔中的较长者作为最小值。桶从 `requested_range.start` 开始;最后一个桶在 `requested_range.end` 结束。 Core 先解析 Project,然后解析 Session 及其 Environment,之后才读取存储;分配和提供方标识都是结果,绝不能作为查询输入。只有 `openai_hosted` Session 存在历史记录。 @@ -189,7 +183,7 @@ Core 先解析 Project,然后解析 Session 及其 Environment,之后才读 "source": "durable", "session_id": "6c77d3a2-71d6-4ed5-884f-687aecda02a3", "requested_range": { "start": 1789951200, "end": 1789954800 }, - "resolution_seconds": 60, + "resolution_seconds": 30, "generated_at": 1789954801, "coverage": { "retained_start": 1789951200, @@ -208,7 +202,7 @@ Core 先解析 Project,然后解析 Session 及其 Environment,之后才读 ### 覆盖范围 {#coverage} -`coverage` 会对范围内 Session 的每个已存储样本进行计数,包括不属于任何分配的不可用样本。`retained_start` 是 `start` 与 `generated_at` 前七天两者中较晚的时间。`expected_sample_count` 是 `retained_start` 与 `end` 之间的采样间隔数,并向上取整。每个桶都包含 `start`、`end`、`first_observed_at`、`last_observed_at`、`observation_count`、`observed_count` 和 `unavailable_count`。 +`coverage` 会对范围内 Session 的每个已存储样本进行计数,包括不属于任何分配的不可用样本。`retained_start` 是 `requested_range.start` 与 `generated_at` 前七天两者中较晚的时间。`expected_sample_count` 是 `retained_start` 与 `requested_range.end` 之间的采样间隔数,并向上取整。每个桶都包含 `start`、`end`、`first_observed_at`、`last_observed_at`、`observation_count`、`observed_count` 和 `unavailable_count`。 ### 数据系列 {#series} @@ -229,18 +223,17 @@ Core 先解析 Project,然后解析 Session 及其 Environment,之后才读 | HTTP | 代码 | 适用情况 | | --- | --- | --- | -| 400 | `unsupported_parameter` | 提供了 `start`、`end` 和 `max_points` 以外的参数,或同一参数提供了两次。 | -| 400 | `invalid_request` | 范围或 `max_points` 无效。 | +| 400 | `invalid_request` | 提供了 `range` 以外的参数、重复的 `range`,或 `range` 不是 `1h`、`6h` 或 `24h`。 | | 404 | `not_found_error` | Project 不存在,或 Session 不属于该 Project。 | | 409 | `runtime_history_unsupported` | Session 不是 `openai_hosted`。 | | 500 | `internal_error` | 已存储的标识不一致。 | | 503 | `runtime_history_unavailable` | 读取失败、超时或产生了超出界限的结果。 | -每个数组最多包含 `max_points` 个桶,响应最多包含 64 条 series,并且 coverage 和 series 中的点总计最多为 10,000 个。存储错误文本既不会返回,也不会记录到日志中。 +每个数组最多包含 120 个桶,响应最多包含 64 条 series,并且 coverage 和 series 中的点总计最多为 10,000 个。存储错误文本既不会返回,也不会记录到日志中。 ### 客户端 {#client-1} -`AdminClient.retrieveRuntimeHistory(projectId, sessionId, {start, end, maxPoints, signal})` 会在发送查询前验证查询。随后,它会检查精确字段、回显的范围和 Session、范围内的桶顺序、coverage 总计、分配标识、贡献者计数、token 用量顺序、可空性、数值和响应大小。任何违规都会使整个响应被拒绝,并返回 502 `invalid_admin_response` 错误。 +`AdminClient.retrieveRuntimeHistory(projectId, sessionId, range, {signal})` 会在发送前验证 Session ID 和 `range`。随后,它会检查精确字段、回显范围的长度和 Session、范围内的桶顺序、coverage 总计、分配标识、贡献者计数、token 用量顺序、可空性、数值和响应大小。任何违规都会使整个响应被拒绝,并返回 502 `invalid_admin_response` 错误。 ## 节点主机观测值和历史记录 {#node-host-observations-and-history} diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index a19504b87..f710d67e4 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -70,7 +70,7 @@ const routeCases: Array<[string, string, (client: AdminClient) => Promise client.downloadArtifact(projectId, sessionId, "a")], ["GET", `/projects/${projectId}/sessions/${sessionId}/execution-configuration`, (client) => client.retrieveSessionExecutionConfiguration(projectId, sessionId)], ["GET", `/projects/${projectId}/sessions/${sessionId}/runtime-observation`, (client) => client.retrieveRuntimeObservation(projectId, sessionId)], - ["GET", `/projects/${projectId}/sessions/${sessionId}/runtime-history?start=1&end=2`, (client) => client.retrieveRuntimeHistory(projectId, sessionId, { start: 1, end: 2 })], + ["GET", `/projects/${projectId}/sessions/${sessionId}/runtime-history?range=1h`, (client) => client.retrieveRuntimeHistory(projectId, sessionId, "1h")], ["GET", `/projects/${projectId}/resource-owners?resource_type=agent&resource_ids=a`, (client) => client.retrieveResourceOwners(projectId, "agent", ["a"])], ["GET", `/projects/${projectId}/write-operations`, (client) => client.listWriteOperations(projectId)], ["GET", `/projects/${projectId}/environments/${resourceId}/executor-credentials`, (client) => client.listExecutorCredentials(projectId, resourceId)], diff --git a/packages/agents-client/src/admin-client.ts b/packages/agents-client/src/admin-client.ts index c8818d2e1..21f166f1a 100644 --- a/packages/agents-client/src/admin-client.ts +++ b/packages/agents-client/src/admin-client.ts @@ -9,7 +9,7 @@ import { } from "./client"; import { projectExecutionConfiguration } from "./execution-configuration-projection"; import { projectAgentTurn, projectSessionItem, projectHistoryPage } from "./history-projection"; -import { projectRuntimeHistory } from "./runtime-history-projection"; +import { projectRuntimeHistory, runtimeHistoryRangeSeconds } from "./runtime-history-projection"; import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord } from "./response-projection"; import { CoreRequester } from "./core-request"; import { projectSkill, projectSkillDeleted, projectSkillVersion, projectSkillVersionDeleted } from "./skill-projection"; @@ -20,7 +20,7 @@ import { projectExecutorCredentials, projectIssuedExecutorCredential, projectCoreHarnessList, projectHarnessModelConfiguration, projectInstallation, } from "./admin-projection"; import type { - CoreHarness, CoreHarnessKind, HarnessModelConfiguration, ModelConfigurationInput, PageOptions, ReadOptions, RuntimeHistoryQuery, SkillList, SkillVersionDeleted, + CoreHarness, CoreHarnessKind, HarnessModelConfiguration, ModelConfigurationInput, PageOptions, ReadOptions, RuntimeHistoryRange, SkillList, SkillVersionDeleted, SkillVersionList, SourceFileList, VaultListOptions, } from "./types"; import type { @@ -260,13 +260,10 @@ export class AdminClient { async retrieveRuntimeObservation(projectId: string, sessionId: string, options?: ReadOptions) { return projectRuntimeObservation(await this.#json(`${scope(projectId)}/sessions/${segment(sessionId)}/runtime-observation`, options), sessionId); } - async retrieveRuntimeHistory(projectId: string, sessionId: string, query: RuntimeHistoryQuery) { - if (canonicalUuid(sessionId) === null || !isNonnegativeInteger(query.start) || !isNonnegativeInteger(query.end) || query.end <= query.start || - (query.maxPoints !== undefined && (!Number.isSafeInteger(query.maxPoints) || query.maxPoints < 2 || query.maxPoints > 10_000))) throw new TypeError("Runtime history query is invalid."); - const path = pageQuery(`${scope(projectId)}/sessions/${segment(sessionId)}/runtime-history`, undefined, { - start: String(query.start), end: String(query.end), max_points: query.maxPoints === undefined ? undefined : String(query.maxPoints), - }); - return projectRuntimeHistory(await this.#json(path, query), sessionId, query, invalidAdminResponse); + /** History over the named range, which Core's clock ends; the caller needs no clock. */ + async retrieveRuntimeHistory(projectId: string, sessionId: string, range: RuntimeHistoryRange, options?: ReadOptions) { + if (canonicalUuid(sessionId) === null || !Object.hasOwn(runtimeHistoryRangeSeconds, range)) throw new TypeError("Runtime history query is invalid."); + return projectRuntimeHistory(await this.#json(`${scope(projectId)}/sessions/${segment(sessionId)}/runtime-history?range=${range}`, options), sessionId, range, invalidAdminResponse); } async retrieveResourceOwners(projectId: string, resourceType: AdminResourceType, resourceIds: string[], options?: ReadOptions): Promise<{ data: AdminResourceOwner[] }> { diff --git a/packages/agents-client/src/runtime-history-projection.ts b/packages/agents-client/src/runtime-history-projection.ts index 6cc2040ec..13e20b64a 100644 --- a/packages/agents-client/src/runtime-history-projection.ts +++ b/packages/agents-client/src/runtime-history-projection.ts @@ -5,11 +5,15 @@ import { type RuntimeHistoryTokenUsagePoint, } from "./generated/core-api"; import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, sameResourceId, type FieldNames } from "./response-projection"; -import type { RuntimeHistory, RuntimeHistoryQuery } from "./types"; +import type { RuntimeHistory, RuntimeHistoryRange } from "./types"; type InvalidRuntimeHistory = (message?: string) => never; const providerTypePattern = /^[a-z][a-z0-9_]{0,31}$/; +/** The length of each named range; Core's clock ends it. */ +export const runtimeHistoryRangeSeconds: Readonly> = { "1h": 3_600, "6h": 21_600, "24h": 86_400 }; +/** Core reads at most this many buckets per array. */ +const maximumPoints = 120; const maximumSeries = 1_000; const maximumTotalPoints = 100_000; @@ -174,14 +178,14 @@ function projectSeries( export function projectRuntimeHistory( value: unknown, expectedSessionId: string, - requested: RuntimeHistoryQuery, + range: RuntimeHistoryRange, invalid: InvalidRuntimeHistory, ): RuntimeHistory { if ( !isRecord(value) || !exactFields(value, runtimeHistoryFields) || value.object !== "agent.runtime_history" || value.source !== "durable" || typeof value.session_id !== "string" || !sameResourceId(value.session_id, expectedSessionId) || !isRecord(value.requested_range) || !exactFields(value.requested_range, runtimeHistoryRangeFields) || - value.requested_range.start !== requested.start || value.requested_range.end !== requested.end || + !isNonnegativeInteger(value.requested_range.start) || value.requested_range.end !== value.requested_range.start + runtimeHistoryRangeSeconds[range] || !positiveInteger(value.resolution_seconds) || !isNonnegativeInteger(value.generated_at) || !isRecord(value.coverage) || !exactFields(value.coverage, runtimeHistoryCoverageFields) || !Array.isArray(value.coverage.buckets) || !Array.isArray(value.series) || !Array.isArray(value.token_usage) @@ -189,7 +193,8 @@ export function projectRuntimeHistory( const sessionId = canonicalUuid(value.session_id); if (sessionId === null) return invalid(); const generatedAt = value.generated_at as number; - const maximumPoints = requested.maxPoints ?? 120; + // Core chose the range on its own clock; every bound below follows it. + const requested = { start: value.requested_range.start, end: value.requested_range.end as number }; if (value.coverage.buckets.length > maximumPoints || value.series.length > maximumSeries) return invalid(); const retainedStart = value.coverage.retained_start; const firstSample = nullableNonnegativeInteger(value.coverage.first_sample_at, invalid); diff --git a/packages/agents-client/src/runtime-history.test.ts b/packages/agents-client/src/runtime-history.test.ts index d82ff9512..4e8a4d11f 100644 --- a/packages/agents-client/src/runtime-history.test.ts +++ b/packages/agents-client/src/runtime-history.test.ts @@ -49,9 +49,9 @@ function history(overrides: Record = {}): Record { const calls: FetchCall[] = []; const controller = new AbortController(); const response = history(); - const value = await clientFor(response, calls).retrieveRuntimeHistory(projectId, sessionId, { - start: 1000, - end: 1120, - maxPoints: 60, - signal: controller.signal, - }); + const value = await clientFor(response, calls).retrieveRuntimeHistory(projectId, sessionId, "1h", { signal: controller.signal }); expect(String(calls[0]?.input)).toBe( - `https://core.example/core/v1/projects/${projectId}/sessions/${sessionId}/runtime-history?start=1000&end=1120&max_points=60`, + `https://core.example/core/v1/projects/${projectId}/sessions/${sessionId}/runtime-history?range=1h`, ); expect(calls[0]?.init?.signal).toBe(controller.signal); expect(value.series[0]?.points[0]?.cpu?.utilization_ratio).toBe(0); @@ -104,14 +99,8 @@ describe("Runtime history client", () => { it("rejects invalid requests before fetch", async () => { const calls: FetchCall[] = []; const client = clientFor(history(), calls); - for (const [id, query] of [ - ["not-a-session", { start: 1000, end: 1120 }], - [sessionId, { start: 1120, end: 1000 }], - [sessionId, { start: -1, end: 1000 }], - [sessionId, { start: 1000, end: 1120, maxPoints: 1 }], - [sessionId, { start: 1000, end: 1120, maxPoints: 10001 }], - ] as const) { - await expect(client.retrieveRuntimeHistory(projectId, id, query)).rejects.toThrow(TypeError); + for (const [id, range] of [["not-a-session", "1h"], [sessionId, "2h"], [sessionId, "constructor"]] as const) { + await expect(client.retrieveRuntimeHistory(projectId, id, range as "1h")).rejects.toThrow(TypeError); } expect(calls).toHaveLength(0); }); @@ -119,7 +108,7 @@ describe("Runtime history client", () => { for (const [name, mutate] of [ ["unknown field", (value: Record) => { value.tokens = 10; }], ["foreign Session", (value: Record) => { value.session_id = "55555555-5555-4555-8555-555555555555"; }], - ["mismatched range", (value: Record) => { value.requested_range = { start: 999, end: 1120 }; }], + ["range of another length", (value: Record) => { value.requested_range = { start: 999, end: 4600 }; }], ["empty result generated before the requested range", (value: Record) => { value.generated_at = 999; value.coverage = { @@ -221,11 +210,8 @@ describe("Runtime history client", () => { it(`rejects ${name} in history`, async () => { const value = history(); mutate(value); - await expect(clientFor(value).retrieveRuntimeHistory(projectId, sessionId, { - start: 1000, - end: 1120, - maxPoints: 60, - })).rejects.toMatchObject({ status: 502, code: "invalid_admin_response" }); + await expect(clientFor(value).retrieveRuntimeHistory(projectId, sessionId, "1h")) + .rejects.toMatchObject({ status: 502, code: "invalid_admin_response" }); }); } }); diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index c9e056007..7a2c37b41 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -878,14 +878,8 @@ export type RuntimeObservation = | RuntimeNoneObservation | RuntimeSelfHostedObservation; -export interface RuntimeHistoryQuery extends ReadOptions { - /** Inclusive Unix-second boundary. */ - start: number; - /** Exclusive Unix-second boundary. */ - end: number; - /** Requested maximum buckets per series. Core selects the effective resolution. */ - maxPoints?: number; -} +/** A Runtime history window that ends at Core's last complete bucket. */ +export type RuntimeHistoryRange = "1h" | "6h" | "24h"; export type { CoreHarnessKind, ModelProviderProtocol } from "./harness-catalog"; diff --git a/services/core/internal/api/admin_runtime_test.go b/services/core/internal/api/admin_runtime_test.go index d2962247a..cb0b838d8 100644 --- a/services/core/internal/api/admin_runtime_test.go +++ b/services/core/internal/api/admin_runtime_test.go @@ -52,7 +52,7 @@ func TestAdminRuntimeRoutesRejectHead(t *testing.T) { return runtimeobs.Observation{}, errors.New("sampled") }) handler, _ := adminRuntimeFixture(t, nil, nil, service) - for _, path := range []string{adminRuntimeObservationsPath, adminSessionsPath + uuid.NewString() + "/runtime-observation", adminSessionsPath + uuid.NewString() + "/runtime-history?start=1&end=2"} { + for _, path := range []string{adminRuntimeObservationsPath, adminSessionsPath + uuid.NewString() + "/runtime-observation", adminSessionsPath + uuid.NewString() + "/runtime-history"} { response := serve(handler, http.MethodHead, path, "", withHeaders([]string{"Authorization", "Bearer admin"})) if response.Code != http.StatusMethodNotAllowed || response.Header().Get("Allow") != "GET" || response.Header().Get("Content-Type") != "application/json" { t.Errorf("HEAD %s = %d %q", path, response.Code, response.Header().Get("Allow")) diff --git a/services/core/internal/api/admin_sessions_routes.go b/services/core/internal/api/admin_sessions_routes.go index 7775b4e27..2f374a78b 100644 --- a/services/core/internal/api/admin_sessions_routes.go +++ b/services/core/internal/api/admin_sessions_routes.go @@ -188,16 +188,14 @@ func (h *Handler) adminGetRuntimeObservation(w http.ResponseWriter, r *http.Requ } // @Summary Retrieve Session Runtime history in a Project -// @Description Core key only; the Project ID selects the target space and does not authenticate. Returns stored Runtime observations for one Session. End is exclusive; the server selects a bounded resolution. Responses contain at most 1,000 series, 10,000 points per coverage/series array, and 100,000 total coverage plus series points. It never reads or changes live compute. +// @Description Core key only; the Project ID selects the target space and does not authenticate. Returns stored Runtime observations for one Session over the range ending at the last complete bucket on Core's clock; requested_range reports it. The server selects a bounded resolution. Responses contain at most 1,000 series, 10,000 points per coverage/series array, and 100,000 total coverage plus series points. It never reads or changes live compute. // @Tags Runtime history // @Produce json // @Security DeploymentAdminAuth // @Param session_id path string true "Session ID" -// @Param start query integer true "Inclusive Unix-second start" minimum(0) maximum(9007199254740991) -// @Param end query integer true "Exclusive Unix-second end" minimum(1) maximum(9007199254740991) -// @Param max_points query integer false "Maximum points per series; defaults to the lower of 120 and the advertised service maximum" minimum(2) maximum(10000) +// @Param range query string false "Time range (default 1h)" Enums(1h,6h,24h) // @Success 200 {object} v1.RuntimeHistory -// @Failure 400,401,404,409,503 {object} CoreErrorResponse +// @Failure 400,401,404,409,500,503 {object} CoreErrorResponse // @Param project_id path string true "Project ID" // @Router /core/v1/projects/{project_id}/sessions/{session_id}/runtime-history [get] func (h *Handler) adminGetRuntimeHistory(w http.ResponseWriter, r *http.Request) { diff --git a/services/core/internal/api/core_metrics.go b/services/core/internal/api/core_metrics.go index 6cf714e69..fa6957699 100644 --- a/services/core/internal/api/core_metrics.go +++ b/services/core/internal/api/core_metrics.go @@ -24,15 +24,11 @@ type Metrics interface { // @Failure 400,401,503 {object} CoreErrorResponse // @Router /core/v1/metrics [get] func (h *Handler) getCoreMetrics(w http.ResponseWriter, r *http.Request) { - values, err := url.ParseQuery(r.URL.RawQuery) - if err != nil || len(values) > 1 || (len(values) == 1 && len(values["range"]) != 1) { + name, ok := rangeName(r) + if !ok { writeError(w, http.StatusBadRequest, "invalid_request", "Only one supported range parameter is allowed.") return } - name := "1h" - if v, ok := values["range"]; ok { - name = v[0] - } switch name { case "1h", "6h", "24h", "7d": default: @@ -47,6 +43,19 @@ func (h *Handler) getCoreMetrics(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, value) } +// rangeName reads the one query parameter a metrics read accepts: a range, +// 1h when absent. Core's clock ends the range, so a caller needs no clock. +func rangeName(r *http.Request) (string, bool) { + values, err := url.ParseQuery(r.URL.RawQuery) + if err != nil || len(values) > 1 || len(values) == 1 && len(values["range"]) != 1 { + return "", false + } + if v, ok := values["range"]; ok { + return v[0], true + } + return "1h", true +} + func (h *Handler) responseHeaders(next http.Handler) http.Handler { return responseHeadersWithLog(next, func(code string) { if code == "execution_unavailable" { diff --git a/services/core/internal/api/runtime_history.go b/services/core/internal/api/runtime_history.go index 0e39a934d..b274ba522 100644 --- a/services/core/internal/api/runtime_history.go +++ b/services/core/internal/api/runtime_history.go @@ -4,7 +4,6 @@ import ( "context" "errors" "net/http" - "strconv" "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" @@ -18,6 +17,8 @@ const ( runtimeHistoryRequestBudget = 15 * time.Second ) +var runtimeHistoryRanges = map[string]time.Duration{"1h": time.Hour, "6h": 6 * time.Hour, "24h": 24 * time.Hour} + // RuntimeHistory queries the durable Runtime history Core samples // periodically. type RuntimeHistory interface { @@ -39,8 +40,6 @@ func (h *Handler) getRuntimeHistory(w http.ResponseWriter, r *http.Request) { value, err := h.RuntimeHistory.QuerySession(ctx, expectedTenantID, expectedSessionID, requested) if err != nil { switch { - case errors.Is(err, runtimehistory.ErrInvalidRange): - writeError(w, http.StatusBadRequest, "invalid_request", "Runtime history range is invalid.") case errors.Is(err, runtimehistory.ErrUnsupported): writeError(w, http.StatusConflict, "runtime_history_unsupported", "Runtime history is not supported for this Session.") case errors.Is(err, runtimehistory.ErrUnavailable), errors.Is(err, runtimehistory.ErrInvalidResult), errors.Is(err, context.DeadlineExceeded): @@ -61,32 +60,17 @@ func (h *Handler) getRuntimeHistory(w http.ResponseWriter, r *http.Request) { } func readRuntimeHistoryRange(w http.ResponseWriter, r *http.Request, capabilities runtimehistory.Capabilities, now time.Time) (runtimehistory.Range, bool) { - query := r.URL.Query() - for key, values := range query { - if key != "start" && key != "end" && key != "max_points" || len(values) != 1 { - writeError(w, http.StatusBadRequest, "unsupported_parameter", "Runtime history accepts one start, end and optional max_points value.") - return runtimehistory.Range{}, false - } - } - start, startErr := strconv.ParseInt(query.Get("start"), 10, 64) - end, endErr := strconv.ParseInt(query.Get("end"), 10, 64) - points := defaultRuntimeHistoryPoints - if capabilities.MaximumPoints < points { - points = capabilities.MaximumPoints - } - if raw := query.Get("max_points"); raw != "" { - var err error - points, err = strconv.Atoi(raw) - if err != nil || points < 2 || points > capabilities.MaximumPoints { - writeError(w, http.StatusBadRequest, "invalid_request", "Runtime history range is invalid.") - return runtimehistory.Range{}, false - } + name, ok := rangeName(r) + if !ok { + writeError(w, http.StatusBadRequest, "invalid_request", "Only one supported range parameter is allowed.") + return runtimehistory.Range{}, false } - if startErr != nil || endErr != nil || start < 0 || end <= start || end > now.Unix()+1 || end-start > int64(capabilities.MaximumRange/time.Second) { - writeError(w, http.StatusBadRequest, "invalid_request", "Runtime history range is invalid.") + duration, ok := runtimeHistoryRanges[name] + if !ok { + writeError(w, http.StatusBadRequest, "invalid_request", "range must be 1h, 6h or 24h.") return runtimehistory.Range{}, false } - return runtimehistory.Range{Start: time.Unix(start, 0).UTC(), End: time.Unix(end, 0).UTC(), MaxPoints: points}, true + return capabilities.Window(now, duration, min(defaultRuntimeHistoryPoints, capabilities.MaximumPoints)), true } func runtimeHistoryResponse(value runtimehistory.Response, expectedTenantID, expectedSessionID string, expectedRange runtimehistory.Range) (v1.RuntimeHistory, error) { diff --git a/services/core/internal/api/runtime_history_test.go b/services/core/internal/api/runtime_history_test.go index ac5b3d5c0..deaa5acfd 100644 --- a/services/core/internal/api/runtime_history_test.go +++ b/services/core/internal/api/runtime_history_test.go @@ -3,7 +3,6 @@ package api import ( "context" "encoding/json" - "fmt" "net/http" "testing" "time" @@ -16,12 +15,13 @@ import ( type runtimeHistoryFixture struct { capabilities runtimehistory.Capabilities - response runtimehistory.Response - err error - tenant string - session string - requested runtimehistory.Range - calls int + // respond builds the response for the range the handler chose. + respond func(runtimehistory.Range) runtimehistory.Response + err error + tenant string + session string + requested runtimehistory.Range + calls int } func (f *runtimeHistoryFixture) Capabilities() runtimehistory.Capabilities { return f.capabilities } @@ -29,7 +29,10 @@ func (f *runtimeHistoryFixture) Capabilities() runtimehistory.Capabilities { ret func (f *runtimeHistoryFixture) QuerySession(_ context.Context, tenant, session string, requested runtimehistory.Range) (runtimehistory.Response, error) { f.calls++ f.tenant, f.session, f.requested = tenant, session, requested - return f.response, f.err + if f.respond == nil { + return runtimehistory.Response{}, f.err + } + return f.respond(requested), f.err } // historyWith answers Runtime history from service. @@ -48,47 +51,64 @@ func historyCapabilities() runtimehistory.Capabilities { } func TestRuntimeHistoryRouteBindsAuthenticatedSessionAndPreservesCoverage(t *testing.T) { - now := time.Now().UTC().Truncate(time.Second) - start := now.Add(-time.Hour) sessionID, environmentID, allocationID := uuid.NewString(), uuid.NewString(), uuid.NewString() scope := runtimehistory.Scope{SessionID: sessionID, EnvironmentID: environmentID} zeroRatio := float64(0) capacity := 2.0 zeroMemory := uint64(0) limit := uint64(2048) - coveragePoint := runtimehistory.CoveragePoint{ - Start: start, End: start.Add(time.Minute), FirstObservedAt: start.Add(10 * time.Second), LastObservedAt: start.Add(10 * time.Second), - ObservationCount: 1, ObservedCount: 1, - } - resourcePoint := runtimehistory.Point{ - Start: start, End: start.Add(time.Minute), FirstObservedAt: start.Add(10 * time.Second), LastObservedAt: start.Add(10 * time.Second), - ObservationCount: 1, ObservedCount: 1, CPUContributorCount: 1, MemoryContributorCount: 1, - CPUUtilizationRatio: &zeroRatio, CPUCapacityCores: &capacity, MemoryUsageBytes: &zeroMemory, MemoryLimitBytes: &limit, - } service := &runtimeHistoryFixture{capabilities: historyCapabilities()} handler, _, tenant := adminTestHandler(t, historyWith(service)) scope.TenantID = tenant - service.response = runtimehistory.Response{ - Capabilities: service.capabilities, Scope: scope, - Requested: runtimehistory.Range{Start: start, End: now, MaxPoints: 60}, Resolution: time.Minute, GeneratedAt: now, RetainedFrom: &start, - Coverage: []runtimehistory.CoveragePoint{coveragePoint}, - Series: []runtimehistory.Series{{Scope: scope, AllocationID: allocationID, StartedAt: start.Add(-time.Minute), ProviderType: "docker", Points: []runtimehistory.Point{resourcePoint}}}, - TokenUsage: []runtimehistory.TokenUsagePoint{{Start: start, End: start.Add(time.Minute), SampledAt: start.Add(10 * time.Second), InputTokens: 120, OutputTokens: 30}}, - } - response := runtimeObservationRequest(handler, adminSessionsPath+sessionID+"/runtime-history?start="+timeString(start)+"&end="+timeString(now)+"&max_points=60") + service.respond = func(requested runtimehistory.Range) runtimehistory.Response { + start := requested.Start + coveragePoint := runtimehistory.CoveragePoint{ + Start: start, End: start.Add(30 * time.Second), FirstObservedAt: start.Add(10 * time.Second), LastObservedAt: start.Add(10 * time.Second), + ObservationCount: 1, ObservedCount: 1, + } + resourcePoint := runtimehistory.Point{ + Start: start, End: start.Add(30 * time.Second), FirstObservedAt: start.Add(10 * time.Second), LastObservedAt: start.Add(10 * time.Second), + ObservationCount: 1, ObservedCount: 1, CPUContributorCount: 1, MemoryContributorCount: 1, + CPUUtilizationRatio: &zeroRatio, CPUCapacityCores: &capacity, MemoryUsageBytes: &zeroMemory, MemoryLimitBytes: &limit, + } + return runtimehistory.Response{ + Capabilities: service.capabilities, Scope: scope, + Requested: requested, Resolution: 30 * time.Second, GeneratedAt: requested.End, RetainedFrom: &start, + Coverage: []runtimehistory.CoveragePoint{coveragePoint}, + Series: []runtimehistory.Series{{Scope: scope, AllocationID: allocationID, StartedAt: start.Add(-time.Minute), ProviderType: "docker", Points: []runtimehistory.Point{resourcePoint}}}, + TokenUsage: []runtimehistory.TokenUsagePoint{{Start: start, End: start.Add(30 * time.Second), SampledAt: start.Add(10 * time.Second), InputTokens: 120, OutputTokens: 30}}, + } + } + response := runtimeObservationRequest(handler, adminSessionsPath+sessionID+"/runtime-history?range=1h") if response.Code != http.StatusOK { t.Fatalf("history returned %d: %s", response.Code, response.Body) } var value v1.RuntimeHistory - if json.Unmarshal(response.Body.Bytes(), &value) != nil || value.Object != "agent.runtime_history" || value.Source != "durable" || value.SessionID != sessionID || value.ResolutionSeconds != 60 || value.Coverage.SampleCount != 1 || value.Coverage.ExpectedSampleCount != 120 || len(value.Coverage.Buckets) != 1 || len(value.Series) != 1 || len(value.Series[0].Points) != 1 || len(value.TokenUsage) != 1 || value.TokenUsage[0].InputTokens != 120 || value.TokenUsage[0].OutputTokens != 30 { + if json.Unmarshal(response.Body.Bytes(), &value) != nil || value.Object != "agent.runtime_history" || value.Source != "durable" || value.SessionID != sessionID || value.ResolutionSeconds != 30 || value.RequestedRange.End-value.RequestedRange.Start != 3600 || value.Coverage.SampleCount != 1 || value.Coverage.ExpectedSampleCount != 120 || len(value.Coverage.Buckets) != 1 || len(value.Series) != 1 || len(value.Series[0].Points) != 1 || len(value.TokenUsage) != 1 || value.TokenUsage[0].InputTokens != 120 || value.TokenUsage[0].OutputTokens != 30 { t.Fatalf("invalid history response: %s", response.Body) } point := value.Series[0].Points[0] if point.CPU == nil || point.CPU.UtilizationRatio == nil || *point.CPU.UtilizationRatio != 0 || point.Memory == nil || point.Memory.UsageBytes == nil || *point.Memory.UsageBytes != 0 { t.Fatalf("observed zero was lost: %+v", point) } - if service.calls != 1 || service.tenant != tenant || service.session != sessionID || !service.requested.Start.Equal(start) || !service.requested.End.Equal(now) || service.requested.MaxPoints != 60 { - t.Fatalf("history authority/range mismatch: %+v", service) + if service.calls != 1 || service.tenant != tenant || service.session != sessionID { + t.Fatalf("history authority mismatch: %+v", service) + } +} + +// The request carries no time, so a caller's clock minutes ahead of or behind +// Core's cannot reject or shift the read: Core's clock ends the range. +func TestRuntimeHistoryRangeEndsOnCoreClock(t *testing.T) { + service := &runtimeHistoryFixture{capabilities: historyCapabilities(), err: runtimehistory.ErrUnavailable} + handler, _, _ := adminTestHandler(t, historyWith(service)) + before := time.Now() + runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-history?range=6h") + after := time.Now() + step := 3 * time.Minute // 6 hours in 120 buckets + got := service.requested + if service.calls != 1 || got.End.Unix()%int64(step/time.Second) != 0 || !got.End.After(before.Add(-step)) || got.End.After(after) || + !got.Start.Equal(got.End.Add(-6*time.Hour)) || got.MaxPoints != 120 { + t.Fatalf("range %v-%v (%d points) does not end at Core's last complete bucket between %v and %v", got.Start, got.End, got.MaxPoints, before, after) } } @@ -97,7 +117,7 @@ func TestRuntimeHistoryRejectsUnsafeQueriesAndFailures(t *testing.T) { handler, _, _ := adminTestHandler(t, historyWith(service)) sessionID := uuid.NewString() for _, query := range []string{ - "", "?start=1", "?start=2&end=1", "?start=x&end=2", "?start=1&end=2&provider=docker", "?start=1&start=1&end=2", "?start=1&end=2&max_points=x", "?start=1&end=2&max_points=1", "?start=1&end=2&max_points=1001", "?start=1&end=90002", "?start=1&end=9223372036854775807", + "?range=", "?range=2h", "?range=7d", "?range=1h&range=1h", "?range=1h&provider=docker", "?range=1h&max_points=60", "?start=1&end=2", } { response := runtimeObservationRequest(handler, adminSessionsPath+sessionID+"/runtime-history"+query) if response.Code != http.StatusBadRequest { @@ -108,13 +128,12 @@ func TestRuntimeHistoryRejectsUnsafeQueriesAndFailures(t *testing.T) { t.Fatalf("unsafe query reached history service %d times", service.calls) } - now := time.Now().UTC().Truncate(time.Second) - path := adminSessionsPath + sessionID + "/runtime-history?start=" + timeString(now.Add(-time.Hour)) + "&end=" + timeString(now) + path := adminSessionsPath + sessionID + "/runtime-history?range=1h" for _, tc := range []struct { err error code int }{ - {runtimehistory.ErrInvalidRange, http.StatusBadRequest}, + {runtimehistory.ErrInvalidRange, http.StatusInternalServerError}, {runtimehistory.ErrUnsupported, http.StatusConflict}, {runtimehistory.ErrUnavailable, http.StatusServiceUnavailable}, {runtimehistory.ErrInvalidResult, http.StatusServiceUnavailable}, @@ -127,7 +146,6 @@ func TestRuntimeHistoryRejectsUnsafeQueriesAndFailures(t *testing.T) { } } service.err = nil - service.response = runtimehistory.Response{} response := runtimeObservationRequest(handler, path) if response.Code != http.StatusServiceUnavailable { t.Fatalf("malformed history response returned %d: %s", response.Code, response.Body) @@ -135,20 +153,10 @@ func TestRuntimeHistoryRejectsUnsafeQueriesAndFailures(t *testing.T) { } func TestRuntimeHistoryRejectsMismatchedServiceResponses(t *testing.T) { - now := time.Now().UTC().Truncate(time.Second) - start := now.Add(-time.Hour) sessionID := uuid.NewString() service := &runtimeHistoryFixture{capabilities: historyCapabilities()} handler, _, tenant := adminTestHandler(t, historyWith(service)) - base := runtimehistory.Response{ - Capabilities: service.capabilities, - Scope: runtimehistory.Scope{ - TenantID: tenant, SessionID: sessionID, EnvironmentID: uuid.NewString(), - }, - Requested: runtimehistory.Range{Start: start, End: now, MaxPoints: 60}, - Resolution: time.Minute, GeneratedAt: now, - } - path := adminSessionsPath + sessionID + "/runtime-history?start=" + timeString(start) + "&end=" + timeString(now) + "&max_points=60" + path := adminSessionsPath + sessionID + "/runtime-history" for name, mutate := range map[string]func(*runtimehistory.Response){ "tenant": func(value *runtimehistory.Response) { value.TenantID = uuid.NewString() }, @@ -158,8 +166,17 @@ func TestRuntimeHistoryRejectsMismatchedServiceResponses(t *testing.T) { }, } { t.Run(name, func(t *testing.T) { - service.response = base - mutate(&service.response) + service.respond = func(requested runtimehistory.Range) runtimehistory.Response { + value := runtimehistory.Response{ + Capabilities: service.capabilities, + Scope: runtimehistory.Scope{ + TenantID: tenant, SessionID: sessionID, EnvironmentID: uuid.NewString(), + }, + Requested: requested, Resolution: 30 * time.Second, GeneratedAt: requested.End, + } + mutate(&value) + return value + } response := runtimeObservationRequest(handler, path) if response.Code != http.StatusServiceUnavailable { t.Fatalf("mismatched %s response returned %d: %s", name, response.Code, response.Body) @@ -173,8 +190,7 @@ func TestRuntimeHistoryDefaultPointBudgetRespectsCapabilities(t *testing.T) { capabilities.MaximumPoints = 60 service := &runtimeHistoryFixture{capabilities: capabilities, err: runtimehistory.ErrUnavailable} handler, _, _ := adminTestHandler(t, historyWith(service)) - now := time.Now().UTC().Truncate(time.Second) - response := runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-history?start="+timeString(now.Add(-time.Hour))+"&end="+timeString(now)) + response := runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-history") if response.Code != http.StatusServiceUnavailable || service.calls != 1 || service.requested.MaxPoints != 60 { t.Fatalf("default point budget ignored capabilities: status=%d calls=%d requested=%+v", response.Code, service.calls, service.requested) } @@ -202,5 +218,3 @@ func TestRuntimeHistoryExpectedCoverageUsesOverflowSafeCeiling(t *testing.T) { t.Fatalf("unsafe expected sample ceiling: count=%d err=%v", response.Coverage.ExpectedSampleCount, err) } } - -func timeString(value time.Time) string { return fmt.Sprintf("%d", value.Unix()) } diff --git a/services/core/internal/api/sandbox_node_detail.go b/services/core/internal/api/sandbox_node_detail.go index 28c3ddb01..4c124000c 100644 --- a/services/core/internal/api/sandbox_node_detail.go +++ b/services/core/internal/api/sandbox_node_detail.go @@ -3,7 +3,6 @@ package api import ( "context" "net/http" - "net/url" "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" @@ -21,15 +20,11 @@ import ( // @Failure 400,401,404,500,503 {object} CoreErrorResponse // @Router /core/v1/sandbox/nodes/{node_id} [get] func (h *Handler) sandboxNodeDetail(w http.ResponseWriter, r *http.Request) { - values, err := url.ParseQuery(r.URL.RawQuery) - if err != nil || len(values) > 1 || len(values) == 1 && len(values["range"]) != 1 { + name, ok := rangeName(r) + if !ok { writeDeploymentError(w, r, deployment.ErrInvalidInput) return } - name := "1h" - if v, ok := values["range"]; ok { - name = v[0] - } ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() value, err := h.Sandboxes.Deployment.NodeDetail(ctx, chi.URLParam(r, "node_id"), name) diff --git a/services/core/internal/runtimehistory/types.go b/services/core/internal/runtimehistory/types.go index f7a0a48d3..d6288e530 100644 --- a/services/core/internal/runtimehistory/types.go +++ b/services/core/internal/runtimehistory/types.go @@ -106,6 +106,15 @@ type Range struct { MaxPoints int } +// Window is the range of duration that ends at the last whole multiple of the +// bucket width, in Unix seconds, at or before now on Core's clock, so reads +// within one bucket share their bounds. +func (c Capabilities) Window(now time.Time, duration time.Duration, maxPoints int) Range { + step := int64(resolution(duration, maxPoints, c.MinimumStep) / time.Second) + end := time.Unix(now.Unix()/step*step, 0).UTC() + return Range{Start: end.Add(-duration), End: end, MaxPoints: maxPoints} +} + type Query struct { Scope Start, End time.Time