diff --git a/packages/control-plane/src/router.policy.test.ts b/packages/control-plane/src/router.policy.test.ts index 66aad9bd15..7fcede4d4b 100644 --- a/packages/control-plane/src/router.policy.test.ts +++ b/packages/control-plane/src/router.policy.test.ts @@ -12,11 +12,11 @@ function routeFor(method: string, path: string) { describe("route policy table", () => { it("publishes the complete canonical route catalog", () => { - expect(routes).toHaveLength(171); + expect(routes).toHaveLength(172); const paths = routes.map((route) => route.path); - expect(new Set(paths).size).toBe(130); - expect(new Set(routes.map((route) => `${route.method}:${route.path}`)).size).toBe(171); + expect(new Set(paths).size).toBe(131); + expect(new Set(routes.map((route) => `${route.method}:${route.path}`)).size).toBe(172); for (const route of routes) { expect(route.pattern.source).toBe(parsePattern(route.path).source); diff --git a/packages/control-plane/src/routes/session-runtime-proxy.test.ts b/packages/control-plane/src/routes/session-runtime-proxy.test.ts index 9ecb62ae29..3039cb6ca5 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.test.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { SessionInternalPaths } from "../session/contracts"; import type { PermissionId } from "@open-inspect/shared/rbac"; import type { RequestContext } from "./shared"; @@ -6,6 +6,7 @@ import type { SqlDatabase } from "../db/sql-database"; import { sessionRuntimeProxyRoutes } from "./session-runtime-proxy"; import type { Env } from "../types"; import { TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support"; +import { SessionIndexStore } from "../db/session-index"; function createCtx( db: SqlDatabase = {} as SqlDatabase, @@ -53,7 +54,63 @@ function getHandler(method: string, path: string) { throw new Error(`No route found for ${method} ${path}`); } +afterEach(() => vi.restoreAllMocks()); + describe("session runtime proxy routes", () => { + it("forwards budget updates with verified user identity", async () => { + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue({ + id: "session-1", + userId: "user-1", + } as Awaited>); + const requests: Request[] = []; + const fetch = vi.fn(async (request: Request) => { + requests.push(request); + return Response.json({ maxSessionCostUsd: 20 }); + }); + const path = "/sessions/session-1/budget"; + const { handler, match, route } = getHandler("PATCH", path); + + const response = await handler( + new Request(`https://test.local${path}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ maxCostUsd: 20 }), + }), + createEnv(fetch), + match, + createCtx({} as SqlDatabase, ["sessions.lifecycle"]) + ); + + expect(response.status).toBe(200); + expect(route.authentication.kind).toBe("user"); + expect(new URL(requests[0].url).pathname).toBe(SessionInternalPaths.budget); + await expect(requests[0].json()).resolves.toEqual({ maxCostUsd: 20 }); + }); + + it("rejects budget updates from a non-owner", async () => { + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue({ + id: "session-1", + userId: "owner-1", + } as Awaited>); + const fetch = vi.fn(async () => Response.json({ maxSessionCostUsd: 20 })); + const path = "/sessions/session-1/budget"; + const { handler, match } = getHandler("PATCH", path); + + const response = await handler( + new Request(`https://test.local${path}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ maxCostUsd: 20 }), + }), + createEnv(fetch), + match, + createCtx({} as SqlDatabase, ["sessions.lifecycle"]) + ); + + expect(response.status).toBe(403); + expect(fetch).not.toHaveBeenCalled(); + }); + it("forwards sandbox access for users", async () => { const requests: Request[] = []; const fetch = vi.fn(async (request: Request) => { diff --git a/packages/control-plane/src/routes/session-runtime-proxy.ts b/packages/control-plane/src/routes/session-runtime-proxy.ts index 39278cb60e..82e7a15def 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.ts @@ -3,6 +3,7 @@ import type { SessionParticipantProfilesResponse, SessionParticipantProfile, } from "@open-inspect/shared/types/sessions"; +import { sessionBudgetUpdateSchema } from "@open-inspect/shared/types/session-api"; import { redactSessionSnapshotSandboxAccess, sessionSnapshotSchema, @@ -315,6 +316,38 @@ function lifecycleProxyRoute( ); } +const budgetProxyRoute = defineRoute( + SCM_AGNOSTIC_HUMAN_USER_ROUTE, + sessionRoute({ + method: "PATCH", + path: "/sessions/:id/budget", + authorization: requirePermission("sessions.lifecycle"), + handler: async (request, _env, match, ctx) => { + const sessionId = getSessionId(match); + if (sessionId instanceof Response) return sessionId; + + const rawBody = await parseJsonBody(request); + if (rawBody instanceof Response) return rawBody; + if (!isObjectBody(rawBody)) return error("Invalid budget request", 400); + + const parsed = sessionBudgetUpdateSchema.safeParse(rawBody); + if (!parsed.success) return error("Invalid budget request", 400); + + const session = await new SessionIndexStore(ctx.db).get(sessionId); + if (!session) return error("Session not found", 404); + if (!ctx.authorization?.userId || session.userId !== ctx.authorization.userId) { + return error("Only the session owner can change the cost limit", 403); + } + + return ctx.sessionRuntime.fetch(sessionId, SessionInternalPaths.budget, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(parsed.data), + }); + }, + }) +); + export const sessionRuntimeProxyRoutes: Route[] = [ simpleProxyRoute({ policy: SCM_AGNOSTIC_HUMAN_USER_ROUTE, @@ -432,4 +465,5 @@ export const sessionRuntimeProxyRoutes: Route[] = [ lifecycleProxyRoute("PATCH", "/sessions/:id/title", SessionInternalPaths.updateTitle), lifecycleProxyRoute("POST", "/sessions/:id/archive", SessionInternalPaths.archive), lifecycleProxyRoute("POST", "/sessions/:id/unarchive", SessionInternalPaths.unarchive), + budgetProxyRoute, ]; diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.test.ts b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts index b5b2c57528..5d30781a77 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts @@ -87,6 +87,10 @@ function createMockSession(overrides: Partial = {}): SessionRow { code_server_enabled: 0, vnc_enabled: 0, total_cost: 0, + max_cost_usd: null, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, sandbox_settings: null, environment_id: null, created_at: Date.now() - 60000, diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.ts b/packages/control-plane/src/sandbox/lifecycle/manager.ts index 1836c1775c..a0a7fb8e8e 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.ts @@ -307,6 +307,7 @@ export interface SandboxLifecycle { export type UnresponsiveSandboxTrigger = | "prompt_dispatch_send_failed" | "stop_send_failed" + | "stop_alarm_failed" | "stop_confirmation_timeout"; export type SandboxAlarmResult = "no_action" | "sandbox_failed" | "sandbox_terminated"; @@ -1462,6 +1463,7 @@ export class SandboxLifecycleManager implements SandboxLifecycle { const closeReason = { prompt_dispatch_send_failed: "Prompt dispatch send failed", stop_send_failed: "Stop command send failed", + stop_alarm_failed: "Stop confirmation alarm failed", stop_confirmation_timeout: "Stop confirmation timed out", }[trigger]; this.wsManager.detachSandboxWebSocket(1011, closeReason); diff --git a/packages/control-plane/src/sandbox/settings.test.ts b/packages/control-plane/src/sandbox/settings.test.ts index 9cbff63f0f..458a850347 100644 --- a/packages/control-plane/src/sandbox/settings.test.ts +++ b/packages/control-plane/src/sandbox/settings.test.ts @@ -126,6 +126,36 @@ describe("normalizeSandboxSettings", () => { ).toEqual({ terminalEnabled: true }); }); + it("accepts valid session cost settings", () => { + expect( + normalizeSandboxSettings({ maxSessionCostUsd: 12.5, costWarningThresholdPct: 99 }) + ).toEqual({ maxSessionCostUsd: 12.5, costWarningThresholdPct: 99 }); + }); + + it.each([ + { maxSessionCostUsd: 0 }, + { maxSessionCostUsd: -1 }, + { maxSessionCostUsd: Number.NaN }, + { costWarningThresholdPct: 0 }, + { costWarningThresholdPct: 99.5 }, + { costWarningThresholdPct: 100 }, + ])("rejects invalid session cost settings %#", (settings) => { + expect(() => normalizeSandboxSettings(settings)).toThrow(SandboxSettingsValidationError); + }); + + it("omits invalid session cost settings while preserving valid siblings", () => { + expect( + normalizeSandboxSettings( + { + maxSessionCostUsd: -1, + costWarningThresholdPct: 100, + terminalEnabled: true, + }, + { invalid: "omit" } + ) + ).toEqual({ terminalEnabled: true }); + }); + it("accepts valid service ports", () => { expect( normalizeSandboxSettings({ codeServerPort: 8081, vncPort: 6081, terminalPort: 7000 }) diff --git a/packages/control-plane/src/sandbox/settings.ts b/packages/control-plane/src/sandbox/settings.ts index e052b2e7af..ec85b4a449 100644 --- a/packages/control-plane/src/sandbox/settings.ts +++ b/packages/control-plane/src/sandbox/settings.ts @@ -150,6 +150,28 @@ export function normalizeSandboxSettings( result.buildTimeoutSeconds = buildTimeoutSeconds; } + const maxSessionCostUsd = normalizePositiveNumberSetting( + settings.maxSessionCostUsd, + "maxSessionCostUsd", + reject + ); + if (maxSessionCostUsd !== undefined) { + result.maxSessionCostUsd = maxSessionCostUsd; + } + + const costWarningThresholdPct = normalizePositiveIntegerSetting( + settings.costWarningThresholdPct, + "costWarningThresholdPct", + reject + ); + if (costWarningThresholdPct !== undefined) { + if (costWarningThresholdPct > 99) { + reject("costWarningThresholdPct must be an integer between 1 and 99"); + } else { + result.costWarningThresholdPct = costWarningThresholdPct; + } + } + checkPortCollisions(result, reject); return result; @@ -271,3 +293,16 @@ function normalizePositiveIntegerSetting( } return value; } + +function normalizePositiveNumberSetting( + value: unknown, + name: string, + reject: (message: string) => false +): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + reject(`${name} must be a positive finite number`); + return undefined; + } + return value; +} diff --git a/packages/control-plane/src/session/alarm/handler.test.ts b/packages/control-plane/src/session/alarm/handler.test.ts index 218d12a0e2..1c80590e7e 100644 --- a/packages/control-plane/src/session/alarm/handler.test.ts +++ b/packages/control-plane/src/session/alarm/handler.test.ts @@ -11,6 +11,8 @@ function createHandler() { }; const messageQueue = { failStuckProcessingMessage: vi.fn<() => Promise>().mockResolvedValue(), + }; + const executionStop = { recoverStopConfirmationTimeout: vi.fn<() => Promise>().mockResolvedValue(), resumeAfterSandboxTermination: vi.fn<() => Promise>().mockResolvedValue(), }; @@ -37,6 +39,7 @@ function createHandler() { const handler = createAlarmHandler({ repository: repository as unknown as MessageRepository, messageQueue, + executionStop, lifecycleManager, terminalMessageProjection, alarmScheduler, @@ -49,6 +52,7 @@ function createHandler() { handler, repository, messageQueue, + executionStop, lifecycleManager, terminalMessageProjection, alarmScheduler, @@ -59,8 +63,15 @@ function createHandler() { describe("createAlarmHandler", () => { it("delegates to lifecycle manager when no processing message exists", async () => { - const { handler, repository, messageQueue, lifecycleManager, alarmScheduler, now } = - createHandler(); + const { + handler, + repository, + messageQueue, + executionStop, + lifecycleManager, + alarmScheduler, + now, + } = createHandler(); repository.getProcessingMessageWithStartedAt.mockReturnValue(null); await handler.handle(); @@ -68,19 +79,19 @@ describe("createAlarmHandler", () => { expect(now).not.toHaveBeenCalled(); expect(alarmScheduler.schedule).not.toHaveBeenCalled(); expect(messageQueue.failStuckProcessingMessage).not.toHaveBeenCalled(); - expect(messageQueue.recoverStopConfirmationTimeout).toHaveBeenCalledOnce(); + expect(executionStop.recoverStopConfirmationTimeout).toHaveBeenCalledOnce(); expect(lifecycleManager.handleAlarm).toHaveBeenCalledTimes(1); }); it("retries a deferred terminal message projection before anything else", async () => { - const { handler, repository, messageQueue, terminalMessageProjection } = createHandler(); + const { handler, repository, executionStop, terminalMessageProjection } = createHandler(); repository.getProcessingMessageWithStartedAt.mockReturnValue(null); await handler.handle(); expect(terminalMessageProjection.flushPending).toHaveBeenCalledOnce(); expect(terminalMessageProjection.flushPending.mock.invocationCallOrder[0]).toBeLessThan( - messageQueue.recoverStopConfirmationTimeout.mock.invocationCallOrder[0] + executionStop.recoverStopConfirmationTimeout.mock.invocationCallOrder[0] ); }); @@ -116,6 +127,7 @@ describe("createAlarmHandler", () => { earliest: vi.fn(() => null), cancelled: vi.fn(() => false), setPending: vi.fn(), + setPendingEarliest: vi.fn(), activate: vi.fn(), clear: vi.fn(), beginDelivery: vi.fn(() => null), @@ -135,6 +147,8 @@ describe("createAlarmHandler", () => { }; const messageQueue = { failStuckProcessingMessage: vi.fn<() => Promise>().mockResolvedValue(), + }; + const executionStop = { recoverStopConfirmationTimeout: vi.fn<() => Promise>().mockResolvedValue(), resumeAfterSandboxTermination: vi.fn<() => Promise>().mockResolvedValue(), }; @@ -142,6 +156,7 @@ describe("createAlarmHandler", () => { const handler = createAlarmHandler({ repository: repository as unknown as MessageRepository, messageQueue, + executionStop, lifecycleManager, terminalMessageProjection: { flushPending: vi.fn(async () => {}) }, alarmScheduler, @@ -179,24 +194,24 @@ describe("createAlarmHandler", () => { }); it("fails stuck work without resuming after a connecting timeout", async () => { - const { handler, repository, messageQueue, lifecycleManager } = createHandler(); + const { handler, repository, messageQueue, executionStop, lifecycleManager } = createHandler(); repository.getProcessingMessageWithStartedAt.mockReturnValue(null); lifecycleManager.handleAlarm.mockResolvedValue("sandbox_failed"); await handler.handle(); expect(messageQueue.failStuckProcessingMessage).toHaveBeenCalledOnce(); - expect(messageQueue.resumeAfterSandboxTermination).not.toHaveBeenCalled(); + expect(executionStop.resumeAfterSandboxTermination).not.toHaveBeenCalled(); }); it("fails stuck work and resumes after lifecycle termination", async () => { - const { handler, repository, messageQueue, lifecycleManager } = createHandler(); + const { handler, repository, messageQueue, executionStop, lifecycleManager } = createHandler(); repository.getProcessingMessageWithStartedAt.mockReturnValue(null); lifecycleManager.handleAlarm.mockResolvedValue("sandbox_terminated"); await handler.handle(); expect(messageQueue.failStuckProcessingMessage).toHaveBeenCalledOnce(); - expect(messageQueue.resumeAfterSandboxTermination).toHaveBeenCalledOnce(); + expect(executionStop.resumeAfterSandboxTermination).toHaveBeenCalledOnce(); }); }); diff --git a/packages/control-plane/src/session/alarm/handler.ts b/packages/control-plane/src/session/alarm/handler.ts index 9537704efe..582c8a7abb 100644 --- a/packages/control-plane/src/session/alarm/handler.ts +++ b/packages/control-plane/src/session/alarm/handler.ts @@ -3,16 +3,16 @@ import { evaluateExecutionTimeout } from "../../sandbox/lifecycle/decisions"; import type { SandboxLifecycleManager } from "../../sandbox/lifecycle/manager"; import type { AlarmScheduler } from "../../platform-ports"; import type { SessionMessageQueue } from "../message-queue"; +import type { ExecutionStopCoordinator } from "../execution-stop-coordinator"; import type { MessageRepository } from "../message-repository"; import type { SessionTerminalMessageProjection } from "../terminal-message-projection"; export interface AlarmHandlerDeps { repository: MessageRepository; - messageQueue: Pick< - SessionMessageQueue, - | "failStuckProcessingMessage" - | "recoverStopConfirmationTimeout" - | "resumeAfterSandboxTermination" + messageQueue: Pick; + executionStop: Pick< + ExecutionStopCoordinator, + "recoverStopConfirmationTimeout" | "resumeAfterSandboxTermination" >; lifecycleManager: Pick; terminalMessageProjection: Pick; @@ -39,7 +39,7 @@ export function createAlarmHandler(deps: AlarmHandlerDeps): AlarmHandler { return { async handle(): Promise { await deps.terminalMessageProjection.flushPending(); - await deps.messageQueue.recoverStopConfirmationTimeout(); + await deps.executionStop.recoverStopConfirmationTimeout(); // Execution timeout check: if a message has been in 'processing' longer than // the configured timeout, fail it. This is idempotent - if the message was // already failed (by lifecycle recovery or a prior alarm), @@ -74,7 +74,7 @@ export function createAlarmHandler(deps: AlarmHandlerDeps): AlarmHandler { await deps.messageQueue.failStuckProcessingMessage(); } if (lifecycleResult === "sandbox_terminated") { - await deps.messageQueue.resumeAfterSandboxTermination(); + await deps.executionStop.resumeAfterSandboxTermination(); } }, }; diff --git a/packages/control-plane/src/session/alarm/scheduler.test.ts b/packages/control-plane/src/session/alarm/scheduler.test.ts index 9c8747866f..4bc638398e 100644 --- a/packages/control-plane/src/session/alarm/scheduler.test.ts +++ b/packages/control-plane/src/session/alarm/scheduler.test.ts @@ -41,6 +41,9 @@ function createDeadlineStore( setPending: vi.fn((value: number) => { pending = value; }), + setPendingEarliest: vi.fn((value: number) => { + pending = pending === null ? value : Math.min(pending, value); + }), activate: vi.fn(() => { cancelled = false; }), @@ -357,6 +360,11 @@ describe("PersistedAlarmDeadlineStore", () => { initSchema(sql); const deadlines = new PersistedAlarmDeadlineStore(sql); + deadlines.setPending(2_000); + deadlines.setPendingEarliest(3_000); + expect(deadlines.pending()).toBe(2_000); + deadlines.setPendingEarliest(1_000); + expect(deadlines.pending()).toBe(1_000); deadlines.setPending(2_000); expect(deadlines.beginDelivery()).toBe(2_000); deadlines.setPending(3_000); diff --git a/packages/control-plane/src/session/alarm/scheduler.ts b/packages/control-plane/src/session/alarm/scheduler.ts index 52f25e4e04..ca66a43e45 100644 --- a/packages/control-plane/src/session/alarm/scheduler.ts +++ b/packages/control-plane/src/session/alarm/scheduler.ts @@ -13,6 +13,7 @@ export interface AlarmDeadlineStore { earliest(): number | null; cancelled(): boolean; setPending(deadline: number): void; + setPendingEarliest(deadline: number): void; activate(): void; clear(): void; beginDelivery(): number | "cancelled" | null; @@ -53,6 +54,18 @@ export class PersistedAlarmDeadlineStore implements AlarmDeadlineStore { ); } + setPendingEarliest(deadline: number): void { + this.sql.exec( + `INSERT INTO session_alarm_state (singleton, pending_deadline) VALUES (1, ?) + ON CONFLICT(singleton) DO UPDATE SET pending_deadline = + CASE + WHEN session_alarm_state.pending_deadline IS NULL THEN excluded.pending_deadline + ELSE MIN(session_alarm_state.pending_deadline, excluded.pending_deadline) + END`, + deadline + ); + } + activate(): void { this.sql.exec("UPDATE session_alarm_state SET cancelled = 0 WHERE singleton = 1"); } diff --git a/packages/control-plane/src/session/budget-service.test.ts b/packages/control-plane/src/session/budget-service.test.ts new file mode 100644 index 0000000000..5e5ed50a66 --- /dev/null +++ b/packages/control-plane/src/session/budget-service.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it, vi } from "vitest"; +import { SessionBudgetService } from "./budget-service"; +import type { EventRepository } from "./event-repository"; +import type { SessionMessenger } from "./messenger"; +import type { SessionCoreRepository } from "./session-core-repository"; +import type { SessionRow } from "./types"; +import type { ExecutionStopPreparation } from "./execution-stop-coordinator"; + +function session(overrides: Partial = {}): SessionRow { + return { + id: "session-1", + session_name: "public-1", + title: null, + repo_owner: null, + repo_name: null, + repo_id: null, + base_branch: null, + branch_name: null, + base_sha: null, + current_sha: null, + opencode_session_id: null, + model: "anthropic/claude-sonnet-4-6", + reasoning_effort: null, + status: "active", + parent_session_id: null, + spawn_source: "user", + spawn_depth: 0, + code_server_enabled: 0, + vnc_enabled: 0, + total_cost: 8, + sandbox_settings: JSON.stringify({ costWarningThresholdPct: 80 }), + max_cost_usd: 10, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, + environment_id: null, + created_at: 1, + updated_at: 1, + ...overrides, + }; +} + +function createService(row = session()) { + let current = row; + const repository = { + getSession: vi.fn(() => current), + transaction: vi.fn((closure: () => void) => closure()), + addSessionCost: vi.fn((cost: number) => { + current = { ...current, total_cost: current.total_cost + cost }; + return current.total_cost; + }), + markCostWarningSent: vi.fn(() => { + current = { ...current, cost_warning_sent: 1 }; + }), + markCostTrackingUnavailable: vi.fn(() => { + current = { ...current, cost_tracking_unavailable: 1 }; + }), + markBudgetExhausted: vi.fn(() => { + current = { ...current, budget_exhausted: 1 }; + }), + setSessionBudget: vi.fn( + (maxCostUsd: number | null, state: { warningSent: boolean; exhausted: boolean }) => { + current = { + ...current, + max_cost_usd: maxCostUsd, + cost_warning_sent: state.warningSent ? 1 : 0, + budget_exhausted: state.exhausted ? 1 : 0, + }; + } + ), + }; + const eventRepository = { + createEvent: vi.fn(), + }; + const reportedCosts = new Map(); + const messageRepository = { + raiseReportedCost: vi.fn((messageId: string, reported: number) => { + const previous = reportedCosts.get(messageId) ?? 0; + if (reported <= previous) return 0; + reportedCosts.set(messageId, reported); + return reported - previous; + }), + }; + const broadcast = vi.fn(); + const preparation = { stopped: true } as ExecutionStopPreparation; + const prepareBudgetStop = vi.fn(() => preparation); + const deliverBudgetStop = vi.fn(async () => {}); + const processMessageQueue = vi.fn(async () => {}); + const service = new SessionBudgetService( + repository as unknown as SessionCoreRepository, + messageRepository, + eventRepository as unknown as EventRepository, + { broadcast } as unknown as SessionMessenger, + { prepare: prepareBudgetStop, deliver: deliverBudgetStop }, + processMessageQueue, + () => "budget-event-1" + ); + return { + service, + repository, + messageRepository, + eventRepository, + broadcast, + prepareBudgetStop, + deliverBudgetStop, + processMessageQueue, + }; +} + +describe("SessionBudgetService", () => { + it("applies a cumulative report once and repairs a dropped one", async () => { + const h = createService(session({ total_cost: 0, max_cost_usd: 100 })); + const event = { + type: "step_finish" as const, + messageId: "message-1", + sandboxId: "sandbox-1", + timestamp: 1, + cost: 1, + messageCostUsd: 1, + }; + + await h.service.ingestStepFinish(event, "message-1", 1000); + await h.service.ingestStepFinish(event, "message-1", 1001); + // The report for the second step was lost; the third carries both. + await h.service.ingestStepFinish( + { ...event, cost: 0.5, messageCostUsd: 2.5 }, + "message-1", + 1002 + ); + + expect(h.repository.addSessionCost).toHaveBeenCalledTimes(2); + expect(h.repository.addSessionCost).toHaveBeenNthCalledWith(1, 1, 1000); + expect(h.repository.addSessionCost).toHaveBeenNthCalledWith(2, 1.5, 1002); + expect(h.repository.transaction).toHaveBeenCalledTimes(3); + }); + + it("attributes cost to the context message when the event names another", async () => { + const h = createService(session({ total_cost: 0, max_cost_usd: 100 })); + + await h.service.ingestStepFinish( + { + type: "step_finish", + messageId: "message-1", + sandboxId: "sandbox-1", + timestamp: 1, + cost: 2, + messageCostUsd: 2, + }, + "message-2", + 1000 + ); + + expect(h.messageRepository.raiseReportedCost).toHaveBeenCalledWith("message-2", 2); + }); + + it("adds a legacy per-step cost directly when no cumulative report is present", async () => { + const h = createService(session({ total_cost: 0, max_cost_usd: 100 })); + const event = { + type: "step_finish" as const, + messageId: "message-1", + sandboxId: "sandbox-1", + timestamp: 1, + cost: 1, + }; + + await h.service.ingestStepFinish(event, "message-1", 1000); + await h.service.ingestStepFinish(event, "message-1", 1001); + + expect(h.messageRepository.raiseReportedCost).not.toHaveBeenCalled(); + expect(h.repository.addSessionCost).toHaveBeenCalledTimes(2); + }); + + it("applies the final report on execution_complete and pauses without a stop", async () => { + const h = createService(session({ total_cost: 9 })); + h.prepareBudgetStop.mockReturnValueOnce({ stopped: false } as ExecutionStopPreparation); + + await h.service.ingestExecutionComplete( + { + type: "execution_complete", + messageId: "message-1", + sandboxId: "sandbox-1", + timestamp: 1, + success: true, + messageCostUsd: 1.5, + }, + 1000 + ); + + expect(h.repository.addSessionCost).toHaveBeenCalledWith(1.5, 1000); + expect(h.repository.markBudgetExhausted).toHaveBeenCalledWith(1000); + expect(h.eventRepository.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.stringContaining("Work paused") }) + ); + expect(h.deliverBudgetStop).toHaveBeenCalledOnce(); + }); + + it("ignores execution_complete without a cumulative report", async () => { + const h = createService(); + + await h.service.ingestExecutionComplete( + { + type: "execution_complete", + messageId: "message-1", + sandboxId: "sandbox-1", + timestamp: 1, + success: true, + }, + 1000 + ); + + expect(h.repository.transaction).not.toHaveBeenCalled(); + expect(h.repository.addSessionCost).not.toHaveBeenCalled(); + }); + + it("persists and broadcasts one threshold warning", async () => { + const h = createService(session({ total_cost: 7 })); + + await h.service.ingestStepFinish( + { + type: "step_finish", + messageId: "message-1", + sandboxId: "sandbox-1", + timestamp: 1, + cost: 1, + }, + "message-1", + 1000 + ); + + expect(h.repository.transaction).toHaveBeenCalledOnce(); + expect(h.repository.markCostWarningSent).toHaveBeenCalledWith(1000); + expect(h.eventRepository.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ id: "budget-event-1", type: "warning", messageId: "message-1" }) + ); + expect(h.broadcast).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sandbox_event", + event: expect.objectContaining({ scope: "budget" }), + }) + ); + expect(h.broadcast).toHaveBeenCalledWith( + expect.objectContaining({ type: "budget_status", totalCost: 8, budgetExhausted: false }) + ); + }); + + it("establishes exhaustion through the budget stop path", async () => { + const h = createService(session({ total_cost: 9.25 })); + + await h.service.ingestStepFinish( + { + type: "step_finish", + messageId: "message-1", + sandboxId: "sandbox-1", + timestamp: 1, + cost: 1, + }, + "message-1", + 1000 + ); + + expect(h.prepareBudgetStop).toHaveBeenCalledOnce(); + expect(h.deliverBudgetStop).toHaveBeenCalledOnce(); + expect(h.repository.markBudgetExhausted).toHaveBeenCalledWith(1000); + expect(h.eventRepository.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.stringContaining("Execution stopped") }) + ); + expect(h.broadcast).toHaveBeenCalledWith( + expect.objectContaining({ type: "budget_status", totalCost: 10.25, budgetExhausted: true }) + ); + }); + + it("latches omitted cost only for positive token usage", async () => { + const h = createService(); + + const event = { + type: "step_finish" as const, + messageId: "message-1", + sandboxId: "sandbox-1", + timestamp: 1, + tokens: { input: 1 }, + }; + await h.service.ingestStepFinish(event, "message-1", 1000); + await h.service.ingestStepFinish(event, "message-1", 1001); + + expect(h.repository.markCostTrackingUnavailable).toHaveBeenCalledOnce(); + expect(h.eventRepository.createEvent).toHaveBeenCalledOnce(); + expect(h.broadcast).toHaveBeenCalledWith( + expect.objectContaining({ type: "budget_status", costTrackingUnavailable: true }) + ); + }); + + it("treats a reported cost of zero as observed, not untracked", async () => { + const h = createService(); + + await h.service.ingestStepFinish( + { + type: "step_finish", + messageId: "message-1", + sandboxId: "sandbox-1", + timestamp: 1, + cost: 0, + tokens: { input: 500, output: 200 }, + }, + "message-1", + 1000 + ); + + expect(h.repository.addSessionCost).not.toHaveBeenCalled(); + expect(h.repository.markCostTrackingUnavailable).not.toHaveBeenCalled(); + expect(h.repository.markBudgetExhausted).not.toHaveBeenCalled(); + expect(h.eventRepository.createEvent).not.toHaveBeenCalled(); + expect(h.broadcast).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "budget_status" }) + ); + }); + + it("updates the live limit and resumes queued work when permitted", async () => { + const h = createService(session({ max_cost_usd: 10, budget_exhausted: 1, total_cost: 10 })); + + await h.service.updateLimit(20, 1000); + + expect(h.repository.setSessionBudget).toHaveBeenCalledWith( + 20, + { warningSent: false, exhausted: false }, + 1000 + ); + expect(h.processMessageQueue).toHaveBeenCalledOnce(); + expect(h.prepareBudgetStop).not.toHaveBeenCalled(); + }); + + it("evaluates a lower live limit immediately", async () => { + const h = createService(session({ max_cost_usd: 20, total_cost: 8 })); + + await h.service.updateLimit(9, 1000); + + expect(h.repository.setSessionBudget).toHaveBeenCalledWith( + 9, + { warningSent: true, exhausted: false }, + 1000 + ); + expect(h.eventRepository.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.stringContaining("reached 80%") }) + ); + }); + + it("treats an unchanged live limit as an idempotent no-op", async () => { + const h = createService(session({ max_cost_usd: 10 })); + + await h.service.updateLimit(10, 1000); + + expect(h.repository.setSessionBudget).not.toHaveBeenCalled(); + expect(h.broadcast).not.toHaveBeenCalled(); + }); + + it("updates an exhausted limit without repeating stop effects", async () => { + const h = createService(session({ max_cost_usd: 10, budget_exhausted: 1, total_cost: 12 })); + + await h.service.updateLimit(11, 1000); + + expect(h.repository.setSessionBudget).toHaveBeenCalledWith( + 11, + { warningSent: false, exhausted: true }, + 1000 + ); + expect(h.prepareBudgetStop).not.toHaveBeenCalled(); + expect(h.eventRepository.createEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/session/budget-service.ts b/packages/control-plane/src/session/budget-service.ts new file mode 100644 index 0000000000..40370b28b7 --- /dev/null +++ b/packages/control-plane/src/session/budget-service.ts @@ -0,0 +1,283 @@ +import { DEFAULT_COST_WARNING_THRESHOLD_PCT } from "@open-inspect/shared/types/integrations"; +import type { SandboxEvent } from "@open-inspect/shared/types/sandbox-events"; +import { parsePersistedSandboxSettings } from "../sandbox/settings"; +import { evaluateBudget, hasPositiveTokenUsage } from "./budget"; +import type { EventRepository } from "./event-repository"; +import type { + ExecutionStopCoordinator, + ExecutionStopPreparation, +} from "./execution-stop-coordinator"; +import type { MessageRepository } from "./message-repository"; +import type { SessionMessenger } from "./messenger"; +import type { SessionCoreRepository } from "./session-core-repository"; +import type { SessionRow } from "./types"; + +interface BudgetTransition { + warningEvent: Extract | null; + stopPreparation: ExecutionStopPreparation | null; + statusChanged: boolean; +} + +const NO_BUDGET_TRANSITION: BudgetTransition = { + warningEvent: null, + stopPreparation: null, + statusChanged: false, +}; + +type StepFinishEvent = Extract; +type ExecutionCompleteEvent = Extract; + +/** + * Cost accounting is idempotent by construction. The runtime reports the + * cumulative cost of the current turn (`messageCostUsd`) on every step and on + * `execution_complete`; the session total only ever moves by the amount that + * report exceeds the highest one already recorded for the message. A resent + * event therefore adds nothing and a dropped one is repaired by the next. + * + * Runtimes that predate the cumulative field still report a per-step `cost`, + * which is added directly; that path undercounts on a dropped event. + */ +export class SessionBudgetService { + constructor( + private readonly repository: SessionCoreRepository, + private readonly messageRepository: Pick, + private readonly eventRepository: EventRepository, + private readonly messenger: SessionMessenger, + private readonly executionStop: Pick, + private readonly processMessageQueue: () => Promise, + private readonly generateId: () => string + ) {} + + async ingestStepFinish( + event: StepFinishEvent, + messageId: string | null, + now: number + ): Promise { + let transition = NO_BUDGET_TRANSITION; + this.repository.transaction(() => { + const delta = this.observeReportedCost(event, messageId); + if (delta > 0) { + const totalCost = this.repository.addSessionCost(delta, now); + transition = this.applyObservedCost(totalCost, messageId, now); + } else if (event.cost == null) { + // A reported cost of 0 (unpriced or free models) is a real observation + // and never latches the warning. Only an absent cost is "not tracked". + transition = this.applyCostTrackingUnavailable(event.tokens, messageId, now); + } + }); + await this.deliverTransition(transition); + } + + async ingestExecutionComplete(event: ExecutionCompleteEvent, now: number): Promise { + if (typeof event.messageCostUsd !== "number") return; + let transition = NO_BUDGET_TRANSITION; + this.repository.transaction(() => { + const delta = this.messageRepository.raiseReportedCost( + event.messageId, + event.messageCostUsd as number + ); + if (delta <= 0) return; + const totalCost = this.repository.addSessionCost(delta, now); + transition = this.applyObservedCost(totalCost, event.messageId, now); + }); + await this.deliverTransition(transition); + } + + async updateLimit(maxCostUsd: number | null, now: number): Promise { + const session = this.repository.getSession(); + if (!session || Object.is(session.max_cost_usd, maxCostUsd)) return; + + const remainsExhausted = + session.budget_exhausted === 1 && maxCostUsd !== null && session.total_cost >= maxCostUsd; + if (remainsExhausted) { + this.repository.setSessionBudget(maxCostUsd, { warningSent: false, exhausted: true }, now); + this.broadcastStatus(); + return; + } + + const action = evaluateBudget({ + totalCost: session.total_cost, + maxCostUsd, + warningThresholdPct: this.warningThreshold(session), + warningSent: false, + exhausted: false, + }); + let warningEvent: Extract | null = null; + + if (action === "exhaust" && maxCostUsd !== null) { + const reason = `Session cost limit reached: ${formatCost(session.total_cost)} of ${formatCost(maxCostUsd)}`; + let preparation!: ExecutionStopPreparation; + let exhaustionEvent!: Extract; + this.repository.transaction(() => { + preparation = this.executionStop.prepare(reason, now); + this.repository.setSessionBudget(maxCostUsd, { warningSent: false, exhausted: true }, now); + exhaustionEvent = this.persistWarning( + `${reason}. ${preparation.stopped ? "Execution stopped." : "Work paused."}`, + null, + now + ); + }); + this.messenger.broadcast({ type: "sandbox_event", event: exhaustionEvent }); + this.broadcastStatus(); + await this.executionStop.deliver(preparation); + return; + } else { + this.repository.transaction(() => { + this.repository.setSessionBudget( + maxCostUsd, + { warningSent: action === "warn", exhausted: false }, + now + ); + if (action === "warn" && maxCostUsd !== null) { + warningEvent = this.persistWarning( + `Session cost ${formatCost(session.total_cost)} reached ${this.warningThreshold(session)}% of the ${formatCost(maxCostUsd)} limit.`, + null, + now + ); + } + }); + } + + if (warningEvent) { + this.messenger.broadcast({ type: "sandbox_event", event: warningEvent }); + } + this.broadcastStatus(); + await this.processMessageQueue(); + } + + broadcastStatus(): void { + const session = this.repository.getSession(); + if (!session) return; + this.messenger.broadcast({ + type: "budget_status", + totalCost: session.total_cost, + maxSessionCostUsd: session.max_cost_usd, + budgetExhausted: session.budget_exhausted === 1, + costTrackingUnavailable: session.cost_tracking_unavailable === 1, + }); + } + + /** Amount the session total should grow by for this step; 0 for resends. */ + private observeReportedCost(event: StepFinishEvent, messageId: string | null): number { + if (typeof event.messageCostUsd === "number" && Number.isFinite(event.messageCostUsd)) { + const target = messageId ?? event.messageId; + return this.messageRepository.raiseReportedCost(target, event.messageCostUsd); + } + if (typeof event.cost === "number" && Number.isFinite(event.cost) && event.cost > 0) { + return event.cost; + } + return 0; + } + + private applyObservedCost( + totalCost: number, + messageId: string | null, + now: number + ): BudgetTransition { + const session = this.repository.getSession(); + if (!session || session.max_cost_usd === null) return NO_BUDGET_TRANSITION; + const limit = session.max_cost_usd; + const threshold = this.warningThreshold(session); + const action = evaluateBudget({ + totalCost, + maxCostUsd: limit, + warningThresholdPct: threshold, + warningSent: session.cost_warning_sent === 1, + exhausted: session.budget_exhausted === 1, + }); + if (action === "none") return NO_BUDGET_TRANSITION; + + if (action === "warn") { + this.repository.markCostWarningSent(now); + return { + warningEvent: this.persistWarning( + `Session cost ${formatCost(totalCost)} reached ${threshold}% of the ${formatCost(limit)} limit.`, + messageId, + now + ), + stopPreparation: null, + statusChanged: true, + }; + } + + const reason = `Session cost limit reached: ${formatCost(totalCost)} of ${formatCost(limit)}`; + const stopPreparation = this.executionStop.prepare(reason, now); + this.repository.markBudgetExhausted(now); + return { + warningEvent: this.persistWarning( + `${reason}. ${stopPreparation.stopped ? "Execution stopped." : "Work paused."}`, + messageId, + now + ), + stopPreparation, + statusChanged: true, + }; + } + + private applyCostTrackingUnavailable( + tokens: unknown, + messageId: string | null, + now: number + ): BudgetTransition { + const session = this.repository.getSession(); + if (!session || session.cost_tracking_unavailable === 1 || !hasPositiveTokenUsage(tokens)) { + return NO_BUDGET_TRANSITION; + } + this.repository.markCostTrackingUnavailable(now); + return { + warningEvent: this.persistWarning( + "Cost tracking was unavailable for a positive-token step; the session cost limit may be incomplete.", + messageId, + now + ), + stopPreparation: null, + statusChanged: true, + }; + } + + private async deliverTransition(transition: BudgetTransition): Promise { + if (transition.warningEvent) { + this.messenger.broadcast({ type: "sandbox_event", event: transition.warningEvent }); + } + if (transition.statusChanged) this.broadcastStatus(); + if (transition.stopPreparation) { + await this.executionStop.deliver(transition.stopPreparation); + } + } + + private warningThreshold(session: SessionRow): number { + try { + return ( + parsePersistedSandboxSettings(session.sandbox_settings).costWarningThresholdPct ?? + DEFAULT_COST_WARNING_THRESHOLD_PCT + ); + } catch { + return DEFAULT_COST_WARNING_THRESHOLD_PCT; + } + } + + private persistWarning( + message: string, + messageId: string | null, + now: number + ): Extract { + const event: Extract = { + type: "warning", + scope: "budget", + message, + timestamp: now / 1000, + }; + this.eventRepository.createEvent({ + id: this.generateId(), + type: "warning", + data: JSON.stringify(event), + messageId, + createdAt: now, + }); + return event; + } +} + +function formatCost(value: number): string { + return `$${value.toFixed(2)}`; +} diff --git a/packages/control-plane/src/session/budget.test.ts b/packages/control-plane/src/session/budget.test.ts new file mode 100644 index 0000000000..d7c1085695 --- /dev/null +++ b/packages/control-plane/src/session/budget.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { evaluateBudget, hasPositiveTokenUsage } from "./budget"; + +describe("evaluateBudget", () => { + it("does nothing without a limit or below the warning threshold", () => { + expect( + evaluateBudget({ + totalCost: 100, + maxCostUsd: null, + warningThresholdPct: 80, + warningSent: false, + exhausted: false, + }) + ).toBe("none"); + expect( + evaluateBudget({ + totalCost: 7.99, + maxCostUsd: 10, + warningThresholdPct: 80, + warningSent: false, + exhausted: false, + }) + ).toBe("none"); + }); + + it("warns once at the configured threshold", () => { + expect( + evaluateBudget({ + totalCost: 8, + maxCostUsd: 10, + warningThresholdPct: 80, + warningSent: false, + exhausted: false, + }) + ).toBe("warn"); + expect( + evaluateBudget({ + totalCost: 9, + maxCostUsd: 10, + warningThresholdPct: 80, + warningSent: true, + exhausted: false, + }) + ).toBe("none"); + }); + + it("exhausts directly at the limit and does not repeat", () => { + expect( + evaluateBudget({ + totalCost: 12, + maxCostUsd: 10, + warningThresholdPct: 80, + warningSent: false, + exhausted: false, + }) + ).toBe("exhaust"); + expect( + evaluateBudget({ + totalCost: 13, + maxCostUsd: 10, + warningThresholdPct: 80, + warningSent: false, + exhausted: true, + }) + ).toBe("none"); + }); +}); + +describe("hasPositiveTokenUsage", () => { + it.each([ + { total: 1 }, + { input: 1 }, + { output: 1 }, + { reasoning: 1 }, + { cache: { read: 1 } }, + { cache: { write: 1 } }, + ])("recognizes positive token usage %#", (tokens) => { + expect(hasPositiveTokenUsage(tokens)).toBe(true); + }); + + it.each([undefined, 1, 0, -1, {}, { input: 0 }, { cache: { read: 0, write: 0 } }])( + "rejects non-positive token usage %#", + (tokens) => { + expect(hasPositiveTokenUsage(tokens)).toBe(false); + } + ); +}); diff --git a/packages/control-plane/src/session/budget.ts b/packages/control-plane/src/session/budget.ts new file mode 100644 index 0000000000..979ec66d02 --- /dev/null +++ b/packages/control-plane/src/session/budget.ts @@ -0,0 +1,45 @@ +export interface BudgetEvaluationInput { + totalCost: number; + maxCostUsd: number | null; + warningThresholdPct: number; + warningSent: boolean; + exhausted: boolean; +} + +export type BudgetAction = "none" | "warn" | "exhaust"; + +export function evaluateBudget(input: BudgetEvaluationInput): BudgetAction { + if (input.maxCostUsd === null) return "none"; + if (input.totalCost >= input.maxCostUsd) { + return input.exhausted ? "none" : "exhaust"; + } + if ( + !input.warningSent && + !input.exhausted && + input.totalCost >= (input.maxCostUsd * input.warningThresholdPct) / 100 + ) { + return "warn"; + } + return "none"; +} + +export function hasPositiveTokenUsage(tokens: unknown): boolean { + if (!tokens || typeof tokens !== "object" || Array.isArray(tokens)) return false; + + const usage = tokens as Record; + if ([usage.total, usage.input, usage.output, usage.reasoning].some(isPositiveNumber)) return true; + + const cache = usage.cache; + return ( + !!cache && + typeof cache === "object" && + !Array.isArray(cache) && + [(cache as Record).read, (cache as Record).write].some( + isPositiveNumber + ) + ); +} + +function isPositiveNumber(value: unknown): boolean { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} diff --git a/packages/control-plane/src/session/client-command-facade.ts b/packages/control-plane/src/session/client-command-facade.ts index f03aa879ba..32ae5a8646 100644 --- a/packages/control-plane/src/session/client-command-facade.ts +++ b/packages/control-plane/src/session/client-command-facade.ts @@ -26,6 +26,7 @@ export class SessionClientCommandFacade implements SessionClientCommands Promise, private readonly presence: PresenceService, private readonly events: SessionEventStream ) {} @@ -43,7 +44,7 @@ export class SessionClientCommandFacade implements SessionClientCommands { - return this.prompts.stopExecution(); + return this.stop(); } notifyTyping(): Promise { diff --git a/packages/control-plane/src/session/components.ts b/packages/control-plane/src/session/components.ts index 68e4cfaf81..0137a27a4f 100644 --- a/packages/control-plane/src/session/components.ts +++ b/packages/control-plane/src/session/components.ts @@ -83,6 +83,8 @@ import { Scheduler } from "../scheduler/scheduler"; import { createCloudflareBackgroundTasks } from "../cloudflare/background-tasks"; import { PresenceService } from "./presence-service"; import { SessionMessageQueue } from "./message-queue"; +import { SessionBudgetService } from "./budget-service"; +import { ExecutionStopCoordinator } from "./execution-stop-coordinator"; import { SandboxArtifactEventHandler } from "./sandbox-events/artifact.handler"; import { SandboxExecutionEventHandler } from "./sandbox-events/execution.handler"; import { SessionSandboxEventProcessor } from "./sandbox-events/processor"; @@ -101,6 +103,7 @@ import { SandboxHandler } from "./http/handlers/sandbox.handler"; import { AttachmentsHandler } from "./http/handlers/attachments.handler"; import { WsTokenHandler } from "./http/handlers/ws-token.handler"; import { SessionLifecycleHandler } from "./http/handlers/session-lifecycle.handler"; +import { SessionBudgetHandler } from "./http/handlers/session-budget.handler"; import { PullRequestHandler } from "./http/handlers/pull-request.handler"; import { ParticipantsHandler } from "./http/handlers/participants.handler"; import { MessageService } from "./services/message.service"; @@ -400,7 +403,23 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi // Tier 6 — the message queue. const getExecutionTimeoutMs = () => resolveExecutionTimeoutMs(sessionCoreRepository, env, log); - const messageQueue = new SessionMessageQueue( + const executionStop: ExecutionStopCoordinator = new ExecutionStopCoordinator( + backgroundTasks, + log, + sessionCoreRepository, + messageRepository, + wsManager, + messenger, + callbackService, + statusService, + recordTerminalMessage, + lifecycleManager, + alarmScheduler, + alarmDeadlines, + (): void => messageQueue.broadcastPromptQueue(), + (): Promise => messageQueue.processMessageQueue() + ); + const messageQueue: SessionMessageQueue = new SessionMessageQueue( backgroundTasks, log, sessionCoreRepository, @@ -418,6 +437,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi sessionIndexStore, scmProviderName, alarmScheduler, + executionStop, getExecutionTimeoutMs ); @@ -437,19 +457,28 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi eventRepository, artifactRepository, messageQueue, - stopExecution: () => messageQueue.stopExecution(), + stopExecution: () => executionStop.stop(), parseArtifactMetadata: (artifact) => parseArtifactMetadata(artifact, log), }); const autofixHandler = new AutofixHandler(messageQueue); + const budgetService = new SessionBudgetService( + sessionCoreRepository, + messageRepository, + eventRepository, + messenger, + executionStop, + () => messageQueue.processMessageQueue(), + generateId + ); const updateLastActivity = (timestamp: number) => lifecycleManager.updateLastActivity(timestamp); const streamingEventHandler = new SandboxStreamingEventHandler( backgroundTasks, - sessionCoreRepository, eventRepository, callbackService, messenger, - updateLastActivity + updateLastActivity, + budgetService ); const artifactEventHandler = new SandboxArtifactEventHandler( artifactRepository, @@ -469,7 +498,8 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi updateLastActivity, () => lifecycleManager.scheduleInactivityCheck(), () => messageQueue.processMessageQueue(), - () => messageQueue.broadcastPromptQueue() + () => messageQueue.broadcastPromptQueue(), + (event, now) => budgetService.ingestExecutionComplete(event, now) ); const runtimeEventHandler = new SandboxRuntimeEventHandler( sessionCoreRepository, @@ -495,6 +525,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi const alarmHandler = createAlarmHandler({ repository: messageRepository, messageQueue, + executionStop, lifecycleManager, terminalMessageProjection, alarmScheduler, @@ -623,6 +654,9 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi await statusService.cancel(() => messageQueue.cancelExecution()); } ); + const sessionBudgetHandler = new SessionBudgetHandler(sessionCoreRepository, budgetService, () => + Date.now() + ); const prCreationClaims = new PullRequestCreationClaims(); const pullRequestHandler = new PullRequestHandler( @@ -741,6 +775,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi pullRequestsRefresh: () => pullRequestHandler.refreshPullRequests(), wsToken: (request, _url, requestLog) => wsTokenHandler.generateWsToken(request, requestLog), updateTitle: (request) => sessionLifecycleHandler.updateTitle(request), + budget: (request) => sessionBudgetHandler.update(request), archive: () => sessionLifecycleHandler.archive(), unarchive: () => sessionLifecycleHandler.unarchive(), expireDraft: () => sessionLifecycleHandler.expireDraft(), @@ -783,6 +818,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi const clientCommands = new SessionClientCommandFacade( connectionAuthenticator, messageQueue, + () => executionStop.stop(), presenceService, eventStream ); diff --git a/packages/control-plane/src/session/connection-authenticator.ts b/packages/control-plane/src/session/connection-authenticator.ts index 80640ee0b6..c616d88147 100644 --- a/packages/control-plane/src/session/connection-authenticator.ts +++ b/packages/control-plane/src/session/connection-authenticator.ts @@ -26,6 +26,7 @@ import type { SandboxRepository } from "./sandbox-repository"; import type { SessionCoreRepository } from "./session-core-repository"; import type { SessionSnapshotReader } from "./snapshot-reader"; import type { SessionWebSocketManager } from "./websocket-manager"; +import { parseClientCapabilities } from "./ws-client-mapping-repository"; import { WS_AUTHORIZATION_LEASE_MS } from "./authorization-lease"; /** @@ -235,6 +236,7 @@ export class SessionConnectionAuthenticator { data: { token: string; clientId: string; + capabilities?: ClientInfo["capabilities"]; } ): Promise { const { wsManager, participantService, presenceService, log } = this.deps; @@ -331,6 +333,7 @@ export class SessionConnectionAuthenticator { status: "active", lastSeen: Date.now(), clientId: data.clientId, + capabilities: data.capabilities ?? [], authorizationExpiresAt, ws, }; @@ -341,7 +344,8 @@ export class SessionConnectionAuthenticator { ws, clientInfo, enrichment, - authorization.authorization.permissions.includes("sessions.sandbox_access") + authorization.authorization.permissions.includes("sessions.sandbox_access"), + participant.role === "owner" ) ); if (!activated) { @@ -382,7 +386,8 @@ export class SessionConnectionAuthenticator { ws: WebSocket, client: ClientInfo, enrichment: Parameters[0], - canAccessSandbox: boolean + canAccessSandbox: boolean, + canManageBudget: boolean ): boolean { const { wsManager, snapshotReader } = this.deps; const snapshot = snapshotReader.readSessionSnapshot(enrichment); @@ -402,6 +407,7 @@ export class SessionConnectionAuthenticator { name: client.name, avatar: client.avatar, }, + canManageBudget, } satisfies ServerMessage) ) { return false; @@ -443,6 +449,7 @@ export class SessionConnectionAuthenticator { status: "active", lastSeen: Date.now(), clientId: mapping.client_id || `client-${Date.now()}`, + capabilities: parseClientCapabilities(mapping.capabilities), authorizationExpiresAt: mapping.authorization_expires_at, ws, }; diff --git a/packages/control-plane/src/session/contracts.ts b/packages/control-plane/src/session/contracts.ts index 7471e74fda..ac5ed6f549 100644 --- a/packages/control-plane/src/session/contracts.ts +++ b/packages/control-plane/src/session/contracts.ts @@ -47,6 +47,7 @@ export const SessionInternalPaths = { childSummary: "/internal/child-summary", parentPrompt: "/internal/parent-prompt", updateTitle: "/internal/update-title", + budget: "/internal/budget", cancel: "/internal/cancel", childSessionUpdate: "/internal/child-session-update", diffState: "/internal/diff-state", diff --git a/packages/control-plane/src/session/execution-stop-coordinator.ts b/packages/control-plane/src/session/execution-stop-coordinator.ts new file mode 100644 index 0000000000..c11e0056d8 --- /dev/null +++ b/packages/control-plane/src/session/execution-stop-coordinator.ts @@ -0,0 +1,184 @@ +import type { SandboxEvent } from "@open-inspect/shared/types/sandbox-events"; +import type { Logger } from "../logger"; +import type { AlarmScheduler, BackgroundTasks } from "../platform-ports"; +import type { SandboxLifecycle } from "../sandbox/lifecycle/manager"; +import type { AlarmDeadlineStore } from "./alarm/scheduler"; +import type { CallbackNotificationService } from "./callback-notification-service"; +import type { MessageRepository, RecordedMessageCompletion } from "./message-repository"; +import { STOP_CONFIRMATION_TIMEOUT_MS } from "./message-repository"; +import type { SessionMessenger } from "./messenger"; +import type { SessionCoreRepository } from "./session-core-repository"; +import type { SessionStatusService } from "./session-status-service"; +import type { SessionWebSocketManager } from "./websocket-manager"; + +interface RecordedMessageFailure { + event: Extract; + completion: RecordedMessageCompletion; +} + +export interface ExecutionStopPreparation { + stopped: boolean; + processingMessageId: string | null; + stopConfirmationDeadline: number | null; + failure: RecordedMessageFailure | null; +} + +export class ExecutionStopCoordinator { + constructor( + private readonly backgroundTasks: BackgroundTasks, + private readonly log: Logger, + private readonly repository: SessionCoreRepository, + private readonly messageRepository: MessageRepository, + private readonly wsManager: SessionWebSocketManager, + private readonly messenger: SessionMessenger, + private readonly callbackService: CallbackNotificationService, + private readonly sessionStatus: SessionStatusService, + private readonly projectTerminalMessage: ( + messageId: string, + messageCreatedAt: number, + completedAt: number + ) => Promise, + private readonly sandboxLifecycle: SandboxLifecycle, + private readonly alarmScheduler: AlarmScheduler, + private readonly alarmDeadlines: AlarmDeadlineStore, + private readonly broadcastPromptQueue: () => void, + private readonly processMessageQueue: () => Promise + ) {} + + async stop(reason = "Execution was stopped"): Promise { + let preparation!: ExecutionStopPreparation; + this.repository.transaction(() => { + preparation = this.prepare(reason, Date.now()); + }); + if (!preparation.stopped) { + this.messenger.broadcast({ type: "processing_status", isProcessing: false }); + return; + } + await this.deliver(preparation); + } + + prepare(reason: string, now: number): ExecutionStopPreparation { + const processingMessage = this.messageRepository.getProcessingMessageWithCreatedAt(); + const stopConfirmationDeadline = now + STOP_CONFIRMATION_TIMEOUT_MS; + const failure = processingMessage + ? this.recordMessageFailure(processingMessage, reason, now) + : null; + if (processingMessage && failure) { + this.messageRepository.markMessageAwaitingStopConfirmation( + processingMessage.id, + stopConfirmationDeadline + ); + this.alarmDeadlines.setPendingEarliest(stopConfirmationDeadline); + } + return { + stopped: failure !== null, + processingMessageId: failure ? (processingMessage?.id ?? null) : null, + stopConfirmationDeadline: failure ? stopConfirmationDeadline : null, + failure, + }; + } + + async deliver(preparation: ExecutionStopPreparation): Promise { + if ( + !preparation.failure || + !preparation.processingMessageId || + preparation.stopConfirmationDeadline === null + ) { + return; + } + this.projectMessageFailure(preparation.failure); + this.broadcastPromptQueue(); + this.log.info("prompt.stopped", { + event: "prompt.stopped", + message_id: preparation.processingMessageId, + }); + this.messenger.broadcast({ type: "processing_status", isProcessing: false }); + + const sandboxWs = this.wsManager.getSandboxSocket(); + const stopSent = sandboxWs !== null && this.wsManager.send(sandboxWs, { type: "stop" }); + const [alarm, status] = await Promise.allSettled([ + this.alarmScheduler.schedule(preparation.stopConfirmationDeadline), + this.sessionStatus.reconcileAfterExecution(false), + ]); + if (status.status === "rejected") { + this.log.error("Stop status reconciliation failed", { error: status.reason }); + } + if (!stopSent || alarm.status === "rejected") { + const reason = stopSent ? "stop_alarm_failed" : "stop_send_failed"; + if (alarm.status === "rejected") { + this.log.error("Stop confirmation alarm failed", { error: alarm.reason }); + } + await this.sandboxLifecycle.terminateUnresponsiveSandbox(reason); + await this.resumeAfterSandboxTermination(); + } + } + + async recoverStopConfirmationTimeout(): Promise { + const awaitingStop = this.messageRepository.getMessageAwaitingStopConfirmation(); + if (!awaitingStop) return; + if (awaitingStop.deadline > Date.now()) { + // An earlier deadline may have consumed the single alarm slot; keep + // this one armed so the stop cannot wait on unrelated work. + await this.alarmScheduler.schedule(awaitingStop.deadline); + return; + } + this.log.warn("Sandbox did not confirm stop before deadline", { + event: "prompt.stop_confirmation_timeout", + message_id: awaitingStop.id, + }); + await this.sandboxLifecycle.terminateUnresponsiveSandbox("stop_confirmation_timeout"); + await this.resumeAfterSandboxTermination(); + } + + async resumeAfterSandboxTermination(): Promise { + const awaitingStop = this.messageRepository.getMessageAwaitingStopConfirmation(); + if (awaitingStop) { + this.messageRepository.clearMessageAwaitingStopConfirmation(awaitingStop.id); + } + await this.processMessageQueue(); + } + + private recordMessageFailure( + message: { id: string; created_at: number }, + error: string, + completedAt: number + ): RecordedMessageFailure | null { + const event: Extract = { + type: "execution_complete", + messageId: message.id, + success: false, + error, + sandboxId: "", + timestamp: completedAt / 1000, + }; + const completion = this.messageRepository.recordMessageCompletion( + event, + completedAt, + "processing" + ); + return completion ? { event, completion } : null; + } + + private projectMessageFailure({ event, completion }: RecordedMessageFailure): void { + this.backgroundTasks.submit( + () => + this.projectTerminalMessage( + completion.messageId, + completion.messageCreatedAt, + completion.completedAt + ) + .catch((error) => { + this.log.error("terminal_message.projection_failed", { + message_id: completion.messageId, + error, + }); + }) + .then(() => this.messenger.broadcast({ type: "sandbox_event", event })), + { name: "terminal_message.project", context: { message_id: completion.messageId } } + ); + this.backgroundTasks.submit( + () => this.callbackService.notifyComplete(completion.messageId, false, event.error), + { name: "callback.notify_complete", context: { message_id: completion.messageId } } + ); + } +} diff --git a/packages/control-plane/src/session/http/handlers/child-sessions.handler.test.ts b/packages/control-plane/src/session/http/handlers/child-sessions.handler.test.ts index 1bebbcf757..6853802c44 100644 --- a/packages/control-plane/src/session/http/handlers/child-sessions.handler.test.ts +++ b/packages/control-plane/src/session/http/handlers/child-sessions.handler.test.ts @@ -29,6 +29,10 @@ function createSession(overrides: Partial = {}): SessionRow { code_server_enabled: 0, vnc_enabled: 0, total_cost: 0, + max_cost_usd: null, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, sandbox_settings: null, environment_id: null, created_at: 1000, diff --git a/packages/control-plane/src/session/http/handlers/child-summary.handler.test.ts b/packages/control-plane/src/session/http/handlers/child-summary.handler.test.ts index 2abe3725b3..03f763f61d 100644 --- a/packages/control-plane/src/session/http/handlers/child-summary.handler.test.ts +++ b/packages/control-plane/src/session/http/handlers/child-summary.handler.test.ts @@ -35,6 +35,10 @@ function createSession(overrides: Partial = {}): SessionRow { code_server_enabled: 0, vnc_enabled: 0, total_cost: 0, + max_cost_usd: null, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, sandbox_settings: null, environment_id: null, created_at: 1000, diff --git a/packages/control-plane/src/session/http/handlers/messages.handler.ts b/packages/control-plane/src/session/http/handlers/messages.handler.ts index 296d0ddb86..dccf4dafda 100644 --- a/packages/control-plane/src/session/http/handlers/messages.handler.ts +++ b/packages/control-plane/src/session/http/handlers/messages.handler.ts @@ -8,6 +8,7 @@ import type { MessageService } from "../../services/message.service"; import { parseEventListCursor } from "../../event-cursor"; import { SessionAttachmentError } from "../../session-attachment-resolver"; import { + BudgetExhaustedError, PromptQueueFullError, PromptRequestConflictError, SessionNotPromptableError, @@ -43,6 +44,9 @@ export class MessagesHandler { if (error instanceof SessionNotPromptableError) { return Response.json({ error: error.message }, { status: 409 }); } + if (error instanceof BudgetExhaustedError) { + return Response.json({ error: error.message, code: "BUDGET_EXHAUSTED" }, { status: 409 }); + } if (error instanceof PromptQueueFullError) { return Response.json({ error: error.message, code: "PROMPT_QUEUE_FULL" }, { status: 429 }); } diff --git a/packages/control-plane/src/session/http/handlers/pull-request.handler.test.ts b/packages/control-plane/src/session/http/handlers/pull-request.handler.test.ts index 7950cd3a61..e141f4666c 100644 --- a/packages/control-plane/src/session/http/handlers/pull-request.handler.test.ts +++ b/packages/control-plane/src/session/http/handlers/pull-request.handler.test.ts @@ -47,6 +47,10 @@ function createSession(overrides: Partial = {}): SessionRow { code_server_enabled: 0, vnc_enabled: 0, total_cost: 0, + max_cost_usd: null, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, sandbox_settings: null, environment_id: null, created_at: 1000, diff --git a/packages/control-plane/src/session/http/handlers/session-budget.handler.test.ts b/packages/control-plane/src/session/http/handlers/session-budget.handler.test.ts new file mode 100644 index 0000000000..f35e0c15f1 --- /dev/null +++ b/packages/control-plane/src/session/http/handlers/session-budget.handler.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { SessionBudgetHandler } from "./session-budget.handler"; +import type { SessionBudgetService } from "../../budget-service"; +import type { SessionCoreRepository } from "../../session-core-repository"; + +function createHandler() { + const session = { + id: "session-1", + total_cost: 8, + max_cost_usd: 10, + budget_exhausted: 0, + cost_tracking_unavailable: 0, + }; + const repository = { getSession: vi.fn(() => session) }; + const budgetService = { + updateLimit: vi.fn(async (maxCostUsd: number | null) => { + session.max_cost_usd = maxCostUsd as number; + session.budget_exhausted = 0; + }), + }; + return { + handler: new SessionBudgetHandler( + repository as unknown as SessionCoreRepository, + budgetService as unknown as SessionBudgetService, + () => 1000 + ), + budgetService, + }; +} + +function request(body: unknown): Request { + return new Request("http://internal/internal/budget", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("SessionBudgetHandler", () => { + it("lets the owner update the live limit", async () => { + const h = createHandler(); + const response = await h.handler.update(request({ maxCostUsd: 20 })); + + expect(response.status).toBe(200); + expect(h.budgetService.updateLimit).toHaveBeenCalledWith(20, 1000); + expect(await response.json()).toMatchObject({ maxSessionCostUsd: 20, totalCost: 8 }); + }); + + it.each([{ maxCostUsd: 0 }, { maxCostUsd: -1 }, { maxCostUsd: 1, extra: true }, {}])( + "rejects invalid body %#", + async (body) => { + const response = await createHandler().handler.update(request(body)); + expect(response.status).toBe(400); + } + ); +}); diff --git a/packages/control-plane/src/session/http/handlers/session-budget.handler.ts b/packages/control-plane/src/session/http/handlers/session-budget.handler.ts new file mode 100644 index 0000000000..13139a8721 --- /dev/null +++ b/packages/control-plane/src/session/http/handlers/session-budget.handler.ts @@ -0,0 +1,31 @@ +import { sessionBudgetUpdateSchema } from "@open-inspect/shared/types/session-api"; +import type { SessionBudgetService } from "../../budget-service"; +import type { SessionCoreRepository } from "../../session-core-repository"; + +export class SessionBudgetHandler { + constructor( + private readonly repository: SessionCoreRepository, + private readonly budgetService: SessionBudgetService, + private readonly now: () => number + ) {} + + async update(request: Request): Promise { + const session = this.repository.getSession(); + if (!session) return Response.json({ error: "Session not found" }, { status: 404 }); + + const parsed = sessionBudgetUpdateSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return Response.json({ error: "Invalid budget request" }, { status: 400 }); + } + + await this.budgetService.updateLimit(parsed.data.maxCostUsd, this.now()); + const updated = this.repository.getSession(); + if (!updated) return Response.json({ error: "Session not found" }, { status: 404 }); + return Response.json({ + totalCost: updated.total_cost, + maxSessionCostUsd: updated.max_cost_usd, + budgetExhausted: updated.budget_exhausted === 1, + costTrackingUnavailable: updated.cost_tracking_unavailable === 1, + }); + } +} diff --git a/packages/control-plane/src/session/http/handlers/session-init.handler.test.ts b/packages/control-plane/src/session/http/handlers/session-init.handler.test.ts index aedbfa5db3..3957c1c465 100644 --- a/packages/control-plane/src/session/http/handlers/session-init.handler.test.ts +++ b/packages/control-plane/src/session/http/handlers/session-init.handler.test.ts @@ -8,6 +8,7 @@ import { getValidModelOrDefault } from "@open-inspect/shared/models"; function createHandler() { const repository = { + getSession: vi.fn(() => null), upsertSession: vi.fn(), replaceSessionRepositories: vi.fn(), transaction: vi.fn((callback: () => void) => callback()), @@ -58,6 +59,33 @@ function createHandler() { } describe("SessionInitHandler", () => { + it("does not repeat initialization side effects for an existing session", async () => { + const { handler, repository, sandboxRepository, encryptScmToken, scheduleWarmSandbox } = + createHandler(); + repository.getSession.mockReturnValue({ id: "session-do-id" } as never); + + const response = await handler.init( + new Request("http://internal/internal/init", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + sessionName: "session-public-id", + repoOwner: null, + repoName: null, + userId: "user-1", + }), + }) + ); + + expect(response.status).toBe(200); + expect(repository.upsertSession).not.toHaveBeenCalled(); + expect(repository.replaceSessionRepositories).not.toHaveBeenCalled(); + expect(sandboxRepository.createSandbox).not.toHaveBeenCalled(); + expect(repository.createParticipant).not.toHaveBeenCalled(); + expect(encryptScmToken).not.toHaveBeenCalled(); + expect(scheduleWarmSandbox).not.toHaveBeenCalled(); + }); + it.each([ ["repoOwner without repoName", { repoOwner: "acme", repoName: null }], ["repoId without repository context", { repoOwner: null, repoName: null, repoId: 123 }], @@ -150,6 +178,7 @@ describe("SessionInitHandler", () => { codeServerEnabled: false, vncEnabled: true, sandboxSettings: null, + maxCostUsd: null, environmentId: null, createdAt: 1234, updatedAt: 1234, @@ -268,7 +297,12 @@ describe("SessionInitHandler", () => { scmTokenExpiresAt: null, scmUserId: null, parentSessionId: null, - sandboxSettings: { cpuCores: null, memoryMib: null, tunnelPorts: [3000] }, + sandboxSettings: { + cpuCores: null, + memoryMib: null, + tunnelPorts: [3000], + maxSessionCostUsd: 12.5, + }, userId: "user-1", }), }) @@ -282,6 +316,7 @@ describe("SessionInitHandler", () => { repoId: null, environmentId: null, parentSessionId: null, + maxCostUsd: 12.5, }) ); expect(repository.createParticipant).toHaveBeenCalledWith( @@ -297,6 +332,7 @@ describe("SessionInitHandler", () => { cpuCores: null, memoryMib: null, tunnelPorts: [3000], + maxSessionCostUsd: 12.5, }); }); diff --git a/packages/control-plane/src/session/http/handlers/session-init.handler.ts b/packages/control-plane/src/session/http/handlers/session-init.handler.ts index 0b695efd05..c6ee928da4 100644 --- a/packages/control-plane/src/session/http/handlers/session-init.handler.ts +++ b/packages/control-plane/src/session/http/handlers/session-init.handler.ts @@ -128,6 +128,12 @@ export class SessionInitHandler { { status: 400 } ); } + // A retried init must not rebuild sandbox/participant rows or reset live + // budget state. If the first attempt committed but never scheduled the + // spawn, the first prompt spawns through processMessageQueue. + if (this.sessionCoreRepository.getSession()) { + return Response.json({ sessionId, status: "created" }); + } let encryptedToken = body.scmTokenEncrypted ?? null; if (body.scmToken) { @@ -178,6 +184,10 @@ export class SessionInitHandler { ); } + const normalizedSandboxSettings = body.sandboxSettings + ? normalizeSandboxSettings(body.sandboxSettings, { invalid: "omit" }) + : null; + this.sessionCoreRepository.transaction(() => { this.sessionCoreRepository.upsertSession({ id: sessionId, @@ -195,9 +205,10 @@ export class SessionInitHandler { spawnDepth: body.spawnDepth ?? 0, codeServerEnabled: body.codeServerEnabled ?? false, vncEnabled: body.vncEnabled ?? false, - sandboxSettings: body.sandboxSettings - ? JSON.stringify(normalizeSandboxSettings(body.sandboxSettings, { invalid: "omit" })) + sandboxSettings: normalizedSandboxSettings + ? JSON.stringify(normalizedSandboxSettings) : null, + maxCostUsd: normalizedSandboxSettings?.maxSessionCostUsd ?? null, environmentId: body.environmentId ?? null, createdAt: now, updatedAt: now, diff --git a/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts b/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts index 34a69575c3..a716aa8e22 100644 --- a/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts +++ b/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts @@ -30,6 +30,10 @@ function createSession(overrides: Partial = {}): SessionRow { code_server_enabled: 0, vnc_enabled: 0, total_cost: 0, + max_cost_usd: null, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, sandbox_settings: null, environment_id: null, created_at: 1000, diff --git a/packages/control-plane/src/session/http/routes.test.ts b/packages/control-plane/src/session/http/routes.test.ts index d92ce5d3ba..701cd40beb 100644 --- a/packages/control-plane/src/session/http/routes.test.ts +++ b/packages/control-plane/src/session/http/routes.test.ts @@ -29,6 +29,7 @@ describe("createSessionInternalRoutes", () => { pullRequestsRefresh: noopHandler(), wsToken: noopHandler(), updateTitle: noopHandler(), + budget: noopHandler(), archive: noopHandler(), unarchive: noopHandler(), expireDraft: noopHandler(), @@ -74,6 +75,7 @@ describe("createSessionInternalRoutes", () => { `POST ${SessionInternalPaths.pullRequestsRefresh}`, `POST ${SessionInternalPaths.wsToken}`, `POST ${SessionInternalPaths.updateTitle}`, + `POST ${SessionInternalPaths.budget}`, `POST ${SessionInternalPaths.archive}`, `POST ${SessionInternalPaths.unarchive}`, `POST ${SessionInternalPaths.expireDraft}`, diff --git a/packages/control-plane/src/session/http/routes.ts b/packages/control-plane/src/session/http/routes.ts index 132e709a9b..ce062967ea 100644 --- a/packages/control-plane/src/session/http/routes.ts +++ b/packages/control-plane/src/session/http/routes.ts @@ -41,6 +41,7 @@ export interface SessionInternalRouteHandlers { pullRequestsRefresh: SessionInternalRouteHandler; wsToken: SessionInternalRouteHandler; updateTitle: SessionInternalRouteHandler; + budget: SessionInternalRouteHandler; archive: SessionInternalRouteHandler; unarchive: SessionInternalRouteHandler; expireDraft: SessionInternalRouteHandler; @@ -110,6 +111,7 @@ export function createSessionInternalRoutes( }, { method: "POST", path: SessionInternalPaths.wsToken, handler: handlers.wsToken }, { method: "POST", path: SessionInternalPaths.updateTitle, handler: handlers.updateTitle }, + { method: "POST", path: SessionInternalPaths.budget, handler: handlers.budget }, { method: "POST", path: SessionInternalPaths.archive, handler: handlers.archive }, { method: "POST", path: SessionInternalPaths.unarchive, handler: handlers.unarchive }, { method: "POST", path: SessionInternalPaths.expireDraft, handler: handlers.expireDraft }, diff --git a/packages/control-plane/src/session/message-queue-types.ts b/packages/control-plane/src/session/message-queue-types.ts new file mode 100644 index 0000000000..4c9e4ce6a0 --- /dev/null +++ b/packages/control-plane/src/session/message-queue-types.ts @@ -0,0 +1,35 @@ +import type { SessionAttachmentReference } from "@open-inspect/shared/types/session-attachments"; +import type { SandboxEvent } from "@open-inspect/shared/types/sandbox-events"; +import type { MessageSource } from "@open-inspect/shared/types/sessions"; +import type { RecordedMessageCompletion } from "./message-repository"; +import type { ParticipantRow } from "./types"; + +export interface PromptMessageData { + clientRequestId?: string; + content: string; + model?: string; + reasoningEffort?: string; + attachments?: SessionAttachmentReference[]; +} + +export interface RecordedMessageFailure { + event: Extract; + completion: RecordedMessageCompletion; +} + +export interface EnqueuePromptCoreData { + participant: ParticipantRow; + userId: string; + content: string; + source: MessageSource; + model?: string; + reasoningEffort?: string; + attachments?: SessionAttachmentReference[]; + callbackContext?: Record; + clientRequestId?: string; +} + +export interface EnqueuedPrompt { + messageId: string; + position: number | null; +} diff --git a/packages/control-plane/src/session/message-queue.test.ts b/packages/control-plane/src/session/message-queue.test.ts index d94eca6365..8813ae709d 100644 --- a/packages/control-plane/src/session/message-queue.test.ts +++ b/packages/control-plane/src/session/message-queue.test.ts @@ -17,6 +17,7 @@ import type { SessionWebSocketManager } from "./websocket-manager"; import type { ParticipantService } from "./participant-service"; import type { CallbackNotificationService } from "./callback-notification-service"; import { createEarliestAlarmScheduler } from "./alarm/scheduler"; +import { ExecutionStopCoordinator } from "./execution-stop-coordinator"; import type { SessionStatusService } from "./session-status-service"; import type { GitHubAutofixSessionCommand } from "@open-inspect/shared"; @@ -62,6 +63,10 @@ function createSession(overrides: Partial = {}): SessionRow { code_server_enabled: 0, vnc_enabled: 0, total_cost: 0, + max_cost_usd: null, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, sandbox_settings: null, environment_id: null, created_at: 1000, @@ -141,14 +146,15 @@ function buildQueue() { child: vi.fn(), }; const repository = { + transaction: vi.fn((closure: () => unknown) => closure()), createMessageWithAttachments: vi.fn(), createEvent: vi.fn(), getPendingOrProcessingCount: vi.fn(() => 1), getMessageByClientRequestId: vi.fn(() => null as MessageRow | null), - admitAutofixMessage: vi.fn(() => ({ - kind: "enqueued", - messageId: "msg-autofix", - })), + admitAutofixMessage: vi.fn((data) => { + if (typeof data.message.authorId === "function") data.message.authorId(); + return { kind: "enqueued", messageId: "msg-autofix" }; + }), getAutofixMessageId: vi.fn(() => null as string | null), getMessageStatus: vi.fn(() => "pending" as const), cancelPendingMessage: vi.fn(() => false), @@ -217,10 +223,41 @@ function buildQueue() { const backgroundTasks = createTestBackgroundTasks(); const getAlarm = vi.fn(async () => null as number | null); const setAlarm = vi.fn(async (_timestamp: number) => {}); + const alarmDeadlines = { + pending: vi.fn(() => null as number | null), + earliest: vi.fn(() => null as number | null), + cancelled: vi.fn(() => false), + setPending: vi.fn(), + setPendingEarliest: vi.fn(), + activate: vi.fn(), + clear: vi.fn(), + beginDelivery: vi.fn(() => null as number | "cancelled" | null), + completeDelivery: vi.fn(), + }; const projectTerminalMessage = vi.fn(async () => {}); const getProviderAuthenticationError = vi.fn(async (_model: string) => null as string | null); - const queue = new SessionMessageQueue( + const alarmScheduler = createEarliestAlarmScheduler( + { getAlarm, setAlarm, deleteAlarm: vi.fn(async () => {}) }, + alarmDeadlines + ); + const executionStop: ExecutionStopCoordinator = new ExecutionStopCoordinator( + backgroundTasks, + log, + repository as unknown as SessionCoreRepository, + repository as unknown as MessageRepository, + wsManager as unknown as SessionWebSocketManager, + messenger, + callbackService as unknown as CallbackNotificationService, + sessionStatus as unknown as SessionStatusService, + projectTerminalMessage, + sandboxLifecycle, + alarmScheduler, + alarmDeadlines, + (): void => queue.broadcastPromptQueue(), + (): Promise => queue.processMessageQueue() + ); + const queue: SessionMessageQueue = new SessionMessageQueue( backgroundTasks, log, repository as unknown as SessionCoreRepository, @@ -237,24 +274,14 @@ function buildQueue() { sandboxLifecycle, null, "github", - createEarliestAlarmScheduler( - { getAlarm, setAlarm, deleteAlarm: vi.fn(async () => {}) }, - { - pending: vi.fn(() => null), - earliest: vi.fn(() => null), - cancelled: vi.fn(() => false), - setPending: vi.fn(), - activate: vi.fn(), - clear: vi.fn(), - beginDelivery: vi.fn(() => null), - completeDelivery: vi.fn(), - } - ), + alarmScheduler, + executionStop, () => executionTimeoutMs ); return { queue, + executionStop, repository, attachmentRepository, wsManager, @@ -265,6 +292,7 @@ function buildQueue() { backgroundTasks, getAlarm, setAlarm, + alarmDeadlines, callbackService, getProviderAuthenticationError, projectTerminalMessage, @@ -304,7 +332,7 @@ describe("SessionMessageQueue", () => { }); expect(h.repository.admitAutofixMessage).toHaveBeenCalledWith({ message: expect.objectContaining({ - authorId: "part-1", + authorId: expect.any(Function), content: command.prompt, source: "github", status: "pending", @@ -376,6 +404,8 @@ describe("SessionMessageQueue", () => { expect.objectContaining({ sessionClosed: true }) ); expect(h.sessionStatus.transition).not.toHaveBeenCalled(); + expect(h.participantService.getByUserId).not.toHaveBeenCalled(); + expect(h.repository.updateParticipantCoalesce).not.toHaveBeenCalled(); }); it("returns a duplicate without re-driving it in a closed session", async () => { @@ -479,6 +509,37 @@ describe("SessionMessageQueue", () => { expect(h.callbackService.notifyStarted).not.toHaveBeenCalled(); }); + it("does not spawn or dispatch while the session budget is exhausted", async () => { + const h = buildQueue(); + h.repository.getSession.mockReturnValue(createSession({ budget_exhausted: 1 })); + h.repository.getNextPendingMessage.mockReturnValue(createMessage()); + + await h.queue.processMessageQueue(); + + expect(h.sandboxLifecycle.spawnSandbox).not.toHaveBeenCalled(); + expect(h.repository.startMessageProcessing).not.toHaveBeenCalled(); + }); + + it.each([null, "Provider authentication expired"])( + "rechecks budget exhaustion before handling provider auth result %s", + async (authenticationError) => { + const h = buildQueue(); + const ready = {} as WebSocket; + h.wsManager.getSandboxSocket.mockReturnValue(ready); + h.repository.getNextPendingMessage.mockReturnValue(createMessage()); + h.getProviderAuthenticationError.mockImplementationOnce(async () => { + h.repository.getSession.mockReturnValue(createSession({ budget_exhausted: 1 })); + return authenticationError; + }); + + await h.queue.processMessageQueue(); + + expect(h.repository.startMessageProcessing).not.toHaveBeenCalled(); + expect(h.repository.recordMessageCompletion).not.toHaveBeenCalled(); + expect(h.wsManager.send).not.toHaveBeenCalled(); + } + ); + it.each(["cancelled", "archived"] as const)( "does not dispatch queued work for a %s session", async (status) => { @@ -583,6 +644,26 @@ describe("SessionMessageQueue", () => { ); }); + it("rejects a new websocket prompt when the session budget is exhausted", async () => { + const h = buildQueue(); + h.repository.getSession.mockReturnValue(createSession({ budget_exhausted: 1 })); + + await h.queue.handlePromptMessage({} as WebSocket, createClientInfo(), { + clientRequestId: "request-budget", + content: "continue", + }); + + expect(h.wsManager.send).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + type: "error", + code: "BUDGET_EXHAUSTED", + clientRequestId: "request-budget", + }) + ); + expect(h.repository.createMessageWithAttachments).not.toHaveBeenCalled(); + }); + it("returns a null position when retrying a completed correlated prompt", async () => { const h = buildQueue(); h.repository.getMessageByClientRequestId.mockReturnValue( @@ -1218,6 +1299,55 @@ describe("SessionMessageQueue", () => { }); }); + it("atomically establishes budget stop intent before delivery", async () => { + const h = buildQueue(); + const sandboxWs = { readyState: 1 } as WebSocket; + h.wsManager.getSandboxSocket.mockReturnValue(sandboxWs); + h.repository.getProcessingMessageWithCreatedAt.mockReturnValue({ + id: "msg-budget", + created_at: 900, + }); + const preparation = h.executionStop.prepare("Session cost limit reached", 1000); + await h.executionStop.deliver(preparation); + + expect(preparation.stopped).toBe(true); + expect(h.repository.recordMessageCompletion).toHaveBeenCalledWith( + expect.objectContaining({ error: "Session cost limit reached" }), + expect.any(Number), + "processing" + ); + expect(h.repository.markMessageAwaitingStopConfirmation).toHaveBeenCalledWith( + "msg-budget", + expect.any(Number) + ); + expect(h.alarmDeadlines.setPendingEarliest).toHaveBeenCalledWith(expect.any(Number)); + expect(h.wsManager.send).toHaveBeenCalledWith(sandboxWs, { type: "stop" }); + }); + + it("continues budget stop delivery when alarm scheduling fails", async () => { + const h = buildQueue(); + const sandboxWs = { readyState: 1 } as WebSocket; + h.wsManager.getSandboxSocket.mockReturnValue(sandboxWs); + h.repository.getProcessingMessageWithCreatedAt.mockReturnValue({ + id: "msg-budget", + created_at: 900, + }); + h.setAlarm.mockRejectedValue(new Error("alarm unavailable")); + + const preparation = h.executionStop.prepare("Session cost limit reached", 1000); + await expect(h.executionStop.deliver(preparation)).resolves.toBeUndefined(); + + expect(h.sessionStatus.reconcileAfterExecution).toHaveBeenCalledWith(false); + expect(h.wsManager.send).toHaveBeenCalledWith(sandboxWs, { type: "stop" }); + expect(h.sandboxLifecycle.terminateUnresponsiveSandbox).toHaveBeenCalledWith( + "stop_alarm_failed" + ); + expect(h.log.error).toHaveBeenCalledWith( + "Stop confirmation alarm failed", + expect.objectContaining({ error: expect.any(Error) }) + ); + }); + it("delegates stop finalization before broadcasting idle and stopping the sandbox", async () => { const h = buildQueue(); const sandboxWs = { readyState: 1 } as WebSocket; @@ -1227,7 +1357,7 @@ describe("SessionMessageQueue", () => { created_at: 900, }); - await h.queue.stopExecution(); + await h.executionStop.stop(); expect(h.repository.recordMessageCompletion).toHaveBeenCalledWith( expect.objectContaining({ @@ -1266,7 +1396,7 @@ describe("SessionMessageQueue", () => { }) ); - await h.queue.stopExecution(); + await h.executionStop.stop(); expect(h.broadcast).not.toHaveBeenCalledWith({ type: "sandbox_event", event: expect.objectContaining({ type: "execution_complete" }), @@ -1289,7 +1419,7 @@ describe("SessionMessageQueue", () => { h.repository.getNextPendingMessage.mockReturnValue(createMessage({ id: "msg-next" })); h.wsManager.getSandboxSocket.mockReturnValue({ readyState: 1 } as WebSocket); - await h.queue.stopExecution(); + await h.executionStop.stop(); expect(h.repository.updateMessageToProcessing).not.toHaveBeenCalledWith( "msg-next", @@ -1312,7 +1442,7 @@ describe("SessionMessageQueue", () => { h.repository.getNextPendingMessage.mockReturnValue(createMessage({ id: "msg-next" })); h.wsManager.getSandboxSocket.mockReturnValue(null); - await h.queue.stopExecution(); + await h.executionStop.stop(); expect(h.sandboxLifecycle.terminateUnresponsiveSandbox).toHaveBeenCalledWith( "stop_send_failed" @@ -1330,7 +1460,7 @@ describe("SessionMessageQueue", () => { h.wsManager.getSandboxSocket.mockReturnValue({ readyState: 1 } as WebSocket); h.wsManager.send.mockReturnValue(false); - await h.queue.stopExecution(); + await h.executionStop.stop(); expect(h.sandboxLifecycle.terminateUnresponsiveSandbox).toHaveBeenCalledWith( "stop_send_failed" @@ -1347,7 +1477,7 @@ describe("SessionMessageQueue", () => { }) .mockReturnValue(null); - await h.queue.recoverStopConfirmationTimeout(); + await h.executionStop.recoverStopConfirmationTimeout(); expect(h.sandboxLifecycle.terminateUnresponsiveSandbox).toHaveBeenCalledWith( "stop_confirmation_timeout" @@ -1364,7 +1494,7 @@ describe("SessionMessageQueue", () => { deadline, }); - await h.queue.recoverStopConfirmationTimeout(); + await h.executionStop.recoverStopConfirmationTimeout(); expect(h.sandboxLifecycle.terminateUnresponsiveSandbox).not.toHaveBeenCalled(); expect(h.setAlarm).toHaveBeenCalledExactlyOnceWith(deadline); @@ -1376,7 +1506,7 @@ describe("SessionMessageQueue", () => { .mockReturnValueOnce({ id: "msg-stopped", deadline: Date.now() - 1 }) .mockReturnValue(null); - await h.queue.resumeAfterSandboxTermination(); + await h.executionStop.resumeAfterSandboxTermination(); expect(h.repository.clearMessageAwaitingStopConfirmation).toHaveBeenCalledWith("msg-stopped"); }); @@ -1396,21 +1526,10 @@ describe("SessionMessageQueue", () => { expect(h.wsManager.send).not.toHaveBeenCalled(); }); - it("suppresses session status reconcile when stopExecution is called with suppress flag", async () => { - const h = buildQueue(); - h.repository.getProcessingMessageWithCreatedAt.mockReturnValue({ - id: "msg-10", - created_at: 900, - }); - await h.queue.stopExecution({ suppressStatusReconcile: true }); - - expect(h.sessionStatus.reconcileAfterExecution).not.toHaveBeenCalled(); - }); - it("does not finalize or stop when no message is processing", async () => { const h = buildQueue(); - await h.queue.stopExecution(); + await h.executionStop.stop(); await h.queue.failStuckProcessingMessage(); expect(h.repository.recordMessageCompletion).not.toHaveBeenCalled(); @@ -1516,6 +1635,57 @@ describe("SessionMessageQueue", () => { }); describe("enqueuePromptFromApi", () => { + it("rejects exhaustion before capacity checks or participant mutations", async () => { + const h = buildQueue(); + h.repository.getSession.mockReturnValue(createSession({ budget_exhausted: 1 })); + h.repository.getPendingOrProcessingCount.mockReturnValue(MAX_UNFINISHED_PROMPTS); + h.participantService.getByUserId.mockReturnValue(null as unknown as ParticipantRow); + + await expect( + h.queue.enqueuePromptFromApi({ + content: "Continue", + authorId: "new-user", + source: "agent", + }) + ).rejects.toMatchObject({ name: "BudgetExhaustedError" }); + + expect(h.repository.getPendingOrProcessingCount).not.toHaveBeenCalled(); + expect(h.participantService.create).not.toHaveBeenCalled(); + }); + + it("rejects a full queue before participant mutations", async () => { + const h = buildQueue(); + h.repository.getPendingOrProcessingCount.mockReturnValue(MAX_UNFINISHED_PROMPTS); + h.participantService.getByUserId.mockReturnValue(null as unknown as ParticipantRow); + + await expect( + h.queue.enqueuePromptFromApi({ + content: "Continue", + authorId: "new-user", + source: "agent", + }) + ).rejects.toMatchObject({ name: "PromptQueueFullError" }); + + expect(h.participantService.create).not.toHaveBeenCalled(); + expect(h.repository.updateParticipantCoalesce).not.toHaveBeenCalled(); + }); + + it("rejects a full queue on the WebSocket path before creating a participant", async () => { + const h = buildQueue(); + h.repository.getPendingOrProcessingCount.mockReturnValue(MAX_UNFINISHED_PROMPTS); + h.repository.getParticipantById.mockReturnValue(null as unknown as ParticipantRow); + h.participantService.getByUserId.mockReturnValue(null as unknown as ParticipantRow); + const ws = {} as WebSocket; + + await h.queue.handlePromptMessage(ws, createClientInfo(), { content: "Continue" }); + + expect(h.participantService.create).not.toHaveBeenCalled(); + expect(h.wsManager.send).toHaveBeenCalledWith( + ws, + expect.objectContaining({ type: "error", code: "PROMPT_QUEUE_FULL" }) + ); + }); + it.each(["cancelled", "archived"] as const)( "rejects prompts for a %s session before inserting a message", async (status) => { diff --git a/packages/control-plane/src/session/message-queue.ts b/packages/control-plane/src/session/message-queue.ts index c4f3af8a0b..d0cb3c2984 100644 --- a/packages/control-plane/src/session/message-queue.ts +++ b/packages/control-plane/src/session/message-queue.ts @@ -1,10 +1,7 @@ import { generateId, hashToken } from "../auth/crypto"; import type { SessionIndexStore } from "../db/session-index"; import type { Logger } from "../logger"; -import type { - SessionAttachmentReference, - ResolvedSessionAttachment, -} from "@open-inspect/shared/types/session-attachments"; +import type { ResolvedSessionAttachment } from "@open-inspect/shared/types/session-attachments"; import type { GitHubAutofixOrigin, GitHubAutofixSessionCommand, @@ -18,7 +15,6 @@ import { } from "@open-inspect/shared/models"; import type { SandboxEvent } from "@open-inspect/shared/types/sandbox-events"; import { isSessionPromptable } from "@open-inspect/shared/types/session-activity"; -import type { MessageSource } from "@open-inspect/shared/types/sessions"; import { MAX_UNFINISHED_PROMPTS } from "@open-inspect/shared/types/prompts"; import type { ClientInfo } from "../types"; import type { SourceControlProviderName } from "../source-control"; @@ -26,7 +22,7 @@ import type { SandboxLifecycle } from "../sandbox/lifecycle/manager"; import type { ParticipantRow, PromptGitIdentity, SandboxCommand, SessionRow } from "./types"; import type { SessionCoreRepository } from "./session-core-repository"; import type { ParticipantRepository } from "./participant-repository"; -import { STOP_CONFIRMATION_TIMEOUT_MS, type MessageRepository } from "./message-repository"; +import type { MessageRepository } from "./message-repository"; import { AttachmentClaimConflictError, type SessionAttachmentRepository, @@ -40,6 +36,7 @@ import type { EnqueuePromptRequest } from "./enqueue-prompt-contract"; import { getAvatarUrl } from "./participant-service"; import { resolveParticipantName } from "./participant-name"; import type { AlarmScheduler, BackgroundTasks } from "../platform-ports"; +import type { ExecutionStopCoordinator } from "./execution-stop-coordinator"; import { resolveGitAuthorIdentity } from "./identity"; import { validateReasoningEffort } from "./reasoning-effort"; import { @@ -47,35 +44,12 @@ import { SessionAttachmentError, resolveSessionAttachments, } from "./session-attachment-resolver"; - -interface PromptMessageData { - clientRequestId?: string; - content: string; - model?: string; - reasoningEffort?: string; - attachments?: SessionAttachmentReference[]; -} - -interface StopExecutionOptions { - suppressStatusReconcile?: boolean; -} - -interface EnqueuePromptCoreData { - participant: ParticipantRow; - userId: string; - content: string; - source: MessageSource; - model?: string; - reasoningEffort?: string; - attachments?: SessionAttachmentReference[]; - callbackContext?: Record; - clientRequestId?: string; -} - -interface EnqueuedPrompt { - messageId: string; - position: number | null; -} +import type { + EnqueuedPrompt, + EnqueuePromptCoreData, + PromptMessageData, + RecordedMessageFailure, +} from "./message-queue-types"; const AUTOFIX_ATTEMPT_WINDOW_MS = 24 * 60 * 60 * 1_000; const STUCK_PROCESSING_ERROR = "Execution timed out (stuck processing)"; @@ -97,6 +71,15 @@ export class SessionNotPromptableError extends Error { } } +export class BudgetExhaustedError extends Error { + constructor() { + super( + "Session cost limit reached. The session owner must raise or remove the limit to continue." + ); + this.name = "BudgetExhaustedError"; + } +} + export class PromptQueueFullError extends Error { constructor() { super(`A session may have at most ${MAX_UNFINISHED_PROMPTS} unfinished prompts`); @@ -168,6 +151,7 @@ export class SessionMessageQueue { private readonly sessionIndex: SessionIndexStore | null, private readonly scmProvider: SourceControlProviderName, private readonly alarmScheduler: AlarmScheduler, + private readonly executionStop: ExecutionStopCoordinator, /** Resolved per use so it honors settings persisted after construction. */ private readonly getExecutionTimeoutMs: () => number ) {} @@ -177,21 +161,22 @@ export class SessionMessageQueue { ): Promise { const session = this.repository.getSession(); const userId = `github:${command.author.id}`; - let participant = this.participantService.getByUserId(userId); - if (!participant) { - participant = this.participantService.create(userId, command.author.login); - } - this.participantRepository.updateParticipantCoalesce(participant.id, { - scmUserId: command.author.id, - scmLogin: command.author.login, - scmName: command.author.login, - }); - const now = Date.now(); const admission = this.messageRepository.admitAutofixMessage({ message: { id: generateId(), - authorId: participant.id, + authorId: () => { + let participant = this.participantService.getByUserId(userId); + if (!participant) { + participant = this.participantService.create(userId, command.author.login); + } + this.participantRepository.updateParticipantCoalesce(participant.id, { + scmUserId: command.author.id, + scmLogin: command.author.login, + scmName: command.author.login, + }); + return participant.id; + }, content: command.prompt, source: "github", status: "pending", @@ -249,6 +234,7 @@ export class SessionMessageQueue { let participant = this.participantRepository.getParticipantById(client.participantId); participant ??= this.participantService.getByUserId(client.userId); if (!participant) { + this.assertBudgetAvailable(); this.assertQueueCapacity(); participant = this.participantService.create(client.userId, client.name); } @@ -299,6 +285,15 @@ export class SessionMessageQueue { }); return; } + if (error instanceof BudgetExhaustedError) { + this.wsManager.send(ws, { + type: "error", + code: "BUDGET_EXHAUSTED", + message: error.message, + clientRequestId: data.clientRequestId, + }); + return; + } throw error; } @@ -360,13 +355,16 @@ export class SessionMessageQueue { const awaitingStop = this.messageRepository.getMessageAwaitingStopConfirmation(); if (awaitingStop) { if (awaitingStop.deadline <= Date.now()) { - await this.recoverStopConfirmationTimeout(); + await this.executionStop.recoverStopConfirmationTimeout(); } else { await this.alarmScheduler.schedule(awaitingStop.deadline); } this.log.debug("processMessageQueue: waiting for sandbox stop confirmation"); return; } + if (currentSession.budget_exhausted === 1) { + return; + } if (this.messageRepository.getProcessingMessage()) { this.log.debug("processMessageQueue: already processing, returning"); return; @@ -380,6 +378,7 @@ export class SessionMessageQueue { const session = this.repository.getSession(); const resolvedModel = getValidModelOrDefault(message.model || session?.model); const authenticationError = await this.getProviderAuthenticationError(resolvedModel); + if (this.repository.getSession()?.budget_exhausted === 1) return; if (authenticationError) { this.log.error("provider_auth.unavailable", { event: "provider_auth.unavailable", @@ -392,7 +391,6 @@ export class SessionMessageQueue { } return; } - const sandboxWs = this.wsManager.getSandboxSocket(); if (!sandboxWs) { this.log.info("prompt.dispatch", { @@ -478,7 +476,7 @@ export class SessionMessageQueue { if (!sent) { this.messageRepository.updateMessageToPending(message.id); await this.sandboxLifecycle.terminateUnresponsiveSandbox("prompt_dispatch_send_failed"); - await this.resumeAfterSandboxTermination(); + await this.executionStop.resumeAfterSandboxTermination(); } else { this.messenger.broadcast({ type: "sandbox_event", event: userMessageEvent }); this.messenger.broadcast({ type: "processing_status", isProcessing: true }); @@ -511,77 +509,10 @@ export class SessionMessageQueue { }); } - /** - * Stop the current execution. - * - * Marks the processing message as failed, upserts a synthetic - * execution_complete, broadcasts that synthetic event so every client flushes - * its buffered tokens, and forwards the stop to the sandbox. - */ - async stopExecution(options: StopExecutionOptions = {}): Promise { - const now = Date.now(); - const processingMessage = this.messageRepository.getProcessingMessageWithCreatedAt(); - let stoppedMessageId: string | null = null; - - if ( - processingMessage && - this.failMessage(processingMessage, "Execution was stopped", now, "processing") - ) { - stoppedMessageId = processingMessage.id; - const stopConfirmationDeadline = now + STOP_CONFIRMATION_TIMEOUT_MS; - this.messageRepository.markMessageAwaitingStopConfirmation( - processingMessage.id, - stopConfirmationDeadline - ); - await this.alarmScheduler.schedule(stopConfirmationDeadline); - this.broadcastPromptQueue(); - this.log.info("prompt.stopped", { - event: "prompt.stopped", - message_id: processingMessage.id, - }); - if (!options.suppressStatusReconcile) { - await this.sessionStatus.reconcileAfterExecution(false); - } - } - - this.messenger.broadcast({ type: "processing_status", isProcessing: false }); - - const sandboxWs = this.wsManager.getSandboxSocket(); - if (stoppedMessageId && (!sandboxWs || !this.wsManager.send(sandboxWs, { type: "stop" }))) { - await this.sandboxLifecycle.terminateUnresponsiveSandbox("stop_send_failed"); - await this.resumeAfterSandboxTermination(); - } - } - - async recoverStopConfirmationTimeout(): Promise { - const awaitingStop = this.messageRepository.getMessageAwaitingStopConfirmation(); - if (!awaitingStop) return; - if (awaitingStop.deadline > Date.now()) { - // An earlier deadline may have consumed the single alarm slot; keep - // this one armed so the stop cannot wait on unrelated work. - await this.alarmScheduler.schedule(awaitingStop.deadline); - return; - } - this.log.warn("Sandbox did not confirm stop before deadline", { - event: "prompt.stop_confirmation_timeout", - message_id: awaitingStop.id, - }); - await this.sandboxLifecycle.terminateUnresponsiveSandbox("stop_confirmation_timeout"); - await this.resumeAfterSandboxTermination(); - } - - async resumeAfterSandboxTermination(): Promise { - const awaitingStop = this.messageRepository.getMessageAwaitingStopConfirmation(); - if (awaitingStop) { - this.messageRepository.clearMessageAwaitingStopConfirmation(awaitingStop.id); - } - await this.processMessageQueue(); - } - async handleFatalSandboxFailure(reason: string): Promise { const termination = this.sandboxLifecycle.terminateFailedSandbox(reason); await this.failStuckProcessingMessage(reason); - if (await termination) await this.resumeAfterSandboxTermination(); + if (await termination) await this.executionStop.resumeAfterSandboxTermination(); } /** Close every unfinished message synchronously; status projection happens afterwards. */ @@ -628,6 +559,18 @@ export class SessionMessageQueue { completedAt: number, expectedStatus: "pending" | "processing" ): boolean { + const failure = this.recordMessageFailure(message, error, completedAt, expectedStatus); + if (!failure) return false; + this.projectMessageFailure(failure); + return true; + } + + private recordMessageFailure( + message: { id: string; created_at: number }, + error: string, + completedAt: number, + expectedStatus: "pending" | "processing" + ): RecordedMessageFailure | null { const event: Extract = { type: "execution_complete", messageId: message.id, @@ -641,8 +584,10 @@ export class SessionMessageQueue { completedAt, expectedStatus ); - if (!completion) return false; + return completion ? { event, completion } : null; + } + private projectMessageFailure({ event, completion }: RecordedMessageFailure): void { this.backgroundTasks.submit( () => this.projectTerminalMessage( @@ -652,24 +597,23 @@ export class SessionMessageQueue { ) .catch((projectionError) => { this.log.error("terminal_message.projection_failed", { - message_id: message.id, + message_id: completion.messageId, error: projectionError, }); }) .then(() => this.messenger.broadcast({ type: "sandbox_event", event })), { name: "terminal_message.project", - context: { message_id: message.id }, + context: { message_id: completion.messageId }, } ); this.backgroundTasks.submit( - () => this.callbackService.notifyComplete(message.id, false, error), + () => this.callbackService.notifyComplete(completion.messageId, false, event.error), { name: "callback.notify_complete", - context: { message_id: message.id }, + context: { message_id: completion.messageId }, } ); - return true; } private createUserMessageEvent( @@ -708,6 +652,7 @@ export class SessionMessageQueue { data: EnqueuePromptRequest ): Promise<{ messageId: string; status: "queued" }> { this.assertPromptableSession(); + this.assertBudgetAvailable(); this.assertQueueCapacity(); let participant = this.participantService.getByUserId(data.authorId); if (!participant) { @@ -764,8 +709,6 @@ export class SessionMessageQueue { requestFingerprint = await fingerprintWebPrompt(data.participant.id, data); } - // Keep the idempotency lookup, capacity check, and insert in one synchronous - // turn so concurrent WebSocket requests cannot race between them. const queueDepthBefore = this.messageRepository.getPendingOrProcessingCount(); if (data.clientRequestId) { const existing = this.messageRepository.getMessageByClientRequestId(data.clientRequestId); @@ -796,6 +739,7 @@ export class SessionMessageQueue { }; } } + this.assertBudgetAvailable(); this.assertQueueCapacity(queueDepthBefore); const resolvedAttachments = resolveSessionAttachments( data.attachments, @@ -873,6 +817,12 @@ export class SessionMessageQueue { return { messageId, position }; } + private assertBudgetAvailable(): void { + if (this.repository.getSession()?.budget_exhausted === 1) { + throw new BudgetExhaustedError(); + } + } + private assertPromptableSession(): void { const session = this.repository.getSession(); if (session && !isSessionPromptable(session.status)) { diff --git a/packages/control-plane/src/session/message-repository.test.ts b/packages/control-plane/src/session/message-repository.test.ts index 7ccca04831..b26bdebc2b 100644 --- a/packages/control-plane/src/session/message-repository.test.ts +++ b/packages/control-plane/src/session/message-repository.test.ts @@ -210,7 +210,31 @@ describe("MessageRepository", () => { sessionClosed: true, }) ).toEqual({ kind: "rejected", reason: "session_closed" }); - expect(mock.calls).toHaveLength(1); + expect(mock.calls).toHaveLength(2); + }); + + it("rejects new Autofix feedback when the session budget is exhausted", () => { + mock.setData(`SELECT budget_exhausted FROM session LIMIT 1`, [{ budget_exhausted: 1 }]); + + expect( + repository.admitAutofixMessage({ + message: { + id: "msg-new", + authorId: "p-1", + content: "Fix feedback", + source: "github", + status: "pending", + createdAt: 2000, + }, + feedbackKey: "github:review:1", + pullRequestKey: "github:99:42", + originContext: "{}", + attemptLimit: 3, + windowStart: 1000, + sessionClosed: false, + }) + ).toEqual({ kind: "rejected", reason: "budget_exhausted" }); + expect(mock.calls).toHaveLength(2); }); it("rejects Autofix admission when the rolling PR cap is reached", () => { @@ -234,7 +258,7 @@ describe("MessageRepository", () => { sessionClosed: false, }) ).toEqual({ kind: "rejected", reason: "attempt_limit" }); - expect(mock.calls).toHaveLength(3); + expect(mock.calls).toHaveLength(4); }); it("admits Autofix feedback without checking the rolling count when there is no limit", () => { @@ -282,7 +306,7 @@ describe("MessageRepository", () => { sessionClosed: false, }) ).toEqual({ kind: "rejected", reason: "queue_full" }); - expect(mock.calls).toHaveLength(2); + expect(mock.calls).toHaveLength(3); }); it("admits Autofix metadata without creating an admission-time event", () => { @@ -492,4 +516,29 @@ describe("MessageRepository", () => { }); expect(repository.getProcessingMessageAuthor()).toEqual({ author_id: "p-1" }); }); + + describe("raiseReportedCost", () => { + it("returns the increase over the stored report", () => { + mock.setMatchingData(/SELECT reported_cost_usd FROM messages/, [{ reported_cost_usd: 1 }]); + + expect(repository.raiseReportedCost("msg-1", 2.5)).toBe(1.5); + + const call = mock.calls.find((c) => c.query.includes("SET reported_cost_usd")); + expect(call?.params).toEqual([2.5, "msg-1"]); + }); + + it("returns 0 without writing for a resend, an unknown message, or a non-positive report", () => { + mock.setMatchingData(/SELECT reported_cost_usd FROM messages/, [{ reported_cost_usd: 2.5 }]); + expect(repository.raiseReportedCost("msg-1", 2.5)).toBe(0); + expect(repository.raiseReportedCost("msg-1", 2)).toBe(0); + expect(repository.raiseReportedCost("msg-1", 0)).toBe(0); + expect(repository.raiseReportedCost("msg-1", Number.NaN)).toBe(0); + expect(mock.calls.filter((c) => c.query.includes("SET reported_cost_usd"))).toHaveLength(0); + }); + + it("returns 0 for an unknown message", () => { + expect(repository.raiseReportedCost("missing", 2.5)).toBe(0); + expect(mock.calls.filter((c) => c.query.includes("SET reported_cost_usd"))).toHaveLength(0); + }); + }); }); diff --git a/packages/control-plane/src/session/message-repository.ts b/packages/control-plane/src/session/message-repository.ts index 8dc4b2f68f..9f0380b9b6 100644 --- a/packages/control-plane/src/session/message-repository.ts +++ b/packages/control-plane/src/session/message-repository.ts @@ -11,7 +11,7 @@ type ExecutionCompleteEvent = Extract & { authorId: string | (() => string) }; feedbackKey: string; pullRequestKey: string; originContext: string; @@ -51,7 +51,10 @@ export interface AdmitAutofixMessageData { export type AutofixMessageAdmission = | { kind: "enqueued"; messageId: string } | { kind: "duplicate"; messageId: string } - | { kind: "rejected"; reason: "session_closed" | "queue_full" | "attempt_limit" }; + | { + kind: "rejected"; + reason: "session_closed" | "budget_exhausted" | "queue_full" | "attempt_limit"; + }; /** Options for listing messages. */ export interface ListMessagesOptions { @@ -117,6 +120,29 @@ export class MessageRepository { ); } + /** + * Record the runtime's cumulative cost report for a turn and return how much + * it exceeds the highest report already stored. Resends, out-of-order + * reports, and unknown messages return 0. + */ + raiseReportedCost(messageId: string, reportedCostUsd: number): number { + if (!Number.isFinite(reportedCostUsd) || reportedCostUsd <= 0) return 0; + // Read-then-write: callers hold the storage transaction, and RETURNING + // would only expose the post-update value. + const rows = this.sql + .exec(`SELECT reported_cost_usd FROM messages WHERE id = ?`, messageId) + .toArray() as Array<{ reported_cost_usd: number }>; + if (rows.length !== 1) return 0; + const previous = rows[0].reported_cost_usd; + if (reportedCostUsd <= previous) return 0; + this.sql.exec( + `UPDATE messages SET reported_cost_usd = ? WHERE id = ?`, + reportedCostUsd, + messageId + ); + return reportedCostUsd - previous; + } + clearMessageAwaitingStopConfirmation(messageId: string): void { this.sql.exec(`UPDATE messages SET stop_confirmation_deadline = NULL WHERE id = ?`, messageId); } @@ -172,6 +198,14 @@ export class MessageRepository { if (existingMessageId) { return { kind: "duplicate", messageId: existingMessageId }; } + const budget = ( + this.sql.exec(`SELECT budget_exhausted FROM session LIMIT 1`).toArray() as Array<{ + budget_exhausted: number; + }> + )[0]; + if (budget?.budget_exhausted === 1) { + return { kind: "rejected", reason: "budget_exhausted" }; + } if (data.sessionClosed) { return { kind: "rejected", reason: "session_closed" }; } @@ -195,6 +229,10 @@ export class MessageRepository { this.createMessage({ ...data.message, + authorId: + typeof data.message.authorId === "function" + ? data.message.authorId() + : data.message.authorId, autofixFeedbackKey: data.feedbackKey, autofixPrKey: data.pullRequestKey, originContext: data.originContext, diff --git a/packages/control-plane/src/session/messenger.test.ts b/packages/control-plane/src/session/messenger.test.ts index 61ebc0afd4..893c53c15f 100644 --- a/packages/control-plane/src/session/messenger.test.ts +++ b/packages/control-plane/src/session/messenger.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { SandboxDeliveryUnavailableError, SessionMessengerImpl } from "./messenger"; function harness(overrides: { sandboxSocket?: WebSocket | null; sendResult?: boolean } = {}) { - const clientA = { readyState: WebSocket.OPEN } as WebSocket; - const clientB = { readyState: WebSocket.OPEN } as WebSocket; + const clientA = { readyState: WebSocket.OPEN, url: "ws://client-a" } as WebSocket; + const clientB = { readyState: WebSocket.OPEN, url: "ws://client-b" } as WebSocket; const sandbox = overrides.sandboxSocket === undefined ? ({ readyState: WebSocket.OPEN } as WebSocket) @@ -17,6 +17,7 @@ function harness(overrides: { sandboxSocket?: WebSocket | null; sendResult?: boo ), getSandboxSocket: vi.fn(() => sandbox), send: vi.fn(() => overrides.sendResult ?? true), + supportsClientCapability: vi.fn((_ws: WebSocket, _capability: "session_budget") => true), }; return { messenger: new SessionMessengerImpl(wsManager), wsManager, clientA, clientB, sandbox }; } @@ -36,6 +37,23 @@ describe("SessionMessengerImpl", () => { expect(wsManager.send).toHaveBeenCalledWith(clientB, message); }); + it("only broadcasts budget protocol messages to capable clients", () => { + const { messenger, wsManager, clientA, clientB } = harness(); + wsManager.supportsClientCapability.mockImplementation((ws) => ws === clientA); + const message = { + type: "budget_status", + totalCost: 5, + maxSessionCostUsd: 10, + budgetExhausted: false, + costTrackingUnavailable: false, + } as const; + + messenger.broadcast(message); + + expect(wsManager.send).toHaveBeenCalledWith(clientA, message); + expect(wsManager.send).not.toHaveBeenCalledWith(clientB, message); + }); + it("sends a command to the connected sandbox socket", async () => { const { messenger, wsManager, sandbox } = harness(); diff --git a/packages/control-plane/src/session/messenger.ts b/packages/control-plane/src/session/messenger.ts index ea7226d09b..00ef04fd96 100644 --- a/packages/control-plane/src/session/messenger.ts +++ b/packages/control-plane/src/session/messenger.ts @@ -10,6 +10,7 @@ */ import type { ServerMessage } from "@open-inspect/shared/types/server-messages"; +import { SESSION_BUDGET_CAPABILITY } from "@open-inspect/shared/types/websocket"; import type { SandboxCommand } from "./types"; import type { SessionWebSocketManager } from "./websocket-manager"; @@ -20,9 +21,18 @@ import type { SessionWebSocketManager } from "./websocket-manager"; */ type DeliverySockets = Pick< SessionWebSocketManager, - "forEachClientSocket" | "getSandboxSocket" | "send" + "forEachClientSocket" | "getSandboxSocket" | "send" | "supportsClientCapability" >; +function requiresBudgetCapability(message: ServerMessage): boolean { + return ( + message.type === "budget_status" || + (message.type === "sandbox_event" && + message.event.type === "warning" && + message.event.scope === "budget") + ); +} + export class SandboxDeliveryUnavailableError extends Error { constructor(message = "No sandbox connected") { super(message); @@ -44,6 +54,12 @@ export class SessionMessengerImpl implements SessionMessenger { broadcast(message: ServerMessage): void { // Best effort; the registry handles per-client send failures. this.wsManager.forEachClientSocket("authenticated_only", (ws) => { + if ( + requiresBudgetCapability(message) && + !this.wsManager.supportsClientCapability(ws, SESSION_BUDGET_CAPABILITY) + ) { + return; + } this.wsManager.send(ws, message); }); } diff --git a/packages/control-plane/src/session/openai-token-refresh-service.test.ts b/packages/control-plane/src/session/openai-token-refresh-service.test.ts index 7ae75a703a..82ecd1303a 100644 --- a/packages/control-plane/src/session/openai-token-refresh-service.test.ts +++ b/packages/control-plane/src/session/openai-token-refresh-service.test.ts @@ -138,6 +138,10 @@ function createSession(overrides: Partial = {}): SessionRow { code_server_enabled: 0, vnc_enabled: 0, total_cost: 0, + max_cost_usd: null, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, sandbox_settings: null, environment_id: null, created_at: 1, diff --git a/packages/control-plane/src/session/pull-request-refresh.test.ts b/packages/control-plane/src/session/pull-request-refresh.test.ts index c0fa405be9..113637a618 100644 --- a/packages/control-plane/src/session/pull-request-refresh.test.ts +++ b/packages/control-plane/src/session/pull-request-refresh.test.ts @@ -26,6 +26,10 @@ function createSession(overrides: Partial = {}): SessionRow { code_server_enabled: 0, vnc_enabled: 0, total_cost: 0, + max_cost_usd: null, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, sandbox_settings: null, environment_id: null, created_at: 1, diff --git a/packages/control-plane/src/session/pull-request-service.per-branch.test.ts b/packages/control-plane/src/session/pull-request-service.per-branch.test.ts index b093e8cc30..796ec95df5 100644 --- a/packages/control-plane/src/session/pull-request-service.per-branch.test.ts +++ b/packages/control-plane/src/session/pull-request-service.per-branch.test.ts @@ -51,6 +51,10 @@ function createSession(overrides: Partial = {}): SessionRow { code_server_enabled: 0, vnc_enabled: 0, total_cost: 0, + max_cost_usd: null, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, sandbox_settings: null, environment_id: null, created_at: 1, diff --git a/packages/control-plane/src/session/pull-request-service.test.ts b/packages/control-plane/src/session/pull-request-service.test.ts index ac1d069db3..39f6c56f12 100644 --- a/packages/control-plane/src/session/pull-request-service.test.ts +++ b/packages/control-plane/src/session/pull-request-service.test.ts @@ -55,6 +55,10 @@ function createSession(overrides: Partial = {}): SessionRow { code_server_enabled: 0, vnc_enabled: 0, total_cost: 0, + max_cost_usd: null, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, sandbox_settings: null, environment_id: null, created_at: 1, diff --git a/packages/control-plane/src/session/repo-id-resolution.test.ts b/packages/control-plane/src/session/repo-id-resolution.test.ts index 8ea233aab1..5ad783eb19 100644 --- a/packages/control-plane/src/session/repo-id-resolution.test.ts +++ b/packages/control-plane/src/session/repo-id-resolution.test.ts @@ -54,6 +54,10 @@ function sessionRow(overrides: Partial = {}): SessionRow { code_server_enabled: 0, vnc_enabled: 0, total_cost: 0, + max_cost_usd: null, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, sandbox_settings: null, environment_id: null, created_at: 1, diff --git a/packages/control-plane/src/session/sandbox-events/execution.handler.ts b/packages/control-plane/src/session/sandbox-events/execution.handler.ts index 95b310c089..8d92804017 100644 --- a/packages/control-plane/src/session/sandbox-events/execution.handler.ts +++ b/packages/control-plane/src/session/sandbox-events/execution.handler.ts @@ -34,7 +34,11 @@ export class SandboxExecutionEventHandler { private readonly updateLastActivity: (timestamp: number) => void, private readonly scheduleInactivityCheck: () => Promise, private readonly processMessageQueue: () => Promise, - private readonly broadcastPromptQueue: () => void + private readonly broadcastPromptQueue: () => void, + private readonly ingestExecutionCost: ( + event: Extract, + now: number + ) => Promise ) {} async handleExecutionComplete( @@ -90,6 +94,10 @@ export class SandboxExecutionEventHandler { }); } + // The final cumulative report lands after completion so a limit crossed by + // the last step pauses the queue without failing an already-finished turn. + await this.ingestExecutionCost(event, context.now); + this.backgroundTasks.submit(() => this.triggerSnapshot("execution_complete"), { name: "snapshot.trigger", context: { reason: "execution_complete", message_id: event.messageId }, diff --git a/packages/control-plane/src/session/sandbox-events/processor.test.ts b/packages/control-plane/src/session/sandbox-events/processor.test.ts index 9faa897616..c893eac5b0 100644 --- a/packages/control-plane/src/session/sandbox-events/processor.test.ts +++ b/packages/control-plane/src/session/sandbox-events/processor.test.ts @@ -18,6 +18,7 @@ import type { EventRepository } from "../event-repository"; import type { MessageRepository } from "../message-repository"; import type { SessionStatusService } from "../session-status-service"; import type { SessionWebSocketManager } from "../websocket-manager"; +import type { SessionBudgetService } from "../budget-service"; function createPushSpec(repoOwner: string, repoName: string, targetBranch: string): GitPushSpec { return { @@ -37,7 +38,7 @@ function createProcessor() { updateSandboxHeartbeat: vi.fn(), recordReportedSandboxRuntimeVersion: vi.fn(), getProcessingMessage, - addSessionCost: vi.fn(), + addSessionCost: vi.fn(() => 1.25), recordMessageCompletion: vi.fn((event: { messageId: string }, completedAt: number) => { getProcessingMessage.mockReturnValue(null); return { @@ -89,6 +90,10 @@ function createProcessor() { child: vi.fn(), }; const backgroundTasks = createTestBackgroundTasks(); + const budgetService = { + ingestStepFinish: vi.fn(async () => {}), + ingestExecutionComplete: vi.fn(async (_event: unknown, _now: number) => {}), + }; // The real family composition, mirroring components.ts, so the suite keeps // pinning end-to-end processSandboxEvent behavior across the split. @@ -99,11 +104,11 @@ function createProcessor() { wsManager as unknown as SessionWebSocketManager, new SandboxStreamingEventHandler( backgroundTasks, - repository as unknown as SessionCoreRepository, eventRepository, callbackService as unknown as CallbackNotificationService, messenger, - updateLastActivity + updateLastActivity, + budgetService as unknown as SessionBudgetService ), new SandboxArtifactEventHandler( artifactRepository, @@ -123,7 +128,8 @@ function createProcessor() { updateLastActivity, scheduleInactivityCheck, processMessageQueue, - broadcastPromptQueue + broadcastPromptQueue, + (event, now) => budgetService.ingestExecutionComplete(event, now) ), new SandboxRuntimeEventHandler( repository as unknown as SessionCoreRepository, @@ -157,6 +163,7 @@ function createProcessor() { applySessionTitleUpdate, backgroundTasks, log, + budgetService, }; } @@ -391,7 +398,7 @@ describe("SessionSandboxEventProcessor", () => { }); }); - it("adds step_finish cost to session aggregate and broadcasts event", async () => { + it("routes step_finish through atomic budget ingestion", async () => { const h = createProcessor(); const event: SandboxEvent = { type: "step_finish", @@ -403,9 +410,31 @@ describe("SessionSandboxEventProcessor", () => { await h.processor.processSandboxEvent(event); - expect(h.repository.addSessionCost).toHaveBeenCalledWith(0.0123, expect.any(Number)); + expect(h.budgetService.ingestStepFinish).toHaveBeenCalledWith( + event, + "msg-1", + expect.any(Number) + ); expect(h.eventRepository.createEvent).not.toHaveBeenCalled(); - expect(h.broadcast).toHaveBeenCalledWith({ type: "sandbox_event", event }); + }); + + it("records unavailable cost tracking for positive-token steps without cost", async () => { + const h = createProcessor(); + const event: SandboxEvent = { + type: "step_finish", + messageId: "msg-1", + sandboxId: "sb-1", + timestamp: 1000, + tokens: { input: 10 }, + }; + + await h.processor.processSandboxEvent(event); + + expect(h.budgetService.ingestStepFinish).toHaveBeenCalledWith( + event, + "msg-1", + expect.any(Number) + ); }); it("does not add session cost for step_finish with NaN cost", async () => { @@ -420,9 +449,11 @@ describe("SessionSandboxEventProcessor", () => { await h.processor.processSandboxEvent(event); - expect(h.repository.addSessionCost).not.toHaveBeenCalled(); - expect(h.eventRepository.createEvent).not.toHaveBeenCalled(); - expect(h.broadcast).toHaveBeenCalledWith({ type: "sandbox_event", event }); + expect(h.budgetService.ingestStepFinish).toHaveBeenCalledWith( + event, + "msg-1", + expect.any(Number) + ); }); it("does not add session cost for step_finish with negative cost", async () => { @@ -437,8 +468,11 @@ describe("SessionSandboxEventProcessor", () => { await h.processor.processSandboxEvent(event); - expect(h.repository.addSessionCost).not.toHaveBeenCalled(); - expect(h.broadcast).toHaveBeenCalledWith({ type: "sandbox_event", event }); + expect(h.budgetService.ingestStepFinish).toHaveBeenCalledWith( + event, + "msg-1", + expect.any(Number) + ); }); it("does not add session cost for step_finish with Infinity cost", async () => { @@ -453,9 +487,11 @@ describe("SessionSandboxEventProcessor", () => { await h.processor.processSandboxEvent(event); - expect(h.repository.addSessionCost).not.toHaveBeenCalled(); - expect(h.eventRepository.createEvent).not.toHaveBeenCalled(); - expect(h.broadcast).toHaveBeenCalledWith({ type: "sandbox_event", event }); + expect(h.budgetService.ingestStepFinish).toHaveBeenCalledWith( + event, + "msg-1", + expect.any(Number) + ); }); it("completes processing message and schedules post-completion work", async () => { diff --git a/packages/control-plane/src/session/sandbox-events/processor.ts b/packages/control-plane/src/session/sandbox-events/processor.ts index 2a600d233b..01a41399ed 100644 --- a/packages/control-plane/src/session/sandbox-events/processor.ts +++ b/packages/control-plane/src/session/sandbox-events/processor.ts @@ -87,7 +87,7 @@ export class SessionSandboxEventProcessor { return; case "step_start": case "step_finish": - this.streaming.handleStep(event, context); + await this.streaming.handleStep(event, context); return; case "tool_call": this.streaming.handleToolCall(event, context); diff --git a/packages/control-plane/src/session/sandbox-events/streaming.handler.ts b/packages/control-plane/src/session/sandbox-events/streaming.handler.ts index 73fb0172ee..eebd8b1759 100644 --- a/packages/control-plane/src/session/sandbox-events/streaming.handler.ts +++ b/packages/control-plane/src/session/sandbox-events/streaming.handler.ts @@ -4,7 +4,7 @@ import type { BackgroundTasks } from "../../platform-ports"; import type { CallbackNotificationService } from "../callback-notification-service"; import type { EventRepository } from "../event-repository"; import type { SessionMessenger } from "../messenger"; -import type { SessionCoreRepository } from "../session-core-repository"; +import type { SessionBudgetService } from "../budget-service"; import { persistSandboxEvent, type SandboxEventContext } from "./context"; /** @@ -18,11 +18,11 @@ import { persistSandboxEvent, type SandboxEventContext } from "./context"; export class SandboxStreamingEventHandler { constructor( private readonly backgroundTasks: BackgroundTasks, - private readonly repository: SessionCoreRepository, private readonly eventRepository: EventRepository, private readonly callbackService: CallbackNotificationService, private readonly messenger: SessionMessenger, - private readonly updateLastActivity: (timestamp: number) => void + private readonly updateLastActivity: (timestamp: number) => void, + private readonly budgetService: SessionBudgetService ) {} handleToken(event: Extract, context: SandboxEventContext): void { @@ -47,20 +47,15 @@ export class SandboxStreamingEventHandler { this.messenger.broadcast({ type: "sandbox_event", event }); } - handleStep( + async handleStep( event: Extract, context: SandboxEventContext - ): void { + ): Promise { this.updateLastActivity(context.now); - if ( - event.type === "step_finish" && - typeof event.cost === "number" && - Number.isFinite(event.cost) && - event.cost > 0 - ) { - this.repository.addSessionCost(event.cost, context.now); - } this.messenger.broadcast({ type: "sandbox_event", event }); + if (event.type === "step_finish") { + await this.budgetService.ingestStepFinish(event, context.messageId, context.now); + } } handleToolCall( diff --git a/packages/control-plane/src/session/schema.test.ts b/packages/control-plane/src/session/schema.test.ts index 3625154b1d..87fc1034da 100644 --- a/packages/control-plane/src/session/schema.test.ts +++ b/packages/control-plane/src/session/schema.test.ts @@ -76,6 +76,12 @@ describe("applyMigrations", () => { vi.setSystemTime(1000); }); + it("has unique, strictly increasing migration ids", () => { + const ids = MIGRATIONS.map((migration) => migration.id); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).toEqual([...ids].sort((a, b) => a - b)); + }); + it("runs all migrations on a fresh DO", () => { // No applied IDs → SELECT returns empty applyMigrations(mock.sql); @@ -484,6 +490,51 @@ describe("applyMigrations", () => { ); }); + it("adds session budget fields for fresh and migrated sessions", () => { + const sessionTable = SCHEMA_SQL.split("CREATE TABLE IF NOT EXISTS session")[1]?.split(");")[0]; + expect(sessionTable).toContain("max_cost_usd REAL"); + expect(sessionTable).toContain("cost_warning_sent INTEGER NOT NULL DEFAULT 0"); + expect(sessionTable).toContain("budget_exhausted INTEGER NOT NULL DEFAULT 0"); + expect(sessionTable).toContain("cost_tracking_unavailable INTEGER NOT NULL DEFAULT 0"); + + expect(SCHEMA_SQL).toContain("reported_cost_usd REAL NOT NULL DEFAULT 0"); + expect(SCHEMA_SQL).toContain("capabilities TEXT NOT NULL DEFAULT '[]'"); + + const migration = MIGRATIONS.find((entry) => entry.id === 48); + expect(typeof migration?.run).toBe("function"); + const db = new DatabaseSync(":memory:"); + const sql = createDatabaseSql(db); + try { + db.exec("CREATE TABLE session (id TEXT PRIMARY KEY)"); + db.exec("CREATE TABLE messages (id TEXT PRIMARY KEY)"); + db.exec(`CREATE TABLE ws_client_mapping ( + ws_id TEXT PRIMARY KEY, + authorization_expires_at INTEGER NOT NULL DEFAULT 0 + )`); + const run = migration!.run as (sql: SqlStorage) => void; + run(sql); + expect(() => run(sql)).not.toThrow(); + expect(db.prepare("PRAGMA table_info(session)").all()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "max_cost_usd", type: "REAL" }), + expect.objectContaining({ name: "cost_warning_sent", type: "INTEGER" }), + expect.objectContaining({ name: "budget_exhausted", type: "INTEGER" }), + expect.objectContaining({ name: "cost_tracking_unavailable", type: "INTEGER" }), + ]) + ); + expect(db.prepare("PRAGMA table_info(ws_client_mapping)").all()).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "capabilities", type: "TEXT" })]) + ); + expect(db.prepare("PRAGMA table_info(messages)").all()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "reported_cost_usd", type: "REAL" }), + ]) + ); + } finally { + db.close(); + } + }); + it("adds Autofix admission metadata and indexes for fresh and migrated sessions", () => { const messagesTable = SCHEMA_SQL.split("CREATE TABLE IF NOT EXISTS messages")[1]?.split( ");" diff --git a/packages/control-plane/src/session/schema.ts b/packages/control-plane/src/session/schema.ts index 6e8e892364..39df43593a 100644 --- a/packages/control-plane/src/session/schema.ts +++ b/packages/control-plane/src/session/schema.ts @@ -56,6 +56,8 @@ const TERMINAL_MESSAGE_PROJECTION_TABLE_SQL = `CREATE TABLE IF NOT EXISTS termin next_attempt_at INTEGER NOT NULL );`; +const DEFAULT_WS_CLIENT_CAPABILITIES_JSON = "[]"; + export const SCHEMA_SQL = ` -- Core session state CREATE TABLE IF NOT EXISTS session ( @@ -80,6 +82,10 @@ CREATE TABLE IF NOT EXISTS session ( vnc_enabled INTEGER NOT NULL DEFAULT 0, -- 0 = disabled, 1 = enabled (opt-in) total_cost REAL NOT NULL DEFAULT 0, -- Running session cost from step_finish events sandbox_settings TEXT DEFAULT NULL, -- JSON blob of SandboxSettings (resolved at session creation) + max_cost_usd REAL, -- Mutable effective session cost limit; NULL = unlimited + cost_warning_sent INTEGER NOT NULL DEFAULT 0, -- One-time warning latch for the current limit + budget_exhausted INTEGER NOT NULL DEFAULT 0, -- Pauses prompt admission and dispatch + cost_tracking_unavailable INTEGER NOT NULL DEFAULT 0, -- At least one positive-token step omitted cost environment_id TEXT, -- Launch environment provenance; NULL for repo-launched/ad-hoc sessions created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, @@ -131,6 +137,7 @@ CREATE TABLE IF NOT EXISTS messages ( status TEXT DEFAULT 'pending', -- 'pending', 'processing', 'completed', 'failed' error_message TEXT, -- If status='failed' stop_confirmation_deadline INTEGER, -- Blocks dispatch until stop is confirmed or times out + reported_cost_usd REAL NOT NULL DEFAULT 0, -- Highest cumulative cost the runtime reported for this turn created_at INTEGER NOT NULL, started_at INTEGER, -- When processing began completed_at INTEGER, -- When processing finished @@ -215,6 +222,7 @@ CREATE TABLE IF NOT EXISTS ws_client_mapping ( ws_id TEXT PRIMARY KEY, participant_id TEXT NOT NULL, client_id TEXT, + capabilities TEXT NOT NULL DEFAULT '${DEFAULT_WS_CLIENT_CAPABILITIES_JSON}', created_at INTEGER NOT NULL, authorization_expires_at INTEGER NOT NULL, FOREIGN KEY (participant_id) REFERENCES participants(id) @@ -648,6 +656,33 @@ export const MIGRATIONS: readonly SchemaMigration[] = [ description: "Persist terminal message projections awaiting retry", run: TERMINAL_MESSAGE_PROJECTION_TABLE_SQL, }, + { + id: 48, + description: "Add session budget state, message reported cost, and client capabilities", + run: (sql) => { + runMigration(sql, `ALTER TABLE session ADD COLUMN max_cost_usd REAL`); + runMigration( + sql, + `ALTER TABLE session ADD COLUMN cost_warning_sent INTEGER NOT NULL DEFAULT 0` + ); + runMigration( + sql, + `ALTER TABLE session ADD COLUMN budget_exhausted INTEGER NOT NULL DEFAULT 0` + ); + runMigration( + sql, + `ALTER TABLE session ADD COLUMN cost_tracking_unavailable INTEGER NOT NULL DEFAULT 0` + ); + runMigration( + sql, + `ALTER TABLE messages ADD COLUMN reported_cost_usd REAL NOT NULL DEFAULT 0` + ); + runMigration( + sql, + `ALTER TABLE ws_client_mapping ADD COLUMN capabilities TEXT NOT NULL DEFAULT '${DEFAULT_WS_CLIENT_CAPABILITIES_JSON}'` + ); + }, + }, ]; /** diff --git a/packages/control-plane/src/session/session-core-repository.test.ts b/packages/control-plane/src/session/session-core-repository.test.ts index bcfe4c308f..e3d4c679e5 100644 --- a/packages/control-plane/src/session/session-core-repository.test.ts +++ b/packages/control-plane/src/session/session-core-repository.test.ts @@ -110,7 +110,7 @@ describe("SessionCoreRepository", () => { }); expect(mock.calls.length).toBe(1); - expect(mock.calls[0].query).toContain("INSERT OR REPLACE INTO session"); + expect(mock.calls[0].query).toContain("ON CONFLICT(id) DO NOTHING"); expect(mock.calls[0].params).toEqual([ "sess-1", "test-session", @@ -129,6 +129,7 @@ describe("SessionCoreRepository", () => { 0, null, null, + null, 1000, 2000, ]); @@ -236,16 +237,36 @@ describe("SessionCoreRepository", () => { }); describe("addSessionCost", () => { - it("increments total_cost and updates updated_at for the current session", () => { - repo.addSessionCost(0.0123, 5000); + it("increments total_cost and returns the accumulated value", () => { + mock.setOne({ total_cost: 1.25 }); + expect(repo.addSessionCost(0.0123, 5000)).toBe(1.25); expect(mock.calls.length).toBe(1); expect(mock.calls[0].query).toContain("SET total_cost = total_cost + ?"); expect(mock.calls[0].query).toContain("updated_at = ?"); + expect(mock.calls[0].query).toContain("RETURNING total_cost"); expect(mock.calls[0].params).toEqual([0.0123, 5000]); }); }); + describe("budget state", () => { + it("updates the live limit and clears latches", () => { + repo.setSessionBudget(20, { warningSent: false, exhausted: false }, 5000); + + expect(mock.calls[0].query).toContain("max_cost_usd = ?"); + expect(mock.calls[0].query).toContain("cost_warning_sent = ?"); + expect(mock.calls[0].query).toContain("budget_exhausted = ?"); + expect(mock.calls[0].params).toEqual([20, 0, 0, 5000]); + }); + + it("latches unavailable cost tracking", () => { + repo.markCostTrackingUnavailable(5000); + + expect(mock.calls[0].query).toContain("cost_tracking_unavailable = 1"); + expect(mock.calls[0].params).toEqual([5000]); + }); + }); + // === SESSION REPOSITORIES === describe("replaceSessionRepositories", () => { diff --git a/packages/control-plane/src/session/session-core-repository.ts b/packages/control-plane/src/session/session-core-repository.ts index cc4e91d4cf..f80e3b1eb6 100644 --- a/packages/control-plane/src/session/session-core-repository.ts +++ b/packages/control-plane/src/session/session-core-repository.ts @@ -22,6 +22,7 @@ export interface UpsertSessionData { codeServerEnabled?: boolean; vncEnabled?: boolean; sandboxSettings?: string | null; + maxCostUsd?: number | null; /** Launch environment provenance; null for repo-launched/ad-hoc sessions. */ environmentId?: string | null; createdAt: number; @@ -72,8 +73,9 @@ export class SessionCoreRepository { } this.sql.exec( - `INSERT OR REPLACE INTO session (id, session_name, title, repo_owner, repo_name, repo_id, base_branch, model, reasoning_effort, status, parent_session_id, spawn_source, spawn_depth, code_server_enabled, vnc_enabled, sandbox_settings, environment_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO session (id, session_name, title, repo_owner, repo_name, repo_id, base_branch, model, reasoning_effort, status, parent_session_id, spawn_source, spawn_depth, code_server_enabled, vnc_enabled, sandbox_settings, environment_id, max_cost_usd, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO NOTHING`, data.id, data.sessionName, data.title, @@ -91,6 +93,7 @@ export class SessionCoreRepository { data.vncEnabled ? 1 : 0, data.sandboxSettings ?? null, data.environmentId ?? null, + data.maxCostUsd ?? null, data.createdAt, data.updatedAt ); @@ -147,12 +150,57 @@ export class SessionCoreRepository { ); } - addSessionCost(cost: number, updatedAt: number): void { + addSessionCost(cost: number, updatedAt: number): number { + const row = this.sql + .exec( + `UPDATE session + SET total_cost = total_cost + ?, updated_at = ? + WHERE id = (SELECT id FROM session LIMIT 1) + RETURNING total_cost`, + cost, + updatedAt + ) + .one() as { total_cost: number }; + return row.total_cost; + } + + setSessionBudget( + maxCostUsd: number | null, + state: { warningSent: boolean; exhausted: boolean }, + updatedAt: number + ): void { this.sql.exec( `UPDATE session - SET total_cost = total_cost + ?, updated_at = ? + SET max_cost_usd = ?, cost_warning_sent = ?, budget_exhausted = ?, updated_at = ? + WHERE id = (SELECT id FROM session LIMIT 1)`, + maxCostUsd, + state.warningSent ? 1 : 0, + state.exhausted ? 1 : 0, + updatedAt + ); + } + + markCostTrackingUnavailable(updatedAt: number): void { + this.sql.exec( + `UPDATE session + SET cost_tracking_unavailable = 1, updated_at = ? + WHERE id = (SELECT id FROM session LIMIT 1)`, + updatedAt + ); + } + + markCostWarningSent(updatedAt: number): void { + this.sql.exec( + `UPDATE session SET cost_warning_sent = 1, updated_at = ? + WHERE id = (SELECT id FROM session LIMIT 1)`, + updatedAt + ); + } + + markBudgetExhausted(updatedAt: number): void { + this.sql.exec( + `UPDATE session SET budget_exhausted = 1, updated_at = ? WHERE id = (SELECT id FROM session LIMIT 1)`, - cost, updatedAt ); } diff --git a/packages/control-plane/src/session/session-target-secrets.test.ts b/packages/control-plane/src/session/session-target-secrets.test.ts index 1736ec1533..eb2bdd4e4e 100644 --- a/packages/control-plane/src/session/session-target-secrets.test.ts +++ b/packages/control-plane/src/session/session-target-secrets.test.ts @@ -40,6 +40,10 @@ function session(overrides: Partial = {}): SessionRow { code_server_enabled: 0, vnc_enabled: 0, total_cost: 0, + max_cost_usd: null, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, sandbox_settings: null, environment_id: null, created_at: 1, diff --git a/packages/control-plane/src/session/snapshot-reader.ts b/packages/control-plane/src/session/snapshot-reader.ts index b5b2b27b32..4cf61f6666 100644 --- a/packages/control-plane/src/session/snapshot-reader.ts +++ b/packages/control-plane/src/session/snapshot-reader.ts @@ -103,6 +103,9 @@ export class SessionSnapshotReader { isProcessing: this.getIsProcessing(), parentSessionId: session.parent_session_id, totalCost: session.total_cost ?? 0, + maxSessionCostUsd: session.max_cost_usd, + budgetExhausted: session.budget_exhausted === 1, + costTrackingUnavailable: session.cost_tracking_unavailable === 1, codeServerUrl: sandbox?.code_server_url ?? null, vncUrl: sandbox?.vnc_url ?? null, tunnelUrls: sandbox?.tunnel_urls diff --git a/packages/control-plane/src/session/types.ts b/packages/control-plane/src/session/types.ts index 626b003953..246845cb31 100644 --- a/packages/control-plane/src/session/types.ts +++ b/packages/control-plane/src/session/types.ts @@ -48,6 +48,10 @@ export interface SessionRow { vnc_enabled: number; // 0 = disabled (default), 1 = enabled total_cost: number; // Running aggregate of step_finish event costs sandbox_settings: string | null; // JSON blob of SandboxSettings + max_cost_usd: number | null; // Mutable effective session cost limit; NULL = unlimited + cost_warning_sent: number; // 0 = warning not sent, 1 = sent for current limit + budget_exhausted: number; // 0 = promptable by budget, 1 = paused + cost_tracking_unavailable: number; // 1 when a positive-token step omitted cost environment_id: string | null; // Launch environment provenance; NULL for repo-launched/ad-hoc sessions created_at: number; updated_at: number; diff --git a/packages/control-plane/src/session/user-env-resolver.test.ts b/packages/control-plane/src/session/user-env-resolver.test.ts index 1b5813bf2b..c4a96899d0 100644 --- a/packages/control-plane/src/session/user-env-resolver.test.ts +++ b/packages/control-plane/src/session/user-env-resolver.test.ts @@ -183,6 +183,10 @@ function sessionRow(overrides: Partial = {}): SessionRow { code_server_enabled: 0, vnc_enabled: 0, total_cost: 0, + max_cost_usd: null, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, sandbox_settings: null, environment_id: null, created_at: 1, diff --git a/packages/control-plane/src/session/websocket-manager.ts b/packages/control-plane/src/session/websocket-manager.ts index a4090ffbf4..9a00adffea 100644 --- a/packages/control-plane/src/session/websocket-manager.ts +++ b/packages/control-plane/src/session/websocket-manager.ts @@ -7,13 +7,15 @@ */ import type { Logger } from "../logger"; +import type { ClientCapability } from "@open-inspect/shared/types/websocket"; import type { AlarmScheduler } from "../platform-ports"; import type { ClientInfo } from "../types"; import type { ConnectionClassification } from "./ports"; import type { SandboxRepository } from "./sandbox-repository"; -import type { - WsClientMappingRepository, - WsClientMappingResult, +import { + parseClientCapabilities, + type WsClientMappingRepository, + type WsClientMappingResult, } from "./ws-client-mapping-repository"; import { WS_AUTHORIZATION_REVOKED_REASON, @@ -67,6 +69,7 @@ export interface SessionWebSocketManager { /** Schedule, synchronize, and atomically publish a client authorization lease. */ activateClient(ws: WebSocket, info: ClientInfo, synchronize: () => boolean): Promise; + supportsClientCapability(ws: WebSocket, capability: ClientCapability): boolean; /** Return a live client or its persisted hibernation mapping, rejecting expired leases. */ lookupClient(ws: WebSocket): ClientLookup; @@ -306,6 +309,7 @@ export class SessionWebSocketManagerImpl implements SessionWebSocketManager { wsId: parsed.wsId, participantId: info.participantId, clientId: info.clientId, + capabilities: info.capabilities, createdAt: Date.now(), authorizationExpiresAt: info.authorizationExpiresAt, }); @@ -332,6 +336,15 @@ export class SessionWebSocketManagerImpl implements SessionWebSocketManager { if (nextExpiry !== null) await this.alarmScheduler.schedule(nextExpiry); } + supportsClientCapability(ws: WebSocket, capability: ClientCapability): boolean { + const lookup = this.lookupClient(ws); + if (lookup.kind === "cached") { + return lookup.client.capabilities?.includes(capability) ?? false; + } + if (lookup.kind !== "recovered") return false; + return parseClientCapabilities(lookup.mapping.capabilities).includes(capability); + } + setClientSynchronizing(ws: WebSocket, synchronizing: boolean): void { if (synchronizing) this.synchronizingClients.add(ws); else this.synchronizingClients.delete(ws); diff --git a/packages/control-plane/src/session/ws-client-mapping-repository.test.ts b/packages/control-plane/src/session/ws-client-mapping-repository.test.ts index 8ee7fd0345..d4d0759c8a 100644 --- a/packages/control-plane/src/session/ws-client-mapping-repository.test.ts +++ b/packages/control-plane/src/session/ws-client-mapping-repository.test.ts @@ -28,11 +28,19 @@ describe("WsClientMappingRepository", () => { wsId: "ws-1", participantId: "p-1", clientId: "client-1", + capabilities: ["session_budget"], createdAt: 1000, authorizationExpiresAt: 2000, }); expect(mock.calls[0].query).toContain("INSERT OR REPLACE INTO ws_client_mapping"); - expect(mock.calls[0].params).toEqual(["ws-1", "p-1", "client-1", 1000, 2000]); + expect(mock.calls[0].params).toEqual([ + "ws-1", + "p-1", + "client-1", + '["session_budget"]', + 1000, + 2000, + ]); }); it("restores a mapping with joined participant data", () => { diff --git a/packages/control-plane/src/session/ws-client-mapping-repository.ts b/packages/control-plane/src/session/ws-client-mapping-repository.ts index 846a3b9573..a21021e309 100644 --- a/packages/control-plane/src/session/ws-client-mapping-repository.ts +++ b/packages/control-plane/src/session/ws-client-mapping-repository.ts @@ -1,4 +1,8 @@ import type { SqlStorage } from "./sql-storage"; +import { + clientCapabilitySchema, + type ClientCapability, +} from "@open-inspect/shared/types/websocket"; /** WS client mapping result for hibernation recovery. */ export interface WsClientMappingResult { @@ -10,6 +14,7 @@ export interface WsClientMappingResult { scm_login: string | null; /** Dormant legacy column may still be present on older mapping fixtures. */ auth_name?: string | null; + capabilities?: string; /** Wall-clock time when the persisted authorization lease expires. */ authorization_expires_at: number; } @@ -20,6 +25,7 @@ export interface WsClientMappingData { participantId: string; clientId: string; createdAt: number; + capabilities?: ClientCapability[]; /** Wall-clock time when the persisted authorization lease expires. */ authorizationExpiresAt: number; } @@ -32,11 +38,12 @@ export class WsClientMappingRepository { upsertWsClientMapping(data: WsClientMappingData): void { this.sql.exec( `INSERT OR REPLACE INTO ws_client_mapping - (ws_id, participant_id, client_id, created_at, authorization_expires_at) - VALUES (?, ?, ?, ?, ?)`, + (ws_id, participant_id, client_id, capabilities, created_at, authorization_expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, data.wsId, data.participantId, data.clientId, + JSON.stringify(data.capabilities ?? []), data.createdAt, data.authorizationExpiresAt ); @@ -47,7 +54,7 @@ export class WsClientMappingRepository { // Keep this indexed JOIN in one query: both tables share the session-local store, // and this read is on the hibernation-recovery hot path. const result = this.sql.exec( - `SELECT m.participant_id, m.client_id, m.authorization_expires_at, + `SELECT m.participant_id, m.client_id, m.capabilities, m.authorization_expires_at, p.user_id, p.canonical_user_id, p.scm_name, p.scm_login FROM ws_client_mapping m JOIN participants p ON m.participant_id = p.id @@ -83,3 +90,17 @@ export class WsClientMappingRepository { return rows[0]?.expires_at ?? null; } } + +/** Decode a persisted capabilities column; malformed or unknown entries are dropped. */ +export function parseClientCapabilities(raw: string | null | undefined): ClientCapability[] { + try { + const parsed: unknown = JSON.parse(raw ?? "[]"); + if (!Array.isArray(parsed)) return []; + return parsed.flatMap((entry) => { + const result = clientCapabilitySchema.safeParse(entry); + return result.success ? [result.data] : []; + }); + } catch { + return []; + } +} diff --git a/packages/control-plane/src/session/xai-token-refresh-service.test.ts b/packages/control-plane/src/session/xai-token-refresh-service.test.ts index 61716a7eda..5de7f1e88d 100644 --- a/packages/control-plane/src/session/xai-token-refresh-service.test.ts +++ b/packages/control-plane/src/session/xai-token-refresh-service.test.ts @@ -93,6 +93,10 @@ function session(overrides: Partial = {}): SessionRow { code_server_enabled: 0, vnc_enabled: 0, total_cost: 0, + max_cost_usd: null, + cost_warning_sent: 0, + budget_exhausted: 0, + cost_tracking_unavailable: 0, sandbox_settings: null, environment_id: null, created_at: 1, diff --git a/packages/control-plane/src/types.ts b/packages/control-plane/src/types.ts index d9879b3abd..fedf81a56c 100644 --- a/packages/control-plane/src/types.ts +++ b/packages/control-plane/src/types.ts @@ -3,6 +3,7 @@ */ import type { ImageBuildFinalizationJob } from "./image-builds/finalization-job"; +import type { ClientCapability } from "@open-inspect/shared/types/websocket"; // Environment bindings export interface Env { @@ -123,5 +124,6 @@ export interface ClientInfo { /** Wall-clock time when this connection's authorization lease expires. */ authorizationExpiresAt: number; ws: WebSocket; + capabilities?: ClientCapability[]; lastFetchHistoryAtMs?: number; } diff --git a/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap b/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap index f4311a7e8a..b23bb3dcfe 100644 --- a/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap +++ b/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap @@ -32,50 +32,51 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"PATCH /sessions/:id/title","pathname":"/sessions/fixture-27-id%2Fraw/title","groups":{"id":"fixture-27-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/title$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /sessions/:id/archive","pathname":"/sessions/fixture-28-id%2Fraw/archive","groups":{"id":"fixture-28-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/archive$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /sessions/:id/unarchive","pathname":"/sessions/fixture-29-id%2Fraw/unarchive","groups":{"id":"fixture-29-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/unarchive$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/ws-token","pathname":"/sessions/fixture-30-id%2Fraw/ws-token","groups":{"id":"fixture-30-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/ws-token$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/prompt","pathname":"/sessions/fixture-31-id%2Fraw/prompt","groups":{"id":"fixture-31-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/prompt$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/pull-requests/refresh","pathname":"/sessions/fixture-32-id%2Fraw/pull-requests/refresh","groups":{"id":"fixture-32-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/pull-requests\\\\/refresh$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/media","pathname":"/sessions/fixture-33-id%2Fraw/media","groups":{"id":"fixture-33-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/media$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/media/:artifactId","pathname":"/sessions/fixture-34-id%2Fraw/media/fixture-34-artifactId%2Fraw","groups":{"id":"fixture-34-id%2Fraw","artifactId":"fixture-34-artifactId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/media\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/attachments","pathname":"/sessions/fixture-35-id%2Fraw/attachments","groups":{"id":"fixture-35-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/attachments$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/attachments/:attachmentId","pathname":"/sessions/fixture-36-id%2Fraw/attachments/fixture-36-attachmentId%2Fraw","groups":{"id":"fixture-36-id%2Fraw","attachmentId":"fixture-36-attachmentId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/attachments\\\\/(?[^/]+)$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/diff","pathname":"/sessions/fixture-37-id%2Fraw/diff","groups":{"id":"fixture-37-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /sessions/:id/diff","pathname":"/sessions/fixture-38-id%2Fraw/diff","groups":{"id":"fixture-38-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/diff/failure","pathname":"/sessions/fixture-39-id%2Fraw/diff/failure","groups":{"id":"fixture-39-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff\\\\/failure$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/diff/:revisionId/files/:fileId","pathname":"/sessions/fixture-40-id%2Fraw/diff/fixture-40-revisionId%2Fraw/files/fixture-40-fileId%2Fraw","groups":{"id":"fixture-40-id%2Fraw","revisionId":"fixture-40-revisionId%2Fraw","fileId":"fixture-40-fileId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff\\\\/(?[^/]+)\\\\/files\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/diff/retry","pathname":"/sessions/fixture-41-id%2Fraw/diff/retry","groups":{"id":"fixture-41-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff\\\\/retry$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/skills","pathname":"/sessions/fixture-42-id%2Fraw/skills","groups":{"id":"fixture-42-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/skills$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/sandbox-skills","pathname":"/sessions/fixture-43-id%2Fraw/sandbox-skills","groups":{"id":"fixture-43-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/sandbox-skills$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children","pathname":"/sessions/fixture-44-id%2Fraw/children","groups":{"id":"fixture-44-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.create"},{"kind":"permission","permission":"sessions.collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/children","pathname":"/sessions/fixture-45-id%2Fraw/children","groups":{"id":"fixture-45-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/children/:childId","pathname":"/sessions/fixture-46-id%2Fraw/children/fixture-46-childId%2Fraw","groups":{"id":"fixture-46-id%2Fraw","childId":"fixture-46-childId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children\\\\/(?[^/]+)$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children/:childId/cancel","pathname":"/sessions/fixture-47-id%2Fraw/children/fixture-47-childId%2Fraw/cancel","groups":{"id":"fixture-47-id%2Fraw","childId":"fixture-47-childId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children\\\\/(?[^/]+)\\\\/cancel$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children/:childId/prompt","pathname":"/sessions/fixture-48-id%2Fraw/children/fixture-48-childId%2Fraw/prompt","groups":{"id":"fixture-48-id%2Fraw","childId":"fixture-48-childId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children\\\\/(?[^/]+)\\\\/prompt$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/slack-notify","pathname":"/sessions/fixture-49-id%2Fraw/slack-notify","groups":{"id":"fixture-49-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/slack-notify$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /sessions/:id/budget","pathname":"/sessions/fixture-30-id%2Fraw/budget","groups":{"id":"fixture-30-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/budget$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/ws-token","pathname":"/sessions/fixture-31-id%2Fraw/ws-token","groups":{"id":"fixture-31-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/ws-token$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/prompt","pathname":"/sessions/fixture-32-id%2Fraw/prompt","groups":{"id":"fixture-32-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/prompt$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/pull-requests/refresh","pathname":"/sessions/fixture-33-id%2Fraw/pull-requests/refresh","groups":{"id":"fixture-33-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/pull-requests\\\\/refresh$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/media","pathname":"/sessions/fixture-34-id%2Fraw/media","groups":{"id":"fixture-34-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/media$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/media/:artifactId","pathname":"/sessions/fixture-35-id%2Fraw/media/fixture-35-artifactId%2Fraw","groups":{"id":"fixture-35-id%2Fraw","artifactId":"fixture-35-artifactId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/media\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/attachments","pathname":"/sessions/fixture-36-id%2Fraw/attachments","groups":{"id":"fixture-36-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/attachments$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/attachments/:attachmentId","pathname":"/sessions/fixture-37-id%2Fraw/attachments/fixture-37-attachmentId%2Fraw","groups":{"id":"fixture-37-id%2Fraw","attachmentId":"fixture-37-attachmentId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/attachments\\\\/(?[^/]+)$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/diff","pathname":"/sessions/fixture-38-id%2Fraw/diff","groups":{"id":"fixture-38-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /sessions/:id/diff","pathname":"/sessions/fixture-39-id%2Fraw/diff","groups":{"id":"fixture-39-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/diff/failure","pathname":"/sessions/fixture-40-id%2Fraw/diff/failure","groups":{"id":"fixture-40-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff\\\\/failure$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/diff/:revisionId/files/:fileId","pathname":"/sessions/fixture-41-id%2Fraw/diff/fixture-41-revisionId%2Fraw/files/fixture-41-fileId%2Fraw","groups":{"id":"fixture-41-id%2Fraw","revisionId":"fixture-41-revisionId%2Fraw","fileId":"fixture-41-fileId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff\\\\/(?[^/]+)\\\\/files\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/diff/retry","pathname":"/sessions/fixture-42-id%2Fraw/diff/retry","groups":{"id":"fixture-42-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff\\\\/retry$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/skills","pathname":"/sessions/fixture-43-id%2Fraw/skills","groups":{"id":"fixture-43-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/skills$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/sandbox-skills","pathname":"/sessions/fixture-44-id%2Fraw/sandbox-skills","groups":{"id":"fixture-44-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/sandbox-skills$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children","pathname":"/sessions/fixture-45-id%2Fraw/children","groups":{"id":"fixture-45-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.create"},{"kind":"permission","permission":"sessions.collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/children","pathname":"/sessions/fixture-46-id%2Fraw/children","groups":{"id":"fixture-46-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/children/:childId","pathname":"/sessions/fixture-47-id%2Fraw/children/fixture-47-childId%2Fraw","groups":{"id":"fixture-47-id%2Fraw","childId":"fixture-47-childId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children\\\\/(?[^/]+)$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children/:childId/cancel","pathname":"/sessions/fixture-48-id%2Fraw/children/fixture-48-childId%2Fraw/cancel","groups":{"id":"fixture-48-id%2Fraw","childId":"fixture-48-childId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children\\\\/(?[^/]+)\\\\/cancel$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children/:childId/prompt","pathname":"/sessions/fixture-49-id%2Fraw/children/fixture-49-childId%2Fraw/prompt","groups":{"id":"fixture-49-id%2Fraw","childId":"fixture-49-childId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children\\\\/(?[^/]+)\\\\/prompt$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/slack-notify","pathname":"/sessions/fixture-50-id%2Fraw/slack-notify","groups":{"id":"fixture-50-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/slack-notify$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /repos","pathname":"/repos","groups":{},"pattern":"^\\\\/repos$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /repos/:owner/:name/metadata","pathname":"/repos/fixture-51-owner%2Fraw/fixture-51-name%2Fraw/metadata","groups":{"owner":"fixture-51-owner%2Fraw","name":"fixture-51-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/metadata$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/metadata","pathname":"/repos/fixture-52-owner%2Fraw/fixture-52-name%2Fraw/metadata","groups":{"owner":"fixture-52-owner%2Fraw","name":"fixture-52-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/metadata$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/branches","pathname":"/repos/fixture-53-owner%2Fraw/fixture-53-name%2Fraw/branches","groups":{"owner":"fixture-53-owner%2Fraw","name":"fixture-53-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/branches$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /repos/:owner/:name/secrets","pathname":"/repos/fixture-54-owner%2Fraw/fixture-54-name%2Fraw/secrets","groups":{"owner":"fixture-54-owner%2Fraw","name":"fixture-54-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/secrets","pathname":"/repos/fixture-55-owner%2Fraw/fixture-55-name%2Fraw/secrets","groups":{"owner":"fixture-55-owner%2Fraw","name":"fixture-55-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /repos/:owner/:name/secrets/:key","pathname":"/repos/fixture-56-owner%2Fraw/fixture-56-name%2Fraw/secrets/fixture-56-key%2Fraw","groups":{"owner":"fixture-56-owner%2Fraw","name":"fixture-56-name%2Fraw","key":"fixture-56-key%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/secrets\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /repos/:owner/:name/metadata","pathname":"/repos/fixture-52-owner%2Fraw/fixture-52-name%2Fraw/metadata","groups":{"owner":"fixture-52-owner%2Fraw","name":"fixture-52-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/metadata$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/metadata","pathname":"/repos/fixture-53-owner%2Fraw/fixture-53-name%2Fraw/metadata","groups":{"owner":"fixture-53-owner%2Fraw","name":"fixture-53-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/metadata$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/branches","pathname":"/repos/fixture-54-owner%2Fraw/fixture-54-name%2Fraw/branches","groups":{"owner":"fixture-54-owner%2Fraw","name":"fixture-54-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/branches$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /repos/:owner/:name/secrets","pathname":"/repos/fixture-55-owner%2Fraw/fixture-55-name%2Fraw/secrets","groups":{"owner":"fixture-55-owner%2Fraw","name":"fixture-55-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/secrets","pathname":"/repos/fixture-56-owner%2Fraw/fixture-56-name%2Fraw/secrets","groups":{"owner":"fixture-56-owner%2Fraw","name":"fixture-56-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /repos/:owner/:name/secrets/:key","pathname":"/repos/fixture-57-owner%2Fraw/fixture-57-name%2Fraw/secrets/fixture-57-key%2Fraw","groups":{"owner":"fixture-57-owner%2Fraw","name":"fixture-57-name%2Fraw","key":"fixture-57-key%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/secrets\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PUT /secrets","pathname":"/secrets","groups":{},"pattern":"^\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /secrets","pathname":"/secrets","groups":{},"pattern":"^\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /secrets/:key","pathname":"/secrets/fixture-59-key%2Fraw","groups":{"key":"fixture-59-key%2Fraw"},"pattern":"^\\\\/secrets\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /secrets/:key","pathname":"/secrets/fixture-60-key%2Fraw","groups":{"key":"fixture-60-key%2Fraw"},"pattern":"^\\\\/secrets\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /environments","pathname":"/environments","groups":{},"pattern":"^\\\\/environments$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /environments","pathname":"/environments","groups":{},"pattern":"^\\\\/environments$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /environments/:id","pathname":"/environments/fixture-62-id%2Fraw","groups":{"id":"fixture-62-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /environments/:id","pathname":"/environments/fixture-63-id%2Fraw","groups":{"id":"fixture-63-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /environments/:id","pathname":"/environments/fixture-64-id%2Fraw","groups":{"id":"fixture-64-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /environments/:id/secrets","pathname":"/environments/fixture-65-id%2Fraw/secrets","groups":{"id":"fixture-65-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /environments/:id/secrets","pathname":"/environments/fixture-66-id%2Fraw/secrets","groups":{"id":"fixture-66-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /environments/:id/secrets/import","pathname":"/environments/fixture-67-id%2Fraw/secrets/import","groups":{"id":"fixture-67-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)\\\\/secrets\\\\/import$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /environments/:id/secrets/:key","pathname":"/environments/fixture-68-id%2Fraw/secrets/fixture-68-key%2Fraw","groups":{"id":"fixture-68-id%2Fraw","key":"fixture-68-key%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)\\\\/secrets\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /environments/:id","pathname":"/environments/fixture-63-id%2Fraw","groups":{"id":"fixture-63-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /environments/:id","pathname":"/environments/fixture-64-id%2Fraw","groups":{"id":"fixture-64-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /environments/:id","pathname":"/environments/fixture-65-id%2Fraw","groups":{"id":"fixture-65-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /environments/:id/secrets","pathname":"/environments/fixture-66-id%2Fraw/secrets","groups":{"id":"fixture-66-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /environments/:id/secrets","pathname":"/environments/fixture-67-id%2Fraw/secrets","groups":{"id":"fixture-67-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /environments/:id/secrets/import","pathname":"/environments/fixture-68-id%2Fraw/secrets/import","groups":{"id":"fixture-68-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)\\\\/secrets\\\\/import$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /environments/:id/secrets/:key","pathname":"/environments/fixture-69-id%2Fraw/secrets/fixture-69-key%2Fraw","groups":{"id":"fixture-69-id%2Fraw","key":"fixture-69-key%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)\\\\/secrets\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /image-builds/build-complete","pathname":"/image-builds/build-complete","groups":{},"pattern":"^\\\\/image-builds\\\\/build-complete$","authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /image-builds/build-failed","pathname":"/image-builds/build-failed","groups":{},"pattern":"^\\\\/image-builds\\\\/build-failed$","authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /image-builds/trigger/environment/:id","pathname":"/image-builds/trigger/environment/fixture-71-id%2Fraw","groups":{"id":"fixture-71-id%2Fraw"},"pattern":"^\\\\/image-builds\\\\/trigger\\\\/environment\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /image-builds/trigger/repo/:owner/:name","pathname":"/image-builds/trigger/repo/fixture-72-owner%2Fraw/fixture-72-name%2Fraw","groups":{"owner":"fixture-72-owner%2Fraw","name":"fixture-72-name%2Fraw"},"pattern":"^\\\\/image-builds\\\\/trigger\\\\/repo\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /image-builds/toggle/repo/:owner/:name","pathname":"/image-builds/toggle/repo/fixture-73-owner%2Fraw/fixture-73-name%2Fraw","groups":{"owner":"fixture-73-owner%2Fraw","name":"fixture-73-name%2Fraw"},"pattern":"^\\\\/image-builds\\\\/toggle\\\\/repo\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /image-builds/trigger/environment/:id","pathname":"/image-builds/trigger/environment/fixture-72-id%2Fraw","groups":{"id":"fixture-72-id%2Fraw"},"pattern":"^\\\\/image-builds\\\\/trigger\\\\/environment\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /image-builds/trigger/repo/:owner/:name","pathname":"/image-builds/trigger/repo/fixture-73-owner%2Fraw/fixture-73-name%2Fraw","groups":{"owner":"fixture-73-owner%2Fraw","name":"fixture-73-name%2Fraw"},"pattern":"^\\\\/image-builds\\\\/trigger\\\\/repo\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /image-builds/toggle/repo/:owner/:name","pathname":"/image-builds/toggle/repo/fixture-74-owner%2Fraw/fixture-74-name%2Fraw","groups":{"owner":"fixture-74-owner%2Fraw","name":"fixture-74-name%2Fraw"},"pattern":"^\\\\/image-builds\\\\/toggle\\\\/repo\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /image-builds/status","pathname":"/image-builds/status","groups":{},"pattern":"^\\\\/image-builds\\\\/status$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /image-builds/enabled","pathname":"/image-builds/enabled","groups":{},"pattern":"^\\\\/image-builds\\\\/enabled$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /image-builds/enabled-repos","pathname":"/image-builds/enabled-repos","groups":{},"pattern":"^\\\\/image-builds\\\\/enabled-repos$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", @@ -84,60 +85,60 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"GET /model-provider-accounts/legacy-credentials","pathname":"/model-provider-accounts/legacy-credentials","groups":{},"pattern":"^\\\\/model-provider-accounts\\\\/legacy-credentials$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /model-provider-accounts","pathname":"/model-provider-accounts","groups":{},"pattern":"^\\\\/model-provider-accounts$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /model-provider-accounts","pathname":"/model-provider-accounts","groups":{},"pattern":"^\\\\/model-provider-accounts$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/device-authorizations","pathname":"/model-provider-accounts/fixture-82-provider%2Fraw/device-authorizations","groups":{"provider":"fixture-82-provider%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/device-authorizations$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/device-authorizations/:id/poll","pathname":"/model-provider-accounts/fixture-83-provider%2Fraw/device-authorizations/fixture-83-id%2Fraw/poll","groups":{"provider":"fixture-83-provider%2Fraw","id":"fixture-83-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/device-authorizations\\\\/(?[^/]+)\\\\/poll$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-accounts/:provider/device-authorizations/:id","pathname":"/model-provider-accounts/fixture-84-provider%2Fraw/device-authorizations/fixture-84-id%2Fraw","groups":{"provider":"fixture-84-provider%2Fraw","id":"fixture-84-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/device-authorizations\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-85-id%2Fraw","groups":{"id":"fixture-85-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PATCH /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-86-id%2Fraw","groups":{"id":"fixture-86-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/verify","pathname":"/model-provider-accounts/fixture-87-id%2Fraw/verify","groups":{"id":"fixture-87-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/verify$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/disable","pathname":"/model-provider-accounts/fixture-88-id%2Fraw/disable","groups":{"id":"fixture-88-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/disable$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/enable","pathname":"/model-provider-accounts/fixture-89-id%2Fraw/enable","groups":{"id":"fixture-89-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/enable$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/reconnect","pathname":"/model-provider-accounts/fixture-90-id%2Fraw/reconnect","groups":{"id":"fixture-90-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/reconnect$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-91-id%2Fraw","groups":{"id":"fixture-91-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/device-authorizations","pathname":"/model-provider-accounts/fixture-83-provider%2Fraw/device-authorizations","groups":{"provider":"fixture-83-provider%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/device-authorizations$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/device-authorizations/:id/poll","pathname":"/model-provider-accounts/fixture-84-provider%2Fraw/device-authorizations/fixture-84-id%2Fraw/poll","groups":{"provider":"fixture-84-provider%2Fraw","id":"fixture-84-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/device-authorizations\\\\/(?[^/]+)\\\\/poll$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-accounts/:provider/device-authorizations/:id","pathname":"/model-provider-accounts/fixture-85-provider%2Fraw/device-authorizations/fixture-85-id%2Fraw","groups":{"provider":"fixture-85-provider%2Fraw","id":"fixture-85-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/device-authorizations\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-86-id%2Fraw","groups":{"id":"fixture-86-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PATCH /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-87-id%2Fraw","groups":{"id":"fixture-87-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/verify","pathname":"/model-provider-accounts/fixture-88-id%2Fraw/verify","groups":{"id":"fixture-88-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/verify$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/disable","pathname":"/model-provider-accounts/fixture-89-id%2Fraw/disable","groups":{"id":"fixture-89-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/disable$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/enable","pathname":"/model-provider-accounts/fixture-90-id%2Fraw/enable","groups":{"id":"fixture-90-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/enable$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/reconnect","pathname":"/model-provider-accounts/fixture-91-id%2Fraw/reconnect","groups":{"id":"fixture-91-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/reconnect$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-92-id%2Fraw","groups":{"id":"fixture-92-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /model-provider-account-defaults","pathname":"/model-provider-account-defaults","groups":{},"pattern":"^\\\\/model-provider-account-defaults$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-93-provider%2Fraw","groups":{"provider":"fixture-93-provider%2Fraw"},"pattern":"^\\\\/model-provider-account-defaults\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-94-provider%2Fraw","groups":{"provider":"fixture-94-provider%2Fraw"},"pattern":"^\\\\/model-provider-account-defaults\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/provider-auth/:provider/access-token","pathname":"/sessions/fixture-95-id%2Fraw/provider-auth/fixture-95-provider%2Fraw/access-token","groups":{"id":"fixture-95-id%2Fraw","provider":"fixture-95-provider%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/provider-auth\\\\/(?[^/]+)\\\\/access-token$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id","pathname":"/integration-settings/fixture-96-id%2Fraw","groups":{"id":"fixture-96-id%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot","pathParams":{"id":"slack"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /integration-settings/:id","pathname":"/integration-settings/fixture-97-id%2Fraw","groups":{"id":"fixture-97-id%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /integration-settings/:id","pathname":"/integration-settings/fixture-98-id%2Fraw","groups":{"id":"fixture-98-id%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/repos","pathname":"/integration-settings/fixture-99-id%2Fraw/repos","groups":{"id":"fixture-99-id%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/repos$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-100-id%2Fraw/repos/fixture-100-owner%2Fraw/fixture-100-name%2Fraw","groups":{"id":"fixture-100-id%2Fraw","owner":"fixture-100-owner%2Fraw","name":"fixture-100-name%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-101-id%2Fraw/repos/fixture-101-owner%2Fraw/fixture-101-name%2Fraw","groups":{"id":"fixture-101-id%2Fraw","owner":"fixture-101-owner%2Fraw","name":"fixture-101-name%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-102-id%2Fraw/repos/fixture-102-owner%2Fraw/fixture-102-name%2Fraw","groups":{"id":"fixture-102-id%2Fraw","owner":"fixture-102-owner%2Fraw","name":"fixture-102-name%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-103-id%2Fraw/environments/fixture-103-environmentId%2Fraw","groups":{"id":"fixture-103-id%2Fraw","environmentId":"fixture-103-environmentId%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-104-id%2Fraw/environments/fixture-104-environmentId%2Fraw","groups":{"id":"fixture-104-id%2Fraw","environmentId":"fixture-104-environmentId%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-105-id%2Fraw/environments/fixture-105-environmentId%2Fraw","groups":{"id":"fixture-105-id%2Fraw","environmentId":"fixture-105-environmentId%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/resolved/:owner/:name","pathname":"/integration-settings/fixture-106-id%2Fraw/resolved/fixture-106-owner%2Fraw/fixture-106-name%2Fraw","groups":{"id":"fixture-106-id%2Fraw","owner":"fixture-106-owner%2Fraw","name":"fixture-106-name%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/resolved\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot","pathParams":{"id":"github"}},{"service":"linear-bot","pathParams":{"id":"linear"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-94-provider%2Fraw","groups":{"provider":"fixture-94-provider%2Fraw"},"pattern":"^\\\\/model-provider-account-defaults\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-95-provider%2Fraw","groups":{"provider":"fixture-95-provider%2Fraw"},"pattern":"^\\\\/model-provider-account-defaults\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/provider-auth/:provider/access-token","pathname":"/sessions/fixture-96-id%2Fraw/provider-auth/fixture-96-provider%2Fraw/access-token","groups":{"id":"fixture-96-id%2Fraw","provider":"fixture-96-provider%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/provider-auth\\\\/(?[^/]+)\\\\/access-token$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id","pathname":"/integration-settings/fixture-97-id%2Fraw","groups":{"id":"fixture-97-id%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot","pathParams":{"id":"slack"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id","pathname":"/integration-settings/fixture-98-id%2Fraw","groups":{"id":"fixture-98-id%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id","pathname":"/integration-settings/fixture-99-id%2Fraw","groups":{"id":"fixture-99-id%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/repos","pathname":"/integration-settings/fixture-100-id%2Fraw/repos","groups":{"id":"fixture-100-id%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/repos$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-101-id%2Fraw/repos/fixture-101-owner%2Fraw/fixture-101-name%2Fraw","groups":{"id":"fixture-101-id%2Fraw","owner":"fixture-101-owner%2Fraw","name":"fixture-101-name%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-102-id%2Fraw/repos/fixture-102-owner%2Fraw/fixture-102-name%2Fraw","groups":{"id":"fixture-102-id%2Fraw","owner":"fixture-102-owner%2Fraw","name":"fixture-102-name%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-103-id%2Fraw/repos/fixture-103-owner%2Fraw/fixture-103-name%2Fraw","groups":{"id":"fixture-103-id%2Fraw","owner":"fixture-103-owner%2Fraw","name":"fixture-103-name%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-104-id%2Fraw/environments/fixture-104-environmentId%2Fraw","groups":{"id":"fixture-104-id%2Fraw","environmentId":"fixture-104-environmentId%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-105-id%2Fraw/environments/fixture-105-environmentId%2Fraw","groups":{"id":"fixture-105-id%2Fraw","environmentId":"fixture-105-environmentId%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-106-id%2Fraw/environments/fixture-106-environmentId%2Fraw","groups":{"id":"fixture-106-id%2Fraw","environmentId":"fixture-106-environmentId%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/resolved/:owner/:name","pathname":"/integration-settings/fixture-107-id%2Fraw/resolved/fixture-107-owner%2Fraw/fixture-107-name%2Fraw","groups":{"id":"fixture-107-id%2Fraw","owner":"fixture-107-owner%2Fraw","name":"fixture-107-name%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/resolved\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot","pathParams":{"id":"github"}},{"service":"linear-bot","pathParams":{"id":"linear"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /commit-signing","pathname":"/commit-signing","groups":{},"pattern":"^\\\\/commit-signing$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PUT /commit-signing","pathname":"/commit-signing","groups":{},"pattern":"^\\\\/commit-signing$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"commit_signing.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"DELETE /commit-signing","pathname":"/commit-signing","groups":{},"pattern":"^\\\\/commit-signing$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"commit_signing.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/commit-signing","pathname":"/sessions/fixture-110-id%2Fraw/commit-signing","groups":{"id":"fixture-110-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/commit-signing$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/commit-signing","pathname":"/sessions/fixture-111-id%2Fraw/commit-signing","groups":{"id":"fixture-111-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/commit-signing$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/commit-signing","pathname":"/sessions/fixture-111-id%2Fraw/commit-signing","groups":{"id":"fixture-111-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/commit-signing$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/commit-signing","pathname":"/sessions/fixture-112-id%2Fraw/commit-signing","groups":{"id":"fixture-112-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/commit-signing$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /scm-settings","pathname":"/scm-settings","groups":{},"pattern":"^\\\\/scm-settings$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PUT /scm-settings","pathname":"/scm-settings","groups":{},"pattern":"^\\\\/scm-settings$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"DELETE /scm-settings","pathname":"/scm-settings","groups":{},"pattern":"^\\\\/scm-settings$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /scm-settings/repos","pathname":"/scm-settings/repos","groups":{},"pattern":"^\\\\/scm-settings\\\\/repos$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-116-owner%2Fraw/fixture-116-name%2Fraw","groups":{"owner":"fixture-116-owner%2Fraw","name":"fixture-116-name%2Fraw"},"pattern":"^\\\\/scm-settings\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-117-owner%2Fraw/fixture-117-name%2Fraw","groups":{"owner":"fixture-117-owner%2Fraw","name":"fixture-117-name%2Fraw"},"pattern":"^\\\\/scm-settings\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-117-owner%2Fraw/fixture-117-name%2Fraw","groups":{"owner":"fixture-117-owner%2Fraw","name":"fixture-117-name%2Fraw"},"pattern":"^\\\\/scm-settings\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-118-owner%2Fraw/fixture-118-name%2Fraw","groups":{"owner":"fixture-118-owner%2Fraw","name":"fixture-118-name%2Fraw"},"pattern":"^\\\\/scm-settings\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /integration-settings/slack/watched-channels","pathname":"/integration-settings/slack/watched-channels","groups":{},"pattern":"^\\\\/integration-settings\\\\/slack\\\\/watched-channels$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /integration-settings/slack/channels","pathname":"/integration-settings/slack/channels","groups":{},"pattern":"^\\\\/integration-settings\\\\/slack\\\\/channels$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /automations","pathname":"/automations","groups":{},"pattern":"^\\\\/automations$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /automations","pathname":"/automations","groups":{},"pattern":"^\\\\/automations$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.create"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /automations/:id","pathname":"/automations/fixture-122-id%2Fraw","groups":{"id":"fixture-122-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /automations/:id","pathname":"/automations/fixture-123-id%2Fraw","groups":{"id":"fixture-123-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /automations/:id","pathname":"/automations/fixture-124-id%2Fraw","groups":{"id":"fixture-124-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/pause","pathname":"/automations/fixture-125-id%2Fraw/pause","groups":{"id":"fixture-125-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/pause$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/resume","pathname":"/automations/fixture-126-id%2Fraw/resume","groups":{"id":"fixture-126-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/resume$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/trigger","pathname":"/automations/fixture-127-id%2Fraw/trigger","groups":{"id":"fixture-127-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/trigger$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"trigger","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /automations/:id/invocations","pathname":"/automations/fixture-128-id%2Fraw/invocations","groups":{"id":"fixture-128-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/invocations$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /automations/:id/runs/:runId","pathname":"/automations/fixture-129-id%2Fraw/runs/fixture-129-runId%2Fraw","groups":{"id":"fixture-129-id%2Fraw","runId":"fixture-129-runId%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/runs\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/regenerate-key","pathname":"/automations/fixture-130-id%2Fraw/regenerate-key","groups":{"id":"fixture-130-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/regenerate-key$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id","pathname":"/automations/fixture-123-id%2Fraw","groups":{"id":"fixture-123-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /automations/:id","pathname":"/automations/fixture-124-id%2Fraw","groups":{"id":"fixture-124-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /automations/:id","pathname":"/automations/fixture-125-id%2Fraw","groups":{"id":"fixture-125-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/pause","pathname":"/automations/fixture-126-id%2Fraw/pause","groups":{"id":"fixture-126-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/pause$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/resume","pathname":"/automations/fixture-127-id%2Fraw/resume","groups":{"id":"fixture-127-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/resume$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/trigger","pathname":"/automations/fixture-128-id%2Fraw/trigger","groups":{"id":"fixture-128-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/trigger$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"trigger","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id/invocations","pathname":"/automations/fixture-129-id%2Fraw/invocations","groups":{"id":"fixture-129-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/invocations$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id/runs/:runId","pathname":"/automations/fixture-130-id%2Fraw/runs/fixture-130-runId%2Fraw","groups":{"id":"fixture-130-id%2Fraw","runId":"fixture-130-runId%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/runs\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/regenerate-key","pathname":"/automations/fixture-131-id%2Fraw/regenerate-key","groups":{"id":"fixture-131-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/regenerate-key$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /mcp-servers","pathname":"/mcp-servers","groups":{},"pattern":"^\\\\/mcp-servers$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /mcp-servers","pathname":"/mcp-servers","groups":{},"pattern":"^\\\\/mcp-servers$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /mcp-servers/:id","pathname":"/mcp-servers/fixture-133-id%2Fraw","groups":{"id":"fixture-133-id%2Fraw"},"pattern":"^\\\\/mcp-servers\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /mcp-servers/:id","pathname":"/mcp-servers/fixture-134-id%2Fraw","groups":{"id":"fixture-134-id%2Fraw"},"pattern":"^\\\\/mcp-servers\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /mcp-servers/:id","pathname":"/mcp-servers/fixture-135-id%2Fraw","groups":{"id":"fixture-135-id%2Fraw"},"pattern":"^\\\\/mcp-servers\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /mcp-servers/:id","pathname":"/mcp-servers/fixture-134-id%2Fraw","groups":{"id":"fixture-134-id%2Fraw"},"pattern":"^\\\\/mcp-servers\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /mcp-servers/:id","pathname":"/mcp-servers/fixture-135-id%2Fraw","groups":{"id":"fixture-135-id%2Fraw"},"pattern":"^\\\\/mcp-servers\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /mcp-servers/:id","pathname":"/mcp-servers/fixture-136-id%2Fraw","groups":{"id":"fixture-136-id%2Fraw"},"pattern":"^\\\\/mcp-servers\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /analytics/dashboard","pathname":"/analytics/dashboard","groups":{},"pattern":"^\\\\/analytics\\\\/dashboard$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /analytics/summary","pathname":"/analytics/summary","groups":{},"pattern":"^\\\\/analytics\\\\/summary$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /analytics/timeseries","pathname":"/analytics/timeseries","groups":{},"pattern":"^\\\\/analytics\\\\/timeseries$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", @@ -148,29 +149,29 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"GET /skills","pathname":"/skills","groups":{},"pattern":"^\\\\/skills$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/preview","pathname":"/skills/preview","groups":{},"pattern":"^\\\\/skills\\\\/preview$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/resolve-preview","pathname":"/skills/resolve-preview","groups":{},"pattern":"^\\\\/skills\\\\/resolve-preview$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /skills/:id","pathname":"/skills/fixture-146-id%2Fraw","groups":{"id":"fixture-146-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /skills/:id","pathname":"/skills/fixture-147-id%2Fraw","groups":{"id":"fixture-147-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills","pathname":"/skills","groups":{},"pattern":"^\\\\/skills$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/import/preview","pathname":"/skills/import/preview","groups":{},"pattern":"^\\\\/skills\\\\/import\\\\/preview$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/import","pathname":"/skills/import","groups":{},"pattern":"^\\\\/skills\\\\/import$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /skills/:id/reimport/preview","pathname":"/skills/fixture-150-id%2Fraw/reimport/preview","groups":{"id":"fixture-150-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)\\\\/reimport\\\\/preview$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /skills/:id/reimport","pathname":"/skills/fixture-151-id%2Fraw/reimport","groups":{"id":"fixture-151-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)\\\\/reimport$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /skills/:id","pathname":"/skills/fixture-152-id%2Fraw","groups":{"id":"fixture-152-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /skills/:id","pathname":"/skills/fixture-153-id%2Fraw","groups":{"id":"fixture-153-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /skills/:id","pathname":"/skills/fixture-154-id%2Fraw","groups":{"id":"fixture-154-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/:id/reimport/preview","pathname":"/skills/fixture-151-id%2Fraw/reimport/preview","groups":{"id":"fixture-151-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)\\\\/reimport\\\\/preview$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/:id/reimport","pathname":"/skills/fixture-152-id%2Fraw/reimport","groups":{"id":"fixture-152-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)\\\\/reimport$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /skills/:id","pathname":"/skills/fixture-153-id%2Fraw","groups":{"id":"fixture-153-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /skills/:id","pathname":"/skills/fixture-154-id%2Fraw","groups":{"id":"fixture-154-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /skills/:id","pathname":"/skills/fixture-155-id%2Fraw","groups":{"id":"fixture-155-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /skill-profiles","pathname":"/skill-profiles","groups":{},"pattern":"^\\\\/skill-profiles$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skill-profiles","pathname":"/skill-profiles","groups":{},"pattern":"^\\\\/skill-profiles$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /skill-profiles/:id","pathname":"/skill-profiles/fixture-157-id%2Fraw","groups":{"id":"fixture-157-id%2Fraw"},"pattern":"^\\\\/skill-profiles\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /skill-profiles/:id","pathname":"/skill-profiles/fixture-158-id%2Fraw","groups":{"id":"fixture-158-id%2Fraw"},"pattern":"^\\\\/skill-profiles\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /skill-profiles/:id","pathname":"/skill-profiles/fixture-158-id%2Fraw","groups":{"id":"fixture-158-id%2Fraw"},"pattern":"^\\\\/skill-profiles\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /skill-profiles/:id","pathname":"/skill-profiles/fixture-159-id%2Fraw","groups":{"id":"fixture-159-id%2Fraw"},"pattern":"^\\\\/skill-profiles\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /keyboard-shortcuts","pathname":"/keyboard-shortcuts","groups":{},"pattern":"^\\\\/keyboard-shortcuts$","authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PUT /keyboard-shortcuts","pathname":"/keyboard-shortcuts","groups":{},"pattern":"^\\\\/keyboard-shortcuts$","authentication":"user","authorization":{"kind":"active-self","auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /me/authorization","pathname":"/me/authorization","groups":{},"pattern":"^\\\\/me\\\\/authorization$","authentication":"user","authorization":{"kind":"authenticated","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /roles","pathname":"/roles","groups":{},"pattern":"^\\\\/roles$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /roles/:id","pathname":"/roles/fixture-163-id%2Fraw","groups":{"id":"fixture-163-id%2Fraw"},"pattern":"^\\\\/roles\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /roles/:id","pathname":"/roles/fixture-164-id%2Fraw","groups":{"id":"fixture-164-id%2Fraw"},"pattern":"^\\\\/roles\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /members","pathname":"/members","groups":{},"pattern":"^\\\\/members$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /members/:id/role","pathname":"/members/fixture-165-id%2Fraw/role","groups":{"id":"fixture-165-id%2Fraw"},"pattern":"^\\\\/members\\\\/(?[^/]+)\\\\/role$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /members/:id/status","pathname":"/members/fixture-166-id%2Fraw/status","groups":{"id":"fixture-166-id%2Fraw"},"pattern":"^\\\\/members\\\\/(?[^/]+)\\\\/status$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /webhooks/sentry/:id","pathname":"/webhooks/sentry/fixture-167-id%2Fraw","groups":{"id":"fixture-167-id%2Fraw"},"pattern":"^\\\\/webhooks\\\\/sentry\\\\/(?[^/]+)$","authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /webhooks/automation/:id","pathname":"/webhooks/automation/fixture-168-id%2Fraw","groups":{"id":"fixture-168-id%2Fraw"},"pattern":"^\\\\/webhooks\\\\/automation\\\\/(?[^/]+)$","authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /members/:id/role","pathname":"/members/fixture-166-id%2Fraw/role","groups":{"id":"fixture-166-id%2Fraw"},"pattern":"^\\\\/members\\\\/(?[^/]+)\\\\/role$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /members/:id/status","pathname":"/members/fixture-167-id%2Fraw/status","groups":{"id":"fixture-167-id%2Fraw"},"pattern":"^\\\\/members\\\\/(?[^/]+)\\\\/status$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /webhooks/sentry/:id","pathname":"/webhooks/sentry/fixture-168-id%2Fraw","groups":{"id":"fixture-168-id%2Fraw"},"pattern":"^\\\\/webhooks\\\\/sentry\\\\/(?[^/]+)$","authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /webhooks/automation/:id","pathname":"/webhooks/automation/fixture-169-id%2Fraw","groups":{"id":"fixture-169-id%2Fraw"},"pattern":"^\\\\/webhooks\\\\/automation\\\\/(?[^/]+)$","authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /internal/github-event","pathname":"/internal/github-event","groups":{},"pattern":"^\\\\/internal\\\\/github-event$","authentication":"service","authorization":{"kind":"service","services":["github-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /internal/slack-event","pathname":"/internal/slack-event","groups":{},"pattern":"^\\\\/internal\\\\/slack-event$","authentication":"service","authorization":{"kind":"service","services":["slack-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", ] diff --git a/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap b/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap index 5069cd4cd7..3f586c6a75 100644 --- a/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap +++ b/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap @@ -58,6 +58,7 @@ exports[`route admission matrix > admits the workspace owner through every brows "PATCH /sessions/:id/title owner=400", "POST /sessions/:id/archive owner=200", "POST /sessions/:id/unarchive owner=409", + "PATCH /sessions/:id/budget owner=400", "POST /sessions/:id/ws-token owner=200", "POST /sessions/:id/prompt owner=400", "POST /sessions/:id/pull-requests/refresh owner=202", @@ -223,6 +224,7 @@ exports[`route admission matrix > rejects every credentialed route anonymously b "PATCH /sessions/:id/title anonymous=401", "POST /sessions/:id/archive anonymous=401", "POST /sessions/:id/unarchive anonymous=401", + "PATCH /sessions/:id/budget anonymous=401", "POST /sessions/:id/ws-token anonymous=401", "POST /sessions/:id/prompt anonymous=401", "POST /sessions/:id/pull-requests/refresh anonymous=401", diff --git a/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts b/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts index 1c68cfc38b..de02415758 100644 --- a/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts +++ b/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts @@ -39,7 +39,7 @@ describe("Hono route catalog conformance", () => { }; }); - expect(manifest).toHaveLength(171); + expect(manifest).toHaveLength(172); // One compact, reviewable line per frozen route keeps the fixture explicit // without thousands of snapshot-only formatting lines. expect(manifest.map((entry) => JSON.stringify(entry))).toMatchSnapshot(); diff --git a/packages/control-plane/test/integration/session-budget.test.ts b/packages/control-plane/test/integration/session-budget.test.ts new file mode 100644 index 0000000000..8b211e92e2 --- /dev/null +++ b/packages/control-plane/test/integration/session-budget.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { cleanD1Tables } from "./cleanup"; +import { + initNamedSession, + initNamedSessionDO, + queryDO, + seedMessage, + serviceFetch, + waitForSandboxStatus, +} from "./helpers"; + +const BROWSER_USER_ID = "11111111111111111111111111111111"; + +describe("session budgets", () => { + beforeEach(cleanD1Tables); + + it("persists resolved budget settings in the session snapshot", async () => { + const name = `budget-snapshot-${Date.now()}`; + const { stub } = await initNamedSession(name, { + sandboxSettings: { maxSessionCostUsd: 10, costWarningThresholdPct: 75 }, + }); + + const response = await stub.fetch("http://internal/internal/snapshot"); + expect(response.status).toBe(200); + const snapshot = await response.json>(); + expect(snapshot.session).toMatchObject({ + totalCost: 0, + maxSessionCostUsd: 10, + budgetExhausted: false, + costTrackingUnavailable: false, + }); + + const rows = await queryDO<{ max_cost_usd: number | null }>( + stub, + "SELECT max_cost_usd FROM session" + ); + expect(rows).toEqual([{ max_cost_usd: 10 }]); + }); + + it("allows the owner to change the live limit through the public route", async () => { + const name = `budget-owner-${Date.now()}`; + const { stub } = await initNamedSession(name, { canonicalUserId: BROWSER_USER_ID }); + + const ownerResponse = await serviceFetch(`https://test.local/sessions/${name}/budget`, { + method: "PATCH", + body: JSON.stringify({ maxCostUsd: 20 }), + headers: { "Content-Type": "application/json" }, + }); + expect(ownerResponse.status).toBe(200); + await expect(ownerResponse.json()).resolves.toMatchObject({ maxSessionCostUsd: 20 }); + + const rows = await queryDO<{ max_cost_usd: number | null }>( + stub, + "SELECT max_cost_usd FROM session" + ); + expect(rows).toEqual([{ max_cost_usd: 20 }]); + }); + + it("preserves live budget state when initialization is retried", async () => { + const name = `budget-reinit-${Date.now()}`; + const { stub } = await initNamedSession(name, { + sandboxSettings: { maxSessionCostUsd: 10 }, + }); + await queryDO( + stub, + `UPDATE session + SET total_cost = 12, max_cost_usd = 15, cost_warning_sent = 1, + budget_exhausted = 1, cost_tracking_unavailable = 1` + ); + await queryDO( + stub, + `UPDATE session_repositories SET branch_name = 'feature/live', current_sha = 'abc123'` + ); + + await initNamedSessionDO(name, { sandboxSettings: { maxSessionCostUsd: 100 } }); + + expect( + await queryDO( + stub, + `SELECT total_cost, max_cost_usd, cost_warning_sent, + budget_exhausted, cost_tracking_unavailable + FROM session` + ) + ).toEqual([ + { + total_cost: 12, + max_cost_usd: 15, + cost_warning_sent: 1, + budget_exhausted: 1, + cost_tracking_unavailable: 1, + }, + ]); + expect( + await queryDO( + stub, + `SELECT + (SELECT COUNT(*) FROM participants) AS participant_count, + (SELECT COUNT(*) FROM sandbox) AS sandbox_count, + (SELECT branch_name FROM session_repositories LIMIT 1) AS branch_name, + (SELECT current_sha FROM session_repositories LIMIT 1) AS current_sha` + ) + ).toEqual([ + { + participant_count: 1, + sandbox_count: 1, + branch_name: "feature/live", + current_sha: "abc123", + }, + ]); + }); + + it("latches unavailable cost tracking for a token-using unpriced step", async () => { + const name = `budget-unpriced-${Date.now()}`; + const { stub } = await initNamedSession(name, { + sandboxSettings: { maxSessionCostUsd: 10 }, + }); + const response = await stub.fetch("http://internal/internal/sandbox-event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "step_finish", + messageId: "message-unpriced", + cost: null, + tokens: { total: 10, input: 8, output: 2 }, + sandboxId: "sandbox-1", + timestamp: Date.now(), + }), + }); + expect(response.status).toBe(200); + expect(await queryDO(stub, "SELECT cost_tracking_unavailable FROM session")).toEqual([ + { cost_tracking_unavailable: 1 }, + ]); + }); + + it("warns, exhausts active work, preserves pending work, and clears on a raised limit", async () => { + const name = `budget-enforcement-${Date.now()}`; + const { stub } = await initNamedSession(name, { + sandboxSettings: { maxSessionCostUsd: 10, costWarningThresholdPct: 80 }, + }); + await waitForSandboxStatus(stub, "failed"); + const [{ id: ownerId }] = await queryDO<{ id: string }>( + stub, + "SELECT id FROM participants WHERE role = 'owner'" + ); + await seedMessage(stub, { + id: "message-active", + authorId: ownerId, + content: "Active work", + source: "web", + status: "processing", + createdAt: Date.now() - 100, + startedAt: Date.now() - 50, + }); + await seedMessage(stub, { + id: "message-pending", + authorId: ownerId, + content: "Pending work", + source: "web", + status: "pending", + createdAt: Date.now(), + }); + + const sendCost = (cost: number, messageCostUsd: number) => + stub.fetch("http://internal/internal/sandbox-event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "step_finish", + messageId: "message-active", + cost, + messageCostUsd, + tokens: { total: 10, input: 8, output: 2 }, + sandboxId: "sandbox-1", + timestamp: Date.now(), + }), + }); + + expect((await sendCost(7, 7)).status).toBe(200); + // A resent report changes nothing. + expect((await sendCost(7, 7)).status).toBe(200); + expect(await queryDO(stub, "SELECT id FROM events WHERE type = 'warning'")).toEqual([]); + expect((await sendCost(1, 8)).status).toBe(200); + // The 9 report was lost; the next cumulative repairs it. + expect((await sendCost(1, 10)).status).toBe(200); + + const [session] = await queryDO<{ + total_cost: number; + budget_exhausted: number; + cost_warning_sent: number; + }>(stub, "SELECT total_cost, budget_exhausted, cost_warning_sent FROM session"); + expect(session).toEqual({ total_cost: 10, budget_exhausted: 1, cost_warning_sent: 1 }); + expect( + await queryDO(stub, "SELECT reported_cost_usd FROM messages WHERE id = 'message-active'") + ).toEqual([{ reported_cost_usd: 10 }]); + expect( + await queryDO<{ id: string; status: string }>( + stub, + "SELECT id, status FROM messages ORDER BY created_at" + ) + ).toEqual([ + { id: "message-active", status: "failed" }, + { id: "message-pending", status: "pending" }, + ]); + const warningsResponse = await stub.fetch("http://internal/internal/events"); + const warningsBody = await warningsResponse.json<{ + events: Array<{ data: { scope?: string } }>; + }>(); + expect(warningsBody.events.filter((item) => item.data.scope === "budget")).toHaveLength(2); + + const raised = await stub.fetch("http://internal/internal/budget", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxCostUsd: 20 }), + }); + expect(raised.status).toBe(200); + expect( + await queryDO(stub, "SELECT max_cost_usd, budget_exhausted, cost_warning_sent FROM session") + ).toEqual([{ max_cost_usd: 20, budget_exhausted: 0, cost_warning_sent: 0 }]); + }); + + it("applies the final cumulative report carried on execution_complete", async () => { + const name = `budget-final-${Date.now()}`; + const { stub } = await initNamedSession(name, { + sandboxSettings: { maxSessionCostUsd: 10 }, + }); + await waitForSandboxStatus(stub, "failed"); + const [{ id: ownerId }] = await queryDO<{ id: string }>( + stub, + "SELECT id FROM participants WHERE role = 'owner'" + ); + await seedMessage(stub, { + id: "message-final", + authorId: ownerId, + content: "Finishing work", + source: "web", + status: "processing", + createdAt: Date.now() - 100, + startedAt: Date.now() - 50, + }); + + const response = await stub.fetch("http://internal/internal/sandbox-event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "execution_complete", + messageId: "message-final", + success: true, + messageCostUsd: 3.25, + sandboxId: "sandbox-1", + timestamp: Date.now(), + }), + }); + expect(response.status).toBe(200); + expect(await queryDO(stub, "SELECT total_cost, budget_exhausted FROM session")).toEqual([ + { total_cost: 3.25, budget_exhausted: 0 }, + ]); + expect( + await queryDO( + stub, + "SELECT status, reported_cost_usd FROM messages WHERE id = 'message-final'" + ) + ).toEqual([{ status: "completed", reported_cost_usd: 3.25 }]); + }); +}); diff --git a/packages/control-plane/test/integration/session-snapshot.test.ts b/packages/control-plane/test/integration/session-snapshot.test.ts index 0885129cb8..f75205e3ec 100644 --- a/packages/control-plane/test/integration/session-snapshot.test.ts +++ b/packages/control-plane/test/integration/session-snapshot.test.ts @@ -83,6 +83,7 @@ describe("session snapshot synchronization", () => { expect(messages![0].session).not.toHaveProperty("codeServerPassword"); expect(messages![0].session).not.toHaveProperty("vncPassword"); expect(messages![0].session).not.toHaveProperty("ttydToken"); + expect(messages![0].canManageBudget).toBe(true); expect(messages![0].timeline).toHaveProperty("events"); expect(JSON.stringify(messages![0])).not.toContain("code-secret"); expect(JSON.stringify(messages![0])).not.toContain("vnc-secret"); diff --git a/packages/sandbox-runtime/src/sandbox_runtime/bridge.py b/packages/sandbox-runtime/src/sandbox_runtime/bridge.py index fdb3f511b3..b43fcbe9ee 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/bridge.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/bridge.py @@ -682,6 +682,7 @@ async def _handle_prompt(self, cmd: dict[str, Any]) -> None: author_data = cmd.get("author", {}) start_time = time.time() outcome = "success" + message_cost_usd: float | None = None self.log.info( "prompt.start", @@ -722,6 +723,8 @@ async def _handle_prompt(self, cmd: dict[str, Any]) -> None: error_message = event.get("error") elif event.get("type") in ("token", "tool_call", "step_finish"): emitted_output = True + if event.get("type") == "step_finish" and "messageCostUsd" in event: + message_cost_usd = event["messageCostUsd"] await self._send_event(event) if not had_error and not emitted_output: @@ -743,6 +746,9 @@ async def _handle_prompt(self, cmd: dict[str, Any]) -> None: "messageId": message_id, "success": not had_error, **({"error": error_message} if error_message else {}), + **( + {"messageCostUsd": message_cost_usd} if message_cost_usd is not None else {} + ), } ) @@ -755,6 +761,9 @@ async def _handle_prompt(self, cmd: dict[str, Any]) -> None: "messageId": message_id, "success": False, "error": str(e), + **( + {"messageCostUsd": message_cost_usd} if message_cost_usd is not None else {} + ), } ) finally: diff --git a/packages/sandbox-runtime/src/sandbox_runtime/prompt_stream.py b/packages/sandbox-runtime/src/sandbox_runtime/prompt_stream.py index 9dc799338a..cba5ec8e12 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/prompt_stream.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/prompt_stream.py @@ -91,6 +91,9 @@ class _PromptState: pending_drop_logged: bool = False child_activity: ChildActivityCorrelator = field(default_factory=ChildActivityCorrelator) emitted_error_messages: set[str] = field(default_factory=set) + # Priced step costs keyed by OpenCode part id. Last write wins, so a part + # OpenCode re-emits with a corrected cost replaces its earlier value. + step_costs: dict[str, float] = field(default_factory=dict) # Set when a parent context-overflow announcement was swallowed; cleared by # session.compacted. If still set at idle with no error emitted, the # promised compaction never happened and the prompt must fail. @@ -104,6 +107,10 @@ def __post_init__(self) -> None: int(self.start_time * 1000), ) + def message_cost_usd(self) -> float: + """Cumulative priced cost of this turn, including subtask steps.""" + return sum(self.step_costs.values()) + class _Disposition(Enum): """What the stream loop should do after applying one SSE event.""" @@ -671,15 +678,19 @@ def _handle_part( ) elif part_type == "step-finish": - events.append( - { - "type": "step_finish", - "cost": part.get("cost"), - "tokens": part.get("tokens"), - "reason": part.get("reason"), - "messageId": state.message_id, - } - ) + cost = part.get("cost") + if isinstance(cost, int | float) and not isinstance(cost, bool): + state.step_costs[str(part.get("id", ""))] = float(cost) + finish_event = { + "type": "step_finish", + "tokens": part.get("tokens"), + "reason": part.get("reason"), + "messageId": state.message_id, + "messageCostUsd": state.message_cost_usd(), + } + if cost is not None: + finish_event["cost"] = cost + events.append(finish_event) if is_subtask: child_session_id = part.get("sessionID", "") diff --git a/packages/sandbox-runtime/tests/test_bridge_cost_report.py b/packages/sandbox-runtime/tests/test_bridge_cost_report.py new file mode 100644 index 0000000000..a084b706e8 --- /dev/null +++ b/packages/sandbox-runtime/tests/test_bridge_cost_report.py @@ -0,0 +1,81 @@ +"""The turn's final cumulative cost rides on execution_complete.""" + +from unittest.mock import AsyncMock + +import pytest + +from sandbox_runtime.bridge import AgentBridge + + +@pytest.fixture +def bridge() -> AgentBridge: + b = AgentBridge( + sandbox_id="test-sandbox", + session_id="test-session", + control_plane_url="http://localhost:8787", + auth_token="test-token", + ) + b.opencode_session_id = "oc-session-123" + b._configure_git_identity = AsyncMock() + b._send_event = AsyncMock() + return b + + +def _prompt_command() -> dict: + return { + "messageId": "msg-1", + "content": "fix the bug", + "model": "claude-sonnet-4-6", + "author": {"userId": "user-1", "gitIdentity": {"mode": "agent-only"}}, + } + + +def _completion(bridge: AgentBridge) -> dict: + events = [call.args[0] for call in bridge._send_event.await_args_list] + return next(event for event in events if event["type"] == "execution_complete") + + +class TestExecutionCompleteCostReport: + @pytest.mark.asyncio + async def test_carries_the_last_cumulative_report(self, bridge: AgentBridge): + async def stream(*_args, **_kwargs): + yield {"type": "step_finish", "messageId": "msg-1", "cost": 0.5, "messageCostUsd": 0.5} + yield { + "type": "step_finish", + "messageId": "msg-1", + "cost": 0.25, + "messageCostUsd": 0.75, + } + + bridge._stream_opencode_response_sse = stream + + await bridge._handle_prompt(_prompt_command()) + + completion = _completion(bridge) + assert completion["success"] is True + assert completion["messageCostUsd"] == 0.75 + + @pytest.mark.asyncio + async def test_carries_the_report_on_failure(self, bridge: AgentBridge): + async def stream(*_args, **_kwargs): + yield {"type": "step_finish", "messageId": "msg-1", "cost": 0.5, "messageCostUsd": 0.5} + yield {"type": "error", "messageId": "msg-1", "error": "boom"} + + bridge._stream_opencode_response_sse = stream + + await bridge._handle_prompt(_prompt_command()) + + completion = _completion(bridge) + assert completion["success"] is False + assert completion["messageCostUsd"] == 0.5 + + @pytest.mark.asyncio + async def test_omits_the_field_when_no_step_reported(self, bridge: AgentBridge): + async def stream(*_args, **_kwargs): + yield {"type": "token", "messageId": "msg-1", "content": "hi"} + + bridge._stream_opencode_response_sse = stream + + await bridge._handle_prompt(_prompt_command()) + + assert "messageCostUsd" not in _completion(bridge) diff --git a/packages/sandbox-runtime/tests/test_bridge_message_tracking.py b/packages/sandbox-runtime/tests/test_bridge_message_tracking.py index c7f74fbf76..aa44de1916 100644 --- a/packages/sandbox-runtime/tests/test_bridge_message_tracking.py +++ b/packages/sandbox-runtime/tests/test_bridge_message_tracking.py @@ -174,12 +174,41 @@ def test_step_finish_part(self, bridge: AgentBridge): { "type": "step_finish", "cost": 0.001, + "messageCostUsd": 0.001, "tokens": 150, "reason": "end_turn", "messageId": "cp-message-123", } ] + def test_step_finish_omits_unknown_cost(self, bridge: AgentBridge): + stream = bridge._ensure_prompt_stream() + events = stream._handle_part( + make_state("cp-message-123"), + {"type": "step-finish", "id": "step-1", "cost": None, "tokens": 150}, + None, + ) + + assert "cost" not in events[0] + assert events[0]["messageCostUsd"] == 0.0 + + def test_step_finish_reports_cumulative_turn_cost(self, bridge: AgentBridge): + """Each step carries the turn total; a re-emitted part replaces its own cost.""" + stream = bridge._ensure_prompt_stream() + state = make_state("cp-message-123") + + first = stream._handle_part(state, {"type": "step-finish", "id": "s1", "cost": 0.5}, None) + second = stream._handle_part(state, {"type": "step-finish", "id": "s2", "cost": 0.25}, None) + corrected = stream._handle_part( + state, {"type": "step-finish", "id": "s1", "cost": 0.75}, None + ) + unpriced = stream._handle_part(state, {"type": "step-finish", "id": "s3"}, None) + + assert first[0]["messageCostUsd"] == 0.5 + assert second[0]["messageCostUsd"] == 0.75 + assert corrected[0]["messageCostUsd"] == 1.0 + assert unpriced[0]["messageCostUsd"] == 1.0 + class TestBuildPromptRequestBody: """Tests for _build_prompt_request_body method.""" diff --git a/packages/shared/src/types/boundary-schemas.test.ts b/packages/shared/src/types/boundary-schemas.test.ts index 241e5b294e..8f52e5998b 100644 --- a/packages/shared/src/types/boundary-schemas.test.ts +++ b/packages/shared/src/types/boundary-schemas.test.ts @@ -648,6 +648,20 @@ describe("boundary schemas", () => { } }); + it("accepts legacy runtime step finish payloads with null cost", () => { + expect( + sandboxEventSchema.safeParse({ + type: "step_finish", + messageId: "message-1", + ackId: "step_finish:1", + cost: null, + tokens: { input: 1 }, + sandboxId: "sandbox-1", + timestamp: 123, + }).success + ).toBe(true); + }); + it("parses a ready event (emitted on every sandbox connect)", () => { const result = sandboxEventSchema.safeParse({ type: "ready", diff --git a/packages/shared/src/types/github-autofix.ts b/packages/shared/src/types/github-autofix.ts index 6f79aa6b6f..5076057b14 100644 --- a/packages/shared/src/types/github-autofix.ts +++ b/packages/shared/src/types/github-autofix.ts @@ -91,7 +91,7 @@ export const githubAutofixSessionResponseSchema = z.discriminatedUnion("kind", [ }), z.object({ kind: z.literal("rejected"), - reason: z.enum(["session_closed", "queue_full", "attempt_limit"]), + reason: z.enum(["session_closed", "budget_exhausted", "queue_full", "attempt_limit"]), }), z.object({ kind: z.literal("found"), diff --git a/packages/shared/src/types/integrations.test.ts b/packages/shared/src/types/integrations.test.ts index 7d9657116e..cb69212056 100644 --- a/packages/shared/src/types/integrations.test.ts +++ b/packages/shared/src/types/integrations.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_BUILD_TIMEOUT_SECONDS, + DEFAULT_COST_WARNING_THRESHOLD_PCT, INTERNAL_TTYD_PORT, INTERNAL_VNC_PORT, MAX_BUILD_TIMEOUT_SECONDS, @@ -283,6 +284,26 @@ describe("integration settings schemas", () => { integrationSettingsSchemas.sandbox.repo.safeParse({ cpuCores: null, memoryMib: null }).success ).toBe(true); }); + + it("parses valid session cost limits", () => { + expect( + integrationSettingsSchemas.sandbox.repo.safeParse({ + maxSessionCostUsd: 12.5, + costWarningThresholdPct: DEFAULT_COST_WARNING_THRESHOLD_PCT, + }).success + ).toBe(true); + }); + + it.each([ + { maxSessionCostUsd: 0 }, + { maxSessionCostUsd: -1 }, + { maxSessionCostUsd: Number.POSITIVE_INFINITY }, + { costWarningThresholdPct: 0 }, + { costWarningThresholdPct: 99.5 }, + { costWarningThresholdPct: 100 }, + ])("rejects invalid session cost settings %#", (settings) => { + expect(integrationSettingsSchemas.sandbox.repo.safeParse(settings).success).toBe(false); + }); }); describe("matchRoutingRules", () => { diff --git a/packages/shared/src/types/integrations.ts b/packages/shared/src/types/integrations.ts index a068305d52..f0793e7718 100644 --- a/packages/shared/src/types/integrations.ts +++ b/packages/shared/src/types/integrations.ts @@ -233,6 +233,9 @@ export const DEFAULT_BUILD_TIMEOUT_SECONDS = 1800; */ export const MAX_BUILD_TIMEOUT_SECONDS = 3600; +/** Default pre-limit warning threshold as a percentage of the session cost limit. */ +export const DEFAULT_COST_WARNING_THRESHOLD_PCT = 80; + /** * Sandbox environment settings. Provider-agnostic: describes what the user * wants, not how it's done. Resource fields (`cpuCores`, `memoryMib`) are @@ -265,6 +268,10 @@ export const sandboxSettingsSchema = z.strictObject({ sandboxTimeoutMs: z.number().optional(), /** Repo-image build timeout (the build sandbox lifetime), in seconds. */ buildTimeoutSeconds: z.number().optional(), + /** Maximum OpenCode-reported session cost in USD. */ + maxSessionCostUsd: z.number().finite().positive().optional(), + /** Percentage of the cost limit that emits the one-time warning. */ + costWarningThresholdPct: z.number().int().min(1).max(99).optional(), }); export type SandboxSettings = z.infer; diff --git a/packages/shared/src/types/repository-contracts.test.ts b/packages/shared/src/types/repository-contracts.test.ts index 41067e534e..0149d33d20 100644 --- a/packages/shared/src/types/repository-contracts.test.ts +++ b/packages/shared/src/types/repository-contracts.test.ts @@ -246,6 +246,17 @@ describe("warning event schema", () => { expect(result.success).toBe(true); }); + it("accepts control-plane budget warnings", () => { + expect( + sandboxEventSchema.safeParse({ + type: "warning", + scope: "budget", + message: "Session cost reached 80% of its limit", + timestamp: 1, + }).success + ).toBe(true); + }); + it("rejects unknown scopes", () => { const result = sandboxEventSchema.safeParse({ type: "warning", diff --git a/packages/shared/src/types/sandbox-events.ts b/packages/shared/src/types/sandbox-events.ts index 88e9bcbe1f..1ee730e501 100644 --- a/packages/shared/src/types/sandbox-events.ts +++ b/packages/shared/src/types/sandbox-events.ts @@ -84,7 +84,10 @@ export const sandboxEventSchema = z.discriminatedUnion("type", [ }), messageSandboxEventBaseSchema.extend({ type: z.literal("step_finish"), - cost: z.number().optional(), + /** Cost of this step alone; absent when the runtime could not price it. */ + cost: z.number().nullable().optional(), + /** Cumulative reported cost of the whole turn so far; idempotent on resend. */ + messageCostUsd: z.number().nonnegative().optional(), tokens: tokenUsageSchema.optional(), reason: z.string().optional(), isSubtask: z.boolean().optional(), @@ -113,6 +116,8 @@ export const sandboxEventSchema = z.discriminatedUnion("type", [ type: z.literal("execution_complete"), success: z.boolean(), error: z.string().optional(), + /** Final cumulative reported cost of the turn. */ + messageCostUsd: z.number().nonnegative().optional(), }), messageSandboxEventBaseSchema.extend({ type: z.literal("context_compacted"), @@ -154,7 +159,7 @@ export const sandboxEventSchema = z.discriminatedUnion("type", [ // unknown union entries, so this entry must exist before runtimes emit it. z.object({ type: z.literal("warning"), - scope: z.enum(["sync", "setup", "start", "assembly", "secrets", "media"]), + scope: z.enum(["sync", "setup", "start", "assembly", "secrets", "media", "budget"]), message: z.string(), repoOwner: z.string().optional(), repoName: z.string().optional(), diff --git a/packages/shared/src/types/server-messages.test.ts b/packages/shared/src/types/server-messages.test.ts index 61f426c974..d1cd256eca 100644 --- a/packages/shared/src/types/server-messages.test.ts +++ b/packages/shared/src/types/server-messages.test.ts @@ -231,4 +231,50 @@ describe("session view contracts", () => { }) ).toMatchObject({ clientRequestId: "request-1" }); }); + + it("parses budget state in snapshots and subscriptions", () => { + const parsed = serverMessageSchema.parse({ + type: "subscribed", + session: { + ...snapshotState, + totalCost: 8.25, + maxSessionCostUsd: 10, + budgetExhausted: false, + costTrackingUnavailable: true, + }, + artifacts: [], + promptQueue: [], + participantId: "participant-1", + canManageBudget: true, + timeline: { events: [], hasMore: false, cursor: null }, + }); + + expect(parsed).toMatchObject({ + canManageBudget: true, + session: { + totalCost: 8.25, + maxSessionCostUsd: 10, + budgetExhausted: false, + costTrackingUnavailable: true, + }, + }); + }); + + it("parses authoritative budget status updates", () => { + expect( + serverMessageSchema.parse({ + type: "budget_status", + totalCost: 10.25, + maxSessionCostUsd: 10, + budgetExhausted: true, + costTrackingUnavailable: false, + }) + ).toEqual({ + type: "budget_status", + totalCost: 10.25, + maxSessionCostUsd: 10, + budgetExhausted: true, + costTrackingUnavailable: false, + }); + }); }); diff --git a/packages/shared/src/types/server-messages.ts b/packages/shared/src/types/server-messages.ts index 432c62356c..e5a5f8637e 100644 --- a/packages/shared/src/types/server-messages.ts +++ b/packages/shared/src/types/server-messages.ts @@ -30,6 +30,9 @@ const sessionStateSchema = z.object({ isProcessing: z.boolean().optional(), parentSessionId: z.string().nullable().optional(), totalCost: z.number().optional(), + maxSessionCostUsd: z.number().nullable().optional(), + budgetExhausted: z.boolean().optional(), + costTrackingUnavailable: z.boolean().optional(), codeServerUrl: z.string().nullable().optional(), codeServerPassword: z.string().nullable().optional(), vncUrl: z.string().nullable().optional(), @@ -135,6 +138,7 @@ const serverMessageUnionSchema = z.discriminatedUnion("type", [ type: z.literal("subscribed"), participantId: z.string(), participant: participantSummarySchema.optional(), + canManageBudget: z.boolean().optional(), }), z.object({ type: z.literal("prompt_queued"), @@ -180,6 +184,13 @@ const serverMessageUnionSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("sandbox_restored"), message: z.string() }), z.object({ type: z.literal("sandbox_warning"), message: z.string() }), z.object({ type: z.literal("processing_status"), isProcessing: z.boolean() }), + z.object({ + type: z.literal("budget_status"), + totalCost: z.number(), + maxSessionCostUsd: z.number().nullable(), + budgetExhausted: z.boolean(), + costTrackingUnavailable: z.boolean(), + }), z.object({ type: z.literal("diff_state_changed"), revisionId: z.string().nullable(), diff --git a/packages/shared/src/types/session-api.ts b/packages/shared/src/types/session-api.ts index 8805f61e9c..8ce7cfaf5a 100644 --- a/packages/shared/src/types/session-api.ts +++ b/packages/shared/src/types/session-api.ts @@ -154,6 +154,12 @@ export const sendPromptRequestSchema = z export type SendPromptRequest = z.infer; +export const sessionBudgetUpdateSchema = z.strictObject({ + maxCostUsd: z.number().finite().positive().nullable(), +}); + +export type SessionBudgetUpdate = z.infer; + /** Request body for POST /sessions/:parentId/children/:childId/prompt. */ export const childFollowUpPromptRequestSchema = z.strictObject({ content: z diff --git a/packages/shared/src/types/websocket.ts b/packages/shared/src/types/websocket.ts index 4f84119ea5..f4fa57caac 100644 --- a/packages/shared/src/types/websocket.ts +++ b/packages/shared/src/types/websocket.ts @@ -3,6 +3,10 @@ import { clientRequestIdSchema, webPromptPayloadSchema } from "./prompts"; export { clientRequestIdSchema, MAX_UNFINISHED_PROMPTS, MAX_WEB_PROMPT_CHARS } from "./prompts"; +export const SESSION_BUDGET_CAPABILITY = "session_budget" as const; +export const clientCapabilitySchema = z.literal(SESSION_BUDGET_CAPABILITY); +export type ClientCapability = z.infer; + /** Standard close code for a transient server-side failure. */ export const WS_CLOSE_INTERNAL_ERROR = 1011; @@ -17,6 +21,7 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ type: z.literal("subscribe"), token: z.string(), clientId: z.string(), + capabilities: z.array(clientCapabilitySchema).optional(), }), webPromptPayloadSchema.extend({ type: z.literal("prompt"), diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx index 7fa38ba730..235dca0744 100644 --- a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx @@ -56,6 +56,7 @@ import { useSessionDetailsSidebar } from "@/hooks/use-session-details-sidebar"; import { findLatestTerminalMessageId } from "@/lib/session-read-state"; import { useMarkSessionRead } from "@/hooks/use-mark-session-read"; import { usePromptInput } from "@/hooks/use-prompt-input"; +import { formatSessionCost } from "@/lib/session-cost"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useSessionSnapshot } from "./session-snapshot-provider"; import { useSessionRename } from "@/hooks/use-session-rename"; @@ -86,6 +87,7 @@ export default function SessionPage() { participants, artifacts, currentParticipantId, + canManageBudget, isProcessing, promptQueue, loadingHistory, @@ -145,7 +147,7 @@ export default function SessionPage() { reasoningEffort, loadingEnabledModels, sessionState?.status ?? DEFAULT_SESSION_STATUS, - ready && capabilities.collaborate, + ready && capabilities.collaborate && !sessionState?.budgetExhausted, shortcuts["send-prompt"] ); const [cancellingPromptIds, setCancellingPromptIds] = useState>(new Set()); @@ -356,7 +358,12 @@ export default function SessionPage() { value: prompt, isProcessing: ready && isProcessing, draftLocked: isSubmitting || sessionAttachments.isUploading, - sendBlocked: !ready, + sendBlocked: !ready || Boolean(sessionState?.budgetExhausted), + blockedReason: sessionState?.budgetExhausted + ? canManageBudget + ? `Session cost limit reached at ${formatSessionCost(sessionState.totalCost ?? 0)} of ${formatSessionCost(sessionState.maxSessionCostUsd ?? 0)}. Raise or remove the limit to continue.` + : `Session cost limit reached at ${formatSessionCost(sessionState.totalCost ?? 0)} of ${formatSessionCost(sessionState.maxSessionCostUsd ?? 0)}. The session owner must raise or remove the limit to continue.` + : undefined, submitError, inputRef, onSubmit: handleSubmit, @@ -449,6 +456,7 @@ export default function SessionPage() { diffLoading={diffLoading} selectedDiff={selectedDiff} onOpenDiff={openDiff} + canManageBudget={canManageBudget} capabilities={capabilities} /> } @@ -484,6 +492,7 @@ export default function SessionPage() { diffLoading={diffLoading} selectedDiff={selectedDiff} onOpenDiff={openDiff} + canManageBudget={canManageBudget} capabilities={capabilities} /> @@ -509,6 +518,7 @@ export default function SessionPage() { diffLoading={diffLoading} selectedDiff={selectedDiff} onOpenDiff={openDiff} + canManageBudget={canManageBudget} capabilities={capabilities} /> )} diff --git a/packages/web/src/app/api/sessions/[id]/budget/route.test.ts b/packages/web/src/app/api/sessions/[id]/budget/route.test.ts new file mode 100644 index 0000000000..639494e604 --- /dev/null +++ b/packages/web/src/app/api/sessions/[id]/budget/route.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/server-auth-session", () => ({ getServerAuthSession: vi.fn() })); +vi.mock("@/lib/control-plane", () => ({ controlPlaneUserFetch: vi.fn() })); + +import { getServerAuthSession } from "@/lib/server-auth-session"; +import { controlPlaneUserFetch } from "@/lib/control-plane"; +import { PATCH } from "./route"; + +describe("session budget BFF", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getServerAuthSession).mockResolvedValue({ user: { id: "user-1" } } as never); + }); + + it("forwards a validated limit update", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + Response.json({ totalCost: 2, maxSessionCostUsd: 10, budgetExhausted: false }) + ); + const request = new Request("http://localhost/api/sessions/session-1/budget", { + method: "PATCH", + body: JSON.stringify({ maxCostUsd: 10 }), + }); + + const response = await PATCH(request as never, { + params: Promise.resolve({ id: "session-1" }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + expect(controlPlaneUserFetch).toHaveBeenCalledWith("/sessions/session-1/budget", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxCostUsd: 10 }), + }); + }); + + it.each([{ maxCostUsd: 0 }, { maxCostUsd: 1, userId: "other" }, {}])( + "rejects invalid input %#", + async (body) => { + const request = new Request("http://localhost/api/sessions/session-1/budget", { + method: "PATCH", + body: JSON.stringify(body), + }); + const response = await PATCH(request as never, { + params: Promise.resolve({ id: "session-1" }), + }); + expect(response.status).toBe(400); + expect(controlPlaneUserFetch).not.toHaveBeenCalled(); + } + ); + + it("uses the route error response for malformed JSON", async () => { + const response = await PATCH( + new Request("http://localhost/api/sessions/session-1/budget", { + method: "PATCH", + body: "{", + }) as never, + { params: Promise.resolve({ id: "session-1" }) } + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ error: "Failed to update session budget" }); + expect(controlPlaneUserFetch).not.toHaveBeenCalled(); + }); + + it("rejects unauthenticated requests", async () => { + vi.mocked(getServerAuthSession).mockResolvedValue(null); + const response = await PATCH( + new Request("http://localhost/api/sessions/session-1/budget", { + method: "PATCH", + body: JSON.stringify({ maxCostUsd: null }), + }) as never, + { params: Promise.resolve({ id: "session-1" }) } + ); + expect(response.status).toBe(401); + }); +}); diff --git a/packages/web/src/app/api/sessions/[id]/budget/route.ts b/packages/web/src/app/api/sessions/[id]/budget/route.ts new file mode 100644 index 0000000000..8afbaf4325 --- /dev/null +++ b/packages/web/src/app/api/sessions/[id]/budget/route.ts @@ -0,0 +1,33 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { sessionBudgetUpdateSchema } from "@open-inspect/shared/types/session-api"; +import { getServerAuthSession } from "@/lib/server-auth-session"; +import { controlPlaneUserFetch } from "@/lib/control-plane"; + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getServerAuthSession(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const parsed = sessionBudgetUpdateSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + + const { id } = await params; + const response = await controlPlaneUserFetch(`/sessions/${id}/budget`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(parsed.data), + }); + return NextResponse.json(await response.json(), { + status: response.status, + headers: { "Cache-Control": "private, no-store" }, + }); + } catch (error) { + console.error("Update session budget error:", error); + return NextResponse.json({ error: "Failed to update session budget" }, { status: 500 }); + } +} diff --git a/packages/web/src/app/api/sessions/[id]/prompt/route.test.ts b/packages/web/src/app/api/sessions/[id]/prompt/route.test.ts index d436a51450..cfc65a81c7 100644 --- a/packages/web/src/app/api/sessions/[id]/prompt/route.test.ts +++ b/packages/web/src/app/api/sessions/[id]/prompt/route.test.ts @@ -81,4 +81,28 @@ describe("session prompt API route", () => { attachments: [{ name: "shot.png", attachmentId: "attachment-1" }], }); }); + + it("preserves structured budget-exhausted responses", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + Response.json( + { error: "Session cost limit reached", code: "BUDGET_EXHAUSTED" }, + { status: 409 } + ) + ); + + const response = await POST( + new Request("http://localhost/api/sessions/session-1/prompt", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "Continue" }), + }) as never, + { params: Promise.resolve({ id: "session-1" }) } + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Session cost limit reached", + code: "BUDGET_EXHAUSTED", + }); + }); }); diff --git a/packages/web/src/app/api/sessions/[id]/prompt/route.ts b/packages/web/src/app/api/sessions/[id]/prompt/route.ts index fa5d697d5f..0d86cf11ff 100644 --- a/packages/web/src/app/api/sessions/[id]/prompt/route.ts +++ b/packages/web/src/app/api/sessions/[id]/prompt/route.ts @@ -51,8 +51,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ }); if (!response.ok) { - const errorText = await response.text(); - console.error(`Failed to send prompt: ${errorText}`); + const errorBody = await response.json().catch(() => null); + console.error("Failed to send prompt:", errorBody); + if (errorBody && typeof errorBody === "object" && !Array.isArray(errorBody)) { + return NextResponse.json(errorBody, { status: response.status }); + } return NextResponse.json({ error: "Failed to send prompt" }, { status: response.status }); } diff --git a/packages/web/src/components/session-details-overlay.tsx b/packages/web/src/components/session-details-overlay.tsx index 0be94c7af0..8d6e836b23 100644 --- a/packages/web/src/components/session-details-overlay.tsx +++ b/packages/web/src/components/session-details-overlay.tsx @@ -47,6 +47,7 @@ export function SessionDetailsOverlay({ diffLoading, selectedDiff, onOpenDiff, + canManageBudget, capabilities, }: SessionDetailsOverlayProps) { const [sheetDragY, setSheetDragY] = useState(0); @@ -176,6 +177,7 @@ export function SessionDetailsOverlay({ diffLoading={diffLoading} selectedDiff={selectedDiff} onOpenDiff={onOpenDiff} + canManageBudget={canManageBudget} capabilities={capabilities} /> ); diff --git a/packages/web/src/components/session-prompt-composer.test.tsx b/packages/web/src/components/session-prompt-composer.test.tsx index f593c82348..e24359c335 100644 --- a/packages/web/src/components/session-prompt-composer.test.tsx +++ b/packages/web/src/components/session-prompt-composer.test.tsx @@ -46,6 +46,7 @@ function ComposerHarness({ status = "active", submitError = null, withSkill = false, + blockedReason, canManageLifecycle = true, }: { initialValue?: string; @@ -55,6 +56,7 @@ function ComposerHarness({ status?: "active" | "archived" | "cancelled"; submitError?: string | null; withSkill?: boolean; + blockedReason?: string; canManageLifecycle?: boolean; }) { const [value, setValue] = useState(initialValue); @@ -75,6 +77,7 @@ function ComposerHarness({ isProcessing, draftLocked: isUploading, sendBlocked: connecting, + blockedReason, submitError, inputRef, onSubmit: vi.fn(), @@ -199,6 +202,19 @@ describe("SessionPromptComposer", () => { expect(screen.getByDisplayValue("Keep me")).toBeInTheDocument(); }); + it("shows a persistent budget pause reason while preserving the draft", () => { + render( + + ); + expect(screen.getByText(/Session cost limit reached/)).toBeInTheDocument(); + expect(screen.getByTitle(/Send/)).toBeDisabled(); + expect(screen.getByDisplayValue("Continue later")).toBeEnabled(); + }); + it("offers pinned skills in the follow-up textarea", async () => { const user = userEvent.setup(); render(); diff --git a/packages/web/src/components/session-prompt-composer.tsx b/packages/web/src/components/session-prompt-composer.tsx index 9b46f59150..c57d5fd370 100644 --- a/packages/web/src/components/session-prompt-composer.tsx +++ b/packages/web/src/components/session-prompt-composer.tsx @@ -32,6 +32,7 @@ type SessionPromptComposerProps = { isProcessing: boolean; draftLocked: boolean; sendBlocked: boolean; + blockedReason?: string; submitError: string | null; inputRef: React.RefObject; onSubmit: (e: React.FormEvent) => void; @@ -225,6 +226,11 @@ export function SessionPromptComposer({ {prompt.submitError}

)} + {prompt.blockedReason && ( +

+ {prompt.blockedReason} +

+ )} diff --git a/packages/web/src/components/session-right-sidebar.test.tsx b/packages/web/src/components/session-right-sidebar.test.tsx index 0f73a4b824..fa83c6d09e 100644 --- a/packages/web/src/components/session-right-sidebar.test.tsx +++ b/packages/web/src/components/session-right-sidebar.test.tsx @@ -2,8 +2,9 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { SessionRightSidebar } from "./session-right-sidebar"; import type { SessionState } from "@open-inspect/shared/types/server-messages"; +import { SessionDetailsOverlay } from "./session-details-overlay"; +import { SessionRightSidebar } from "./session-right-sidebar"; import type { SessionCapabilities } from "@/lib/session-capabilities"; vi.mock("swr", () => ({ default: () => ({ data: undefined }) })); @@ -18,8 +19,23 @@ const FULL_CAPABILITIES: SessionCapabilities = { }; describe("SessionRightSidebar", () => { + const sessionState: SessionState = { + id: "session-1", + title: null, + repoOwner: null, + repoName: null, + baseBranch: null, + branchName: null, + status: "active", + sandboxStatus: "ready", + messageCount: 0, + createdAt: 1, + totalCost: 3, + maxSessionCostUsd: 10, + }; + it("hides sandbox access controls when the capability is denied", () => { - const sessionState: SessionState = { + const sandboxSessionState: SessionState = { id: "session-1", title: "Viewer session", repoOwner: "acme", @@ -40,7 +56,7 @@ describe("SessionRightSidebar", () => { render( { expect(sidebar).toHaveAttribute("aria-hidden", "true"); expect(screen.queryByText("details")).not.toBeInTheDocument(); }); + + it("forwards budget management to the desktop sidebar", () => { + render( + + ); + + expect(screen.getByRole("button", { name: "Edit limit" })).toBeInTheDocument(); + }); + + it("forwards budget management to the mobile overlay", () => { + render( + + ); + + expect(screen.getByRole("button", { name: "Edit limit" })).toBeInTheDocument(); + }); }); diff --git a/packages/web/src/components/session-right-sidebar.tsx b/packages/web/src/components/session-right-sidebar.tsx index 4aabce8ef5..41d8d0c9b9 100644 --- a/packages/web/src/components/session-right-sidebar.tsx +++ b/packages/web/src/components/session-right-sidebar.tsx @@ -25,6 +25,7 @@ import type { DiffSelection } from "@/lib/session-diffs"; import { deriveSessionDiffView } from "@/lib/session-diffs"; import { DiffRetryNotice } from "@/components/diff-retry-notice"; import { ManagedSkillsSection } from "./sidebar/managed-skills-section"; +import { BudgetSection } from "./sidebar/budget-section"; import type { SessionCapabilities } from "@/lib/session-capabilities"; interface SessionRightSidebarProps { @@ -43,10 +44,13 @@ interface SessionRightSidebarProps { selectedDiff?: DiffSelection | null; onOpenDiff?: (repository: SessionDiffRepository, file: SessionDiffFile) => void; capabilities: SessionCapabilities; + canManageBudget?: boolean; } export type SessionRightSidebarContentProps = SessionRightSidebarProps; +const DEFAULT_CAN_MANAGE_BUDGET = false; + export function SessionRightSidebarContent({ sessionId, sessionState, @@ -61,6 +65,7 @@ export function SessionRightSidebarContent({ diffLoading, selectedDiff, onOpenDiff, + canManageBudget = DEFAULT_CAN_MANAGE_BUDGET, capabilities, }: SessionRightSidebarContentProps) { const tasks = useMemo(() => extractLatestTasks(events), [events]); @@ -126,9 +131,15 @@ export function SessionRightSidebarContent({ environmentName={sessionState.environmentName} warnings={warnings} parentSessionId={sessionState.parentSessionId} - totalCost={sessionState.totalCost} canManageLifecycle={capabilities.lifecycle} /> + {/* Code Server */} @@ -294,6 +305,7 @@ export function SessionRightSidebar({ diffLoading, selectedDiff, onOpenDiff, + canManageBudget = DEFAULT_CAN_MANAGE_BUDGET, capabilities, }: SessionRightSidebarProps) { return ( @@ -320,6 +332,7 @@ export function SessionRightSidebar({ diffLoading={diffLoading} selectedDiff={selectedDiff} onOpenDiff={onOpenDiff} + canManageBudget={canManageBudget} capabilities={capabilities} /> diff --git a/packages/web/src/components/settings/sandbox-settings.test.tsx b/packages/web/src/components/settings/sandbox-settings.test.tsx index 40a1460810..9fb465868c 100644 --- a/packages/web/src/components/settings/sandbox-settings.test.tsx +++ b/packages/web/src/components/settings/sandbox-settings.test.tsx @@ -42,7 +42,12 @@ const SETTINGS_KEY = "/api/integration-settings/sandbox"; function globalSettings( tunnelPorts: number[], enabledRepos?: string[], - limits?: { maxConcurrentChildSessions?: number; maxTotalChildSessions?: number } + limits?: { + maxConcurrentChildSessions?: number; + maxTotalChildSessions?: number; + maxSessionCostUsd?: number; + costWarningThresholdPct?: number; + } ) { return { integrationId: "sandbox", @@ -83,6 +88,14 @@ afterEach(() => { describe("SandboxSettingsPage — tunnel ports editor", () => { const user = userEvent.setup(); + it("renders configured session cost controls", () => { + renderWithSWR( + globalSettings([], undefined, { maxSessionCostUsd: 25, costWarningThresholdPct: 75 }) + ); + expect(screen.getByLabelText("Cost limit (USD)")).toHaveValue(25); + expect(screen.getByLabelText("Warning threshold (%)")).toHaveValue(75); + }); + it("shows empty state when no ports configured", () => { renderWithSWR({ integrationId: "sandbox", settings: null }); expect(screen.getByText("No tunnel ports configured.")).toBeInTheDocument(); diff --git a/packages/web/src/components/settings/sandbox-settings.tsx b/packages/web/src/components/settings/sandbox-settings.tsx index 16be4d3e79..85ac8d4fe0 100644 --- a/packages/web/src/components/settings/sandbox-settings.tsx +++ b/packages/web/src/components/settings/sandbox-settings.tsx @@ -29,6 +29,7 @@ import { sandboxTimeoutMinutesFromMs, sandboxTimeoutMsFromMinutes, } from "./sandbox-timeout"; +import { SessionCostSettingsFields, useSessionCostSettings } from "./session-cost-settings-fields"; import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; const GLOBAL_SCOPE = "__global__"; @@ -281,7 +282,6 @@ export function SandboxSettingsEditor({ ownSettings?.maxTotalChildSessions ?? baseDefaults?.maxTotalChildSessions ?? DEFAULT_MAX_TOTAL_CHILD_SESSIONS; - const currentCpuCores = resourceDisplayValue(ownSettings, baseDefaults, "cpuCores"); const currentMemoryMib = resourceDisplayValue(ownSettings, baseDefaults, "memoryMib"); @@ -309,6 +309,7 @@ export function SandboxSettingsEditor({ maxConcurrentChildSessions ?? String(currentMaxConcurrentChildSessions); const resolvedMaxTotalChildSessions = maxTotalChildSessions ?? String(currentMaxTotalChildSessions); + const sessionCostSettings = useSessionCostSettings(ownSettings, baseDefaults, isGlobal); const resolvedCpuCores = cpuCores ?? (currentCpuCores !== undefined ? String(currentCpuCores) : ""); const resolvedMemoryMib = @@ -358,6 +359,12 @@ export function SandboxSettingsEditor({ return; } + const costSettingsError = sessionCostSettings.validate(); + if (costSettingsError) { + setError(costSettingsError); + return; + } + const trimmedCpu = resolvedCpuCores.trim(); if (trimmedCpu !== "" && !isValidCpuCores(trimmedCpu)) { setError("CPU cores must be a positive number."); @@ -502,6 +509,7 @@ export function SandboxSettingsEditor({ ) { settingsPayload.maxTotalChildSessions = Number(resolvedMaxTotalChildSessions); } + sessionCostSettings.apply(settingsPayload); const cpu = resourcePayloadValue(isGlobal, cpuCores, trimmedCpu, ownSettings?.cpuCores); if (cpu !== undefined) settingsPayload.cpuCores = cpu; const memory = resourcePayloadValue( @@ -531,6 +539,7 @@ export function SandboxSettingsEditor({ setTerminalEnabled(null); setMaxConcurrentChildSessions(null); setMaxTotalChildSessions(null); + sessionCostSettings.reset(); setCpuCores(null); setMemoryMib(null); setCodeServerPort(null); @@ -584,6 +593,7 @@ export function SandboxSettingsEditor({ hasTerminalChange || hasConcurrentLimitChange || hasTotalLimitChange || + sessionCostSettings.hasChanges || hasCpuChange || hasMemoryChange || hasCodeServerPortChange || @@ -741,6 +751,14 @@ export function SandboxSettingsEditor({ + +
Child Sessions

diff --git a/packages/web/src/components/settings/session-cost-settings-fields.test.tsx b/packages/web/src/components/settings/session-cost-settings-fields.test.tsx new file mode 100644 index 0000000000..795282d27c --- /dev/null +++ b/packages/web/src/components/settings/session-cost-settings-fields.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { act, render, renderHook, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { SandboxSettings } from "@open-inspect/shared/types/integrations"; +import { SessionCostSettingsFields, useSessionCostSettings } from "./session-cost-settings-fields"; + +describe("useSessionCostSettings", () => { + it("describes blank scoped limits as inherited", () => { + render( + undefined} + onCostWarningThresholdPctChange={() => undefined} + /> + ); + + expect(screen.getByText(/blank to inherit the broader setting/)).toBeInTheDocument(); + expect(screen.getByLabelText("Cost limit (USD)")).toHaveAttribute("placeholder", "Inherit"); + }); + + it("clears a scoped warning threshold back to inheritance", () => { + const { result } = renderHook(() => + useSessionCostSettings( + { costWarningThresholdPct: 75 }, + { costWarningThresholdPct: 80 }, + false + ) + ); + + act(() => result.current.setThreshold("")); + const payload: SandboxSettings = {}; + result.current.apply(payload); + + expect(result.current.validate()).toBeNull(); + expect(payload).not.toHaveProperty("costWarningThresholdPct"); + }); + + it("ignores threshold whitespace when detecting changes", () => { + const { result } = renderHook(() => + useSessionCostSettings({ costWarningThresholdPct: 75 }, undefined, false) + ); + + act(() => result.current.setThreshold("75 ")); + + expect(result.current.hasChanges).toBe(false); + }); +}); diff --git a/packages/web/src/components/settings/session-cost-settings-fields.tsx b/packages/web/src/components/settings/session-cost-settings-fields.tsx new file mode 100644 index 0000000000..65373fba94 --- /dev/null +++ b/packages/web/src/components/settings/session-cost-settings-fields.tsx @@ -0,0 +1,164 @@ +import { useState } from "react"; +import { + DEFAULT_COST_WARNING_THRESHOLD_PCT, + type SandboxSettings, +} from "@open-inspect/shared/types/integrations"; +import { Input } from "@/components/ui/input"; + +interface SessionCostSettingsFieldsProps { + isGlobal: boolean; + maxSessionCostUsd: string; + costWarningThresholdPct: string; + onMaxSessionCostUsdChange: (value: string) => void; + onCostWarningThresholdPctChange: (value: string) => void; +} + +export function SessionCostSettingsFields({ + isGlobal, + maxSessionCostUsd, + costWarningThresholdPct, + onMaxSessionCostUsdChange, + onCostWarningThresholdPctChange, +}: SessionCostSettingsFieldsProps) { + return ( +

+ Session Cost +

+ Stops additional model work after reported session cost reaches the limit. Leave the limit + blank {isGlobal ? "for unlimited sessions" : "to inherit the broader setting"}. Unreported + model cost cannot be limited. +

+
+
+ + onMaxSessionCostUsdChange(event.target.value)} + placeholder={isGlobal ? "No limit" : "Inherit"} + /> +
+
+ + onCostWarningThresholdPctChange(event.target.value)} + placeholder="Inherit default" + /> +
+
+
+ ); +} + +function validateSessionCostSettings( + maxSessionCostUsd: string, + costWarningThresholdPct: string +): string | null { + if ( + maxSessionCostUsd !== "" && + (!Number.isFinite(Number(maxSessionCostUsd)) || Number(maxSessionCostUsd) <= 0) + ) { + return "Session cost limit must be a positive USD amount."; + } + if ( + costWarningThresholdPct !== "" && + (!Number.isInteger(Number(costWarningThresholdPct)) || + Number(costWarningThresholdPct) < 1 || + Number(costWarningThresholdPct) > 99) + ) { + return "Cost warning threshold must be a whole percentage from 1 to 99."; + } + return null; +} + +function applySessionCostSettings( + target: SandboxSettings, + input: { + isGlobal: boolean; + maxCostEdit: string | null; + resolvedMaxCost: string; + thresholdEdit: string | null; + resolvedThreshold: string; + own: SandboxSettings | undefined; + } +): void { + if (input.isGlobal || input.maxCostEdit !== null || input.own?.maxSessionCostUsd !== undefined) { + if (input.resolvedMaxCost !== "") { + target.maxSessionCostUsd = Number(input.resolvedMaxCost); + } + } + if (input.thresholdEdit !== null || input.own?.costWarningThresholdPct !== undefined) { + if (input.resolvedThreshold !== "") { + target.costWarningThresholdPct = Number(input.resolvedThreshold); + } + } +} + +export function useSessionCostSettings( + own: SandboxSettings | undefined, + base: SandboxSettings | undefined, + isGlobal: boolean +) { + const currentMaxCost = own?.maxSessionCostUsd ?? base?.maxSessionCostUsd; + const currentThreshold = own?.costWarningThresholdPct ?? base?.costWarningThresholdPct; + const [maxCostEdit, setMaxCostEdit] = useState(null); + const [thresholdEdit, setThresholdEdit] = useState(null); + const maxCost = maxCostEdit ?? (currentMaxCost === undefined ? "" : String(currentMaxCost)); + const threshold = + thresholdEdit ?? + (currentThreshold === undefined + ? isGlobal + ? String(DEFAULT_COST_WARNING_THRESHOLD_PCT) + : "" + : String(currentThreshold)); + const initialThreshold = + currentThreshold === undefined + ? isGlobal + ? String(DEFAULT_COST_WARNING_THRESHOLD_PCT) + : "" + : String(currentThreshold); + + return { + maxCost, + threshold, + setMaxCost: setMaxCostEdit, + setThreshold: setThresholdEdit, + validate: () => validateSessionCostSettings(maxCost.trim(), threshold.trim()), + apply: (target: SandboxSettings) => + applySessionCostSettings(target, { + isGlobal, + maxCostEdit, + resolvedMaxCost: maxCost.trim(), + thresholdEdit, + resolvedThreshold: threshold.trim(), + own, + }), + reset: () => { + setMaxCostEdit(null); + setThresholdEdit(null); + }, + hasChanges: + (maxCostEdit !== null && maxCostEdit.trim() !== (currentMaxCost?.toString() ?? "")) || + (thresholdEdit !== null && thresholdEdit.trim() !== initialThreshold), + }; +} diff --git a/packages/web/src/components/sidebar/budget-section.test.tsx b/packages/web/src/components/sidebar/budget-section.test.tsx new file mode 100644 index 0000000000..2849ec5516 --- /dev/null +++ b/packages/web/src/components/sidebar/budget-section.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +/// + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { BudgetSection } from "./budget-section"; + +expect.extend(matchers); + +const fetchMock = vi.fn(); +vi.mock("@/lib/browser-api-fetch", () => ({ + browserApiFetch: (...args: unknown[]) => fetchMock(...args), +})); + +afterEach(() => { + cleanup(); + fetchMock.mockReset(); +}); + +describe("BudgetSection", () => { + it("shows observed cost, limit, and incomplete tracking", () => { + render( + + ); + expect(screen.getByText("Session cost: $3.42 of $10.00 limit")).toBeInTheDocument(); + expect(screen.getByText(/Cost tracking was unavailable/)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Edit limit" })).not.toBeInTheDocument(); + }); + + it("lets the owner remove the session limit", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole("button", { name: "Edit limit" })); + await user.click(screen.getByRole("button", { name: "No limit" })); + + expect(fetchMock).toHaveBeenCalledWith("/api/sessions/session-1/budget", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxCostUsd: null }), + }); + }); +}); diff --git a/packages/web/src/components/sidebar/budget-section.tsx b/packages/web/src/components/sidebar/budget-section.tsx new file mode 100644 index 0000000000..13f67495a7 --- /dev/null +++ b/packages/web/src/components/sidebar/budget-section.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState } from "react"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { formatSessionCost } from "@/lib/session-cost"; + +interface BudgetSectionProps { + sessionId: string; + totalCost: number; + maxSessionCostUsd?: number | null; + costTrackingUnavailable?: boolean; + canManageBudget: boolean; +} + +const DEFAULT_COST_TRACKING_UNAVAILABLE = false; + +export function BudgetSection({ + sessionId, + totalCost, + maxSessionCostUsd, + costTrackingUnavailable = DEFAULT_COST_TRACKING_UNAVAILABLE, + canManageBudget, +}: BudgetSectionProps) { + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + if (!canManageBudget && maxSessionCostUsd == null && totalCost <= 0 && !costTrackingUnavailable) { + return null; + } + + const updateLimit = async (maxCostUsd: number | null) => { + setSaving(true); + setError(null); + try { + const response = await browserApiFetch(`/api/sessions/${sessionId}/budget`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxCostUsd }), + }); + if (!response.ok) { + const body: unknown = await response.json().catch(() => null); + const serverMessage = + body && + typeof body === "object" && + typeof (body as { error?: unknown }).error === "string" + ? (body as { error: string }).error + : null; + throw new Error(serverMessage ?? "Unable to update the session cost limit"); + } + setEditing(false); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Unable to update the session cost limit"); + } finally { + setSaving(false); + } + }; + + const saveValue = () => { + const limit = Number(value); + if (!Number.isFinite(limit) || limit <= 0) { + setError("Enter a positive USD amount"); + return; + } + void updateLimit(limit); + }; + + return ( +
+
+ + {maxSessionCostUsd != null + ? `Session cost: ${formatSessionCost(totalCost)} of ${formatSessionCost(maxSessionCostUsd)} limit` + : totalCost > 0 + ? `Session cost: ${formatSessionCost(totalCost)}` + : "No session cost limit"} + + {canManageBudget && !editing && ( + + )} +
+ + {editing && ( +
+ +
+ setValue(event.target.value)} + className="min-w-0 flex-1 border border-border bg-input px-2 py-1 text-foreground" + disabled={saving} + /> + + +
+

Applies only to this session.

+
+ )} + + {costTrackingUnavailable && ( +

+ Cost tracking was unavailable for part of this session. Observed cost and limit + enforcement may be incomplete. +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/packages/web/src/components/sidebar/metadata-section.tsx b/packages/web/src/components/sidebar/metadata-section.tsx index f17d74f6cb..bea741a394 100644 --- a/packages/web/src/components/sidebar/metadata-section.tsx +++ b/packages/web/src/components/sidebar/metadata-section.tsx @@ -3,7 +3,6 @@ import { useState } from "react"; import Link from "next/link"; import { formatModelName, truncateBranch, copyToClipboard } from "@/lib/format"; -import { formatSessionCost } from "@/lib/session-cost"; import { formatRelativeTime } from "@/lib/time"; import { getSafeExternalUrl } from "@/lib/urls"; import { getScmBranchUrl, getScmRepoUrl } from "@/lib/scm"; @@ -51,7 +50,6 @@ interface MetadataSectionProps { /** Non-fatal boot/runtime warnings surfaced to the user. */ warnings?: WarningEvent[]; parentSessionId?: string | null; - totalCost?: number; canManageLifecycle: boolean; } @@ -108,7 +106,6 @@ export function MetadataSection({ environmentName, warnings = [], parentSessionId, - totalCost, canManageLifecycle, }: MetadataSectionProps) { const [copied, setCopied] = useState(false); @@ -170,12 +167,6 @@ export function MetadataSection({ )} - {typeof totalCost === "number" && totalCost > 0 && ( -
- Session cost: {formatSessionCost(totalCost)} -
- )} - {/* Environment provenance */} {environmentId && (
diff --git a/packages/web/src/hooks/use-session-socket.ts b/packages/web/src/hooks/use-session-socket.ts index 4eed409f33..c6333a2bd0 100644 --- a/packages/web/src/hooks/use-session-socket.ts +++ b/packages/web/src/hooks/use-session-socket.ts @@ -54,6 +54,7 @@ interface UseSessionSocketReturn { participants: ParticipantPresence[]; artifacts: Artifact[]; currentParticipantId: string | null; + canManageBudget: boolean; isProcessing: boolean; promptQueue: PromptQueueItem[]; hasMoreHistory: boolean; @@ -425,6 +426,7 @@ export function useSessionSocket( participants: state.participants, artifacts: state.artifacts, currentParticipantId: state.currentParticipantId, + canManageBudget: state.canManageBudget, isProcessing, promptQueue: state.promptQueue, hasMoreHistory, diff --git a/packages/web/src/hooks/use-session-transport.test.tsx b/packages/web/src/hooks/use-session-transport.test.tsx index a3e3a0b36e..e23d066693 100644 --- a/packages/web/src/hooks/use-session-transport.test.tsx +++ b/packages/web/src/hooks/use-session-transport.test.tsx @@ -101,6 +101,7 @@ describe("useSessionTransport", () => { type: "subscribe", token: "ws-token", clientId: "00000000-0000-0000-0000-000000000000", + capabilities: ["session_budget"], }, ]); await waitFor(() => { diff --git a/packages/web/src/hooks/use-session-transport.ts b/packages/web/src/hooks/use-session-transport.ts index dbe266fc29..ed091ce211 100644 --- a/packages/web/src/hooks/use-session-transport.ts +++ b/packages/web/src/hooks/use-session-transport.ts @@ -7,6 +7,7 @@ import { type ServerMessage, } from "@open-inspect/shared/types/server-messages"; import { + SESSION_BUDGET_CAPABILITY, WS_CLOSE_AUTHORIZATION_REVOKED, WS_CLOSE_INTERNAL_ERROR, } from "@open-inspect/shared/types/websocket"; @@ -195,6 +196,7 @@ export function useSessionTransport( type: "subscribe", token: wsTokenRef.current, clientId: crypto.randomUUID(), + capabilities: [SESSION_BUDGET_CAPABILITY], }) ); }, []); diff --git a/packages/web/src/lib/session-socket/reducer.test.ts b/packages/web/src/lib/session-socket/reducer.test.ts index 320ad99814..6ee44d7e07 100644 --- a/packages/web/src/lib/session-socket/reducer.test.ts +++ b/packages/web/src/lib/session-socket/reducer.test.ts @@ -240,6 +240,28 @@ describe("sessionSocketReducer", () => { }); describe("subscribed", () => { + it("hydrates budget management capability and applies authoritative budget updates", () => { + const subscribed = subscribedState({ canManageBudget: true }); + const state = reduce( + subscribed, + serverMessage({ + type: "budget_status", + totalCost: 10.5, + maxSessionCostUsd: 10, + budgetExhausted: true, + costTrackingUnavailable: false, + }) + ); + + expect(state.canManageBudget).toBe(true); + expect(state.sessionState).toMatchObject({ + totalCost: 10.5, + maxSessionCostUsd: 10, + budgetExhausted: true, + costTrackingUnavailable: false, + }); + }); + it("hydrates the authoritative projection", () => { const state = subscribedState({ session: createSessionState({ diff --git a/packages/web/src/lib/session-socket/reducer.ts b/packages/web/src/lib/session-socket/reducer.ts index 8ee8f2db0c..33436fadef 100644 --- a/packages/web/src/lib/session-socket/reducer.ts +++ b/packages/web/src/lib/session-socket/reducer.ts @@ -29,6 +29,7 @@ export interface SessionSocketState { participants: ParticipantPresence[]; artifacts: Artifact[]; currentParticipantId: string | null; + canManageBudget: boolean; hasMoreHistory: boolean; loadingHistory: boolean; cursor: HistoryCursor | null; @@ -51,6 +52,7 @@ export const initialSessionSocketState: SessionSocketState = { participants: [], artifacts: [], currentParticipantId: null, + canManageBudget: false, hasMoreHistory: false, loadingHistory: false, cursor: null, @@ -190,6 +192,7 @@ function reduceServerMessage( }, artifacts: message.artifacts.map(toUiArtifact), currentParticipantId: message.participantId || state.currentParticipantId, + canManageBudget: message.canManageBudget ?? false, events: renderTimelineEvents(timelineEvents), hasMoreHistory: message.timeline.hasMore, cursor: message.timeline.cursor, @@ -302,6 +305,15 @@ function reduceServerMessage( isProcessing: message.isProcessing, })); + case "budget_status": + return updateSessionState(state, (prev) => ({ + ...prev, + totalCost: message.totalCost, + maxSessionCostUsd: message.maxSessionCostUsd, + budgetExhausted: message.budgetExhausted, + costTrackingUnavailable: message.costTrackingUnavailable, + })); + case "prompt_queue_updated": return { ...state, promptQueue: message.promptQueue };