diff --git a/.specs/cloud-agent-session.md b/.specs/cloud-agent-session.md index 3e8b08f228..09c69d1754 100644 --- a/.specs/cloud-agent-session.md +++ b/.specs/cloud-agent-session.md @@ -8,12 +8,14 @@ itself, without reading logs. It does not prescribe implementation and it carries no test mechanics: stack setup, account provisioning, and the mapping from a rule to a wire event belong to the `test-cloud-agent` skill. Any backend serving this chat MUST satisfy this surface; the user MUST NOT be able to tell -which implementation they are on. +which implementation they are on beyond the bounded runtime-metadata exception +specified under Sandbox Status. All other runtime and infrastructure privacy +requirements remain in force. ## Status -Draft -- created 2026-08-24, revised 2026-08-26 for worktree navigation, grouped -browser-chat tabs, and their filesystem durability boundary. +Draft -- created 2026-08-24, revised 2026-08-27 to define the passive sandbox +status contract alongside shared worktree navigation and durability. ## Conventions @@ -182,6 +184,110 @@ repository. Incomplete cleanup MUST remain recoverable, MUST NOT report success, and MUST NOT permit late creation or ingestion to resurrect the worktree. +### Sandbox Status + +1. Eligible owned control-plane Cloud Agent web chats MUST show a compact, + icon-only sandbox lifecycle control in the header. A fixed box icon MUST + identify the sandbox, with a small bottom-right static badge for its state. + Badge shapes and accessible labels MUST distinguish states without relying + only on color or a loading spinner. Details MUST be available through + hover, keyboard focus, and touch/click, with visible focus and Escape + dismissal. Sandbox lifecycle MUST remain distinct from agent progress and + compute-billing phase; observing it MUST NOT change the composer, terminal, + or turn behavior. +2. The public snapshot MUST contain only `status`, `provider`, `observedAt`, + `detailCode`, `inactivityTimeoutMs`, `estimatedSleepAt`, and an OPTIONAL + `runtime` object. Status MUST be one of `active`, `sleeping`, `starting`, + `stopping`, `error`, `unreachable`, or `unknown`. Provider MUST be exactly + `Cloudflare`, `Vercel`, or `Unknown`, derived from authoritative stored + provider information rather than an arbitrary provider string. When present, + `runtime` MUST contain only nullable `sandboxType`, `kiloCliVersion`, + `wrapperVersion`, `startedAt`, and `stoppedAt` fields. Sandbox types MUST be + bounded to shared, isolated-small, isolated-standard, code-review, + devcontainer, or unknown. Versions MUST be bounded, validated values reported + by the relevant runtime, not inferred from the current deployment. Runtime + identifiers, owner identifiers, infrastructure addresses or regions, + credentials, and raw backend errors MUST NOT appear in status responses or + details. Unknown response fields MUST NOT survive validation. Validation + errors MUST NOT be forwarded as raw backend diagnostics. +3. Detail codes MUST be bounded and consistent with status. The safe meanings + are: + + | Status | Detail code | Meaning | + |---|---|---| + | `active` | `sandbox_ready` | The sandbox is active. | + | `sleeping` | `sandbox_stopped` | The sandbox is sleeping; a message can resume it. | + | `starting` | `sandbox_starting` | The sandbox is starting. | + | `stopping` | `sandbox_stopping` | The sandbox is stopping. | + | `error` | `sandbox_failed` | The sandbox encountered an error; a message can retry. | + | `unreachable` | `connection_unavailable` | The sandbox connection is unavailable; its current state cannot be confirmed. | + | `unknown` | `insufficient_evidence` | There is not enough information to confirm the current state. | + | `unknown` | `status_unavailable` | Sandbox status is temporarily unavailable; this does not mean the sandbox failed. | + + Loading a snapshot MUST use the static `unknown` presentation, never + authoritative `starting`. Failure of the observation service MUST use + `status_unavailable`, not + `sandbox_failed`, and MUST remain non-blocking. Failed, paused, unauthorized, + or stale observations MUST NOT retain current-looking active status or a + sleep estimate. A snapshot from another session or personal/organization + context MUST NOT be displayed. +4. Observation MUST be passive, including first access after reconstruction + and repeated polling. It MUST NOT wake, start, probe, extend, keep alive, + bill, or otherwise mutate a sandbox. It MUST NOT trigger recovery, repair + lifecycle records, change alarms, request wrapper frames, or initialize + operational work indirectly. It MAY use validated stored state and evidence + already received on the current connection. Existing session authorization, + exact organization isolation, and billing rules MUST remain unchanged; + reading status MUST NOT require credits or admit paid work. +5. Status observation MUST accept only valid control-plane `workspace_` + references. Legacy `agent_`, unresolved, demo, remote, unrelated, and + read-only sessions MUST NOT show the indicator or request its status. + Legacy and malformed references MUST be rejected before any legacy runtime + lookup. This does not change the Read-only rules for historical sessions. +6. For control-plane `workspace_` sessions, confirmed stopped, creating, + stopping, failed, and unknown physical states correspond to `sleeping`, + `starting`, `stopping`, `error`, and `unknown`. A missing physical record MUST + yield `unknown`, not synthetic `sleeping`. Running requires fresh readiness + from the unique current open, handshaken connection to establish `active`; + explicit current not-ready evidence means `starting`, while a disconnected + or expired connection means `unreachable`. Historical readiness or provider + instance identity alone MUST NOT establish current readiness. +7. `observedAt` MUST be the snapshot creation time in Unix epoch milliseconds, + not a claim that the sandbox was probed. Timestamps MUST be finite, + nonnegative whole milliseconds within the representable date range. The + applicable `inactivityTimeoutMs` MUST be a finite positive whole-millisecond + duration, or `null` when unknown. It MUST describe the real inactivity policy, + not wrapper retention or a provider's maximum lifetime. The current policy + is 5 minutes for control-plane idle stop; a policy MUST NOT be guessed for an + unsupported provider or session. +8. `estimatedSleepAt` MUST be `null` unless an active sandbox has a known + inactivity policy, a valid future idle-stop deadline, and coherent fresh + sandbox-wide idle evidence. Busy, finalizing, stalled, incomplete, stale, or + mismatched activity evidence MUST suppress the estimate. A stored deadline + alone is insufficient. Supported estimates MUST be presented as approximate + remaining minutes, rounded up and conditional on continued inactivity, never + exact promises. The popup MUST reveal the estimate only after two minutes of + the confirmed idle window, derived from the returned deadline and inactivity + policy. This display delay MUST NOT change + the actual idle-stop policy. The toolbar MUST show `Sleeping soon` in the + final 60 seconds of a supported deadline. Expiry of an estimate MUST NOT + locally invent `sleeping`; new authoritative evidence is required. +9. Details MUST group the current text status, runtime information, and known + lifecycle timing. Runtime information MUST identify the provider and bounded + sandbox type using plain-language labels. Hover, click, and touch MUST show + the same popup, including a Debug section collapsed by default with labeled + control-plane mode and reported CLI/wrapper versions. Hover MUST NOT move + keyboard focus. Moving into the popup MUST keep it open; clicking the trigger + or interacting inside MUST keep the same view open until dismissed. Compact + runtime codes MUST NOT be shown. Missing versions + MUST display Unknown rather than guessed values. Debug MUST NOT contain a + unique sandbox or provider identifier. + Known physical start/stop dates MUST use local display times; unknown dates + MUST be omitted. Creation requests, wrapper readiness, chat creation, and + billing intervals MUST NOT substitute for physical lifecycle dates. Retained + runtime metadata MUST be isolated to its allocation and MUST NOT carry into a + replacement. Status observation MUST NOT collect or persist new metadata. + ### Persistence 1. Refresh MUST restore the transcript, the preparation history that was shown, @@ -215,9 +321,12 @@ repository. Cloud administration surfaces beyond the existing chat sidebar (MCP Gateway, triggers, webhooks), profile and organization administration, the mobile app, -and the CLI. Also out of scope, and deliberately so: which sandbox provider -runs the environment, the shape of session ids, and the streaming wire protocol. -A tester MUST NOT be able to tell those apart from the chat. +and the CLI. +The bounded provider, sandbox type, debug metadata, and known lifecycle dates in +Sandbox Status are a narrow exception to runtime opacity. They do not permit +arbitrary provider names, runtime identifiers, infrastructure addresses, +credentials, or raw errors. Session-id formats and the streaming wire protocol +remain implementation details that MUST NOT be exposed in status details. ## Not Yet Implemented @@ -234,6 +343,16 @@ The following use SHOULD and are not enforced today: ## Changelog +### 2026-09-02 -- Structured sandbox details + +- Made the toolbar icon-only with a fixed sandbox icon, static status badges, + and a final-minute sleep warning. +- Grouped details into status, runtime, and timing; kept bounded reported + versions in a collapsed Debug section instead of a compact runtime code. +- Showed approximate remaining minutes if inactive after two minutes idle, + without changing the five-minute stop policy. Unknown lifecycle dates remain + omitted. + ### 2026-09-01 -- Shared worktree auto-commit - Restored automatic commit and push for shared worktrees, enabled by default, @@ -249,6 +368,15 @@ The following use SHOULD and are not enforced today: - Defined transcript and group recovery separately from physical filesystem durability without changing terminal or permission requirements. +### 2026-08-27 -- Sandbox status contract + +- Added a bounded passive lifecycle snapshot and distinct observation-failure + details for control-plane web sessions only. +- Required supported inactivity policies and explicitly approximate optional + sleep times. +- Allowed only a bounded human-readable provider label, preserving all other + runtime, infrastructure, credential, and error privacy guarantees. + ### 2026-08-24 -- Audit revision - Corrected: a failing setup command fails the turn on a rebuild too, not only diff --git a/apps/web/src/components/cloud-agent-next/ChatHeader.tsx b/apps/web/src/components/cloud-agent-next/ChatHeader.tsx index fa1651c326..e7774b3d2e 100644 --- a/apps/web/src/components/cloud-agent-next/ChatHeader.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatHeader.tsx @@ -18,6 +18,7 @@ import { SoundToggleButton } from '@/components/shared/SoundToggleButton'; import { FeedbackDialog } from './FeedbackDialog'; import { buildRepoBrowseUrl, detectGitPlatform } from './utils/git-utils'; import { useTRPC } from '@/lib/trpc/utils'; +import { SandboxStatusIndicator } from './SandboxStatusIndicator'; export function computeBillingRefetchInterval( sessionActive: boolean, @@ -45,6 +46,7 @@ type ChatHeaderProps = { onToggleSound?: () => void; sessionTitle?: string; sessionActive: boolean; + sandboxStatusEligible?: boolean; }; export function ChatHeader({ @@ -64,6 +66,7 @@ export function ChatHeader({ organizationId, sessionTitle, sessionActive, + sandboxStatusEligible = false, }: ChatHeaderProps) { const [showActionsDialog, setShowActionsDialog] = useState(false); const moreOptionsTriggerRef = useRef(null); @@ -124,16 +127,24 @@ export function ChatHeader({ repository={repository} />
+ {sandboxStatusEligible && ( + + )} {onToggleSound && ( - + )} diff --git a/apps/web/src/components/cloud-agent-next/SandboxStatusIndicator.tsx b/apps/web/src/components/cloud-agent-next/SandboxStatusIndicator.tsx new file mode 100644 index 0000000000..6518c1035e --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/SandboxStatusIndicator.tsx @@ -0,0 +1,376 @@ +'use client'; + +import { useEffect, useId, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + Box, + ChevronRight, + Circle, + CircleQuestionMark, + Clock3, + Moon, + Power, + PowerOff, + TriangleAlert, + Unplug, + type LucideIcon, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { useRawTRPCClient, useTRPC } from '@/lib/trpc/utils'; +import { cn } from '@/lib/utils'; +import { + SANDBOX_STATUS_POLL_INTERVAL_MS, + observeSandboxStatus, + sandboxStatusPresentation, + type SandboxStatusPresentation, +} from './sandbox-status'; + +const statusBadgeIcons = { + active: Circle, + sleeping: Moon, + 'sleeping-soon': Clock3, + starting: Power, + stopping: PowerOff, + error: TriangleAlert, + unreachable: Unplug, + unknown: CircleQuestionMark, +} satisfies Record; + +function SandboxStatusDetails({ view }: { view: SandboxStatusPresentation }) { + const hasLifecycleDates = view.startedAt !== null || view.stoppedAt !== null; + const hasTiming = hasLifecycleDates || view.estimatedSleepAt !== null; + + return ( +
+
+
+ Sandbox + {view.label} +
+ {view.status !== 'active' &&

{view.detail}

} +
+
+

Runtime

+
+
Provider
+
{view.provider}
+
Sandbox type
+
{view.sandboxType}
+
+
+ {hasTiming && ( +
+
+

Timing

+ {hasLifecycleDates && Local time} +
+
+ {view.startedAt !== null && ( + <> +
Started
+
+ +
+ + )} + {view.stoppedAt !== null && ( + <> +
Stopped
+
+ +
+ + )} + {view.estimatedSleepAt !== null && ( + <> +
Sleeps in
+
+ +
+ + )} +
+
+ )} +
+ ); +} + +export function SandboxStatusIndicator({ + cloudAgentSessionId, + organizationId, + sessionActive, +}: { + cloudAgentSessionId: string; + organizationId?: string; + sessionActive: boolean; +}) { + const trpc = useTRPC(); + const trpcClient = useRawTRPCClient(); + const [observation, setObservation] = useState({ + enabled: false, + initialized: false, + freshAfter: 0, + }); + const [clock, setClock] = useState(0); + const [activity, setActivity] = useState({ active: sessionActive, changedAt: 0 }); + const [openMode, setOpenMode] = useState<'preview' | 'pinned' | null>(null); + const triggerRef = useRef(null); + const debugSummaryRef = useRef(null); + const closeTimerRef = useRef | null>(null); + const detailsId = useId(); + + function cancelPreviewClose() { + if (closeTimerRef.current !== null) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + } + + function closePopover() { + cancelPreviewClose(); + setOpenMode(null); + } + + function schedulePreviewClose() { + cancelPreviewClose(); + closeTimerRef.current = setTimeout(() => { + closeTimerRef.current = null; + setOpenMode(current => (current === 'preview' ? null : current)); + }, 150); + } + + function pinPopover() { + cancelPreviewClose(); + setOpenMode('pinned'); + } + + useEffect(() => { + return () => { + if (closeTimerRef.current !== null) clearTimeout(closeTimerRef.current); + }; + }, []); + + if (activity.active !== sessionActive) { + setActivity({ active: sessionActive, changedAt: Date.now() }); + } + + useEffect(() => { + const update = () => { + setObservation({ + enabled: document.visibilityState === 'visible' && navigator.onLine, + initialized: true, + freshAfter: Date.now(), + }); + }; + update(); + document.addEventListener('visibilitychange', update); + window.addEventListener('offline', update); + window.addEventListener('online', update); + return () => { + document.removeEventListener('visibilitychange', update); + window.removeEventListener('offline', update); + window.removeEventListener('online', update); + }; + }, []); + + const queryKey = organizationId + ? trpc.organizations.cloudAgentNext.getSandboxStatus.queryKey({ + organizationId, + cloudAgentSessionId, + }) + : trpc.cloudAgentNext.getSandboxStatus.queryKey({ cloudAgentSessionId }); + const query = useQuery({ + queryKey: [...queryKey, 'observation'], + queryFn: ({ signal }) => + observeSandboxStatus(() => + organizationId + ? trpcClient.organizations.cloudAgentNext.getSandboxStatus.query( + { organizationId, cloudAgentSessionId }, + { signal } + ) + : trpcClient.cloudAgentNext.getSandboxStatus.query({ cloudAgentSessionId }, { signal }) + ), + enabled: observation.enabled, + refetchInterval: observation.enabled ? SANDBOX_STATUS_POLL_INTERVAL_MS : false, + refetchIntervalInBackground: false, + refetchOnWindowFocus: 'always', + refetchOnReconnect: 'always', + refetchOnMount: 'always', + staleTime: 0, + gcTime: 0, + retry: false, + throwOnError: false, + placeholderData: undefined, + }); + + useEffect(() => { + const refresh = () => { + if (document.visibilityState === 'visible' && navigator.onLine) { + void query.refetch({ cancelRefetch: false }); + } + }; + window.addEventListener('focus', refresh); + return () => window.removeEventListener('focus', refresh); + }, [query.refetch]); + + const now = Math.max(clock, Date.now()); + const view = sandboxStatusPresentation({ + data: query.data?.snapshot, + observation: !observation.initialized + ? 'checking' + : !observation.enabled || query.fetchStatus === 'paused' + ? 'paused' + : query.isError + ? 'unavailable' + : !query.isFetchedAfterMount || query.isPending + ? 'checking' + : 'observing', + requestedAt: query.data?.requestedAt ?? 0, + receivedAt: query.data?.receivedAt ?? 0, + freshAfter: observation.freshAfter, + estimateAfter: activity.changedAt, + sessionActive, + now, + }); + + useEffect(() => { + const deadline = view.nextChangeAt; + if (deadline === null) return; + const timer = setTimeout( + () => setClock(current => Math.max(current, deadline, Date.now())), + Math.max(0, deadline - Date.now()) + ); + return () => clearTimeout(timer); + }, [view.nextChangeAt]); + + const StatusBadgeIcon = statusBadgeIcons[view.status]; + + return ( + { + if (open) pinPopover(); + else closePopover(); + }} + > + + + + {openMode !== null && ( + { + if (openMode === 'preview') event.preventDefault(); + }} + onCloseAutoFocus={event => { + if (openMode === 'preview') event.preventDefault(); + }} + onEscapeKeyDown={() => { + if (openMode === 'pinned') triggerRef.current?.focus(); + }} + onKeyDown={event => { + if ( + event.key === 'Tab' && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + (event.shiftKey || event.target !== event.currentTarget) + ) { + triggerRef.current?.focus(); + closePopover(); + } + }} + > +
+ +
+
+ + +
+
Execution
+
Control plane
+
Kilo CLI
+
+ {view.kiloCliVersion ?? 'Unknown'} +
+
Wrapper
+
+ {view.wrapperVersion ?? 'Unknown'} +
+
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/cloud-agent-next/sandbox-status.test.ts b/apps/web/src/components/cloud-agent-next/sandbox-status.test.ts new file mode 100644 index 0000000000..97e989b1e4 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/sandbox-status.test.ts @@ -0,0 +1,652 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import type { CloudAgentSessionId, KiloSessionId } from '@kilocode/cloud-agent-sdk'; +import type { SandboxStatusSnapshot } from '@/routers/cloud-agent-next-schemas'; +import { + isSandboxStatusEligible, + observeSandboxStatus, + sandboxStatusPresentation, +} from './sandbox-status'; + +const now = 1_800_000_000_000; +const snapshot: SandboxStatusSnapshot = { + status: 'active', + provider: 'Cloudflare', + observedAt: now, + detailCode: 'sandbox_ready', + inactivityTimeoutMs: 300_000, + estimatedSleepAt: now + 180_000, +}; +const runtime = { + sandboxType: 'isolated-standard', + kiloCliVersion: '7.4.20', + wrapperVersion: '2.4.0', + startedAt: now - 600_000, + stoppedAt: null, +} satisfies NonNullable; +const observation = { + data: snapshot, + observation: 'observing' as const, + requestedAt: now, + receivedAt: now, + freshAfter: now, + estimateAfter: 0, + sessionActive: false, + now, +}; + +const sessionId = 'workspace_00000000-0000-4000-8000-000000000001' as CloudAgentSessionId; +const kiloSessionId = 'ses_status_first' as KiloSessionId; +const eligible = { + currentUserId: 'owner', + sessionId, + sessionIdFromParams: kiloSessionId, + activeSessionType: 'cloud-agent' as const, + isReadOnly: false, + fetchedSessionData: { kiloSessionId, cloudAgentSessionId: sessionId, organizationId: null }, +}; + +describe('sandbox status eligibility', () => { + it('requires a resolved owned workspace in the exact route scope', () => { + expect(isSandboxStatusEligible(eligible)).toBe(true); + expect( + isSandboxStatusEligible({ + ...eligible, + organizationId: 'org-1', + fetchedSessionData: { ...eligible.fetchedSessionData, organizationId: 'org-1' }, + }) + ).toBe(true); + }); + + it.each([ + { currentUserId: undefined }, + { sessionId: null }, + { sessionId: 'Starting session…' }, + { sessionId: 'workspace_invalid' }, + { sessionId: 'agent_00000000-0000-4000-8000-000000000001' }, + { activeSessionType: null }, + { activeSessionType: 'remote' as const }, + { activeSessionType: 'read-only' as const }, + { isReadOnly: true }, + { fetchedSessionData: null }, + { sessionIdFromParams: 'ses_other' }, + { organizationId: 'org-other' }, + { fetchedSessionData: { ...eligible.fetchedSessionData, organizationId: 'org-other' } }, + { fetchedSessionData: { ...eligible.fetchedSessionData, cloudAgentSessionId: null } }, + ])('excludes unresolved, unrelated, and unsupported contexts: %j', override => { + expect(isSandboxStatusEligible({ ...eligible, ...override })).toBe(false); + }); +}); + +describe('sandbox status observation', () => { + it('retains request time when a delayed response arrives after activity or resuming', async () => { + const clock = jest.spyOn(Date, 'now').mockReturnValue(now); + const pending = Promise.withResolvers(); + try { + const result = observeSandboxStatus(() => pending.promise); + clock.mockReturnValue(now + 10_000); + pending.resolve({ ...snapshot, observedAt: now + 10_000 }); + const received = await result; + expect(received.requestedAt).toBe(now); + expect(received.receivedAt).toBe(now + 10_000); + const input = { + ...observation, + data: received.snapshot, + requestedAt: received.requestedAt, + receivedAt: received.receivedAt, + now: now + 10_000, + }; + expect(sandboxStatusPresentation(input)).toMatchObject({ + status: 'active', + nextChangeAt: now + 15_000, + }); + expect(sandboxStatusPresentation({ ...input, estimateAfter: now + 1 })).toMatchObject({ + status: 'active', + estimatedSleepAt: null, + }); + expect(sandboxStatusPresentation({ ...input, freshAfter: now + 1 })).toMatchObject({ + status: 'unknown', + estimatedSleepAt: null, + }); + expect(sandboxStatusPresentation({ ...input, now: now + 15_000 })).toMatchObject({ + status: 'unknown', + estimatedSleepAt: null, + }); + } finally { + clock.mockRestore(); + } + }); +}); + +describe('sandbox status presentation', () => { + it('does not count request latency toward the two-minute idle reveal delay', () => { + const input = { + ...observation, + data: { + ...snapshot, + observedAt: now + 10_000, + estimatedSleepAt: now + 191_000, + }, + receivedAt: now + 10_000, + now: now + 10_000, + }; + expect(sandboxStatusPresentation(input)).toMatchObject({ + status: 'active', + estimatedSleepAt: null, + sleepMinutesRemaining: null, + nextChangeAt: now + 11_000, + }); + expect(sandboxStatusPresentation({ ...input, now: now + 11_000 })).toMatchObject({ + status: 'active', + estimatedSleepAt: input.data.estimatedSleepAt, + sleepMinutesRemaining: 3, + nextChangeAt: now + 15_000, + }); + }); + + it.each([-20_000, -1_000, 1_000, 20_000])( + 'uses client freshness and server-relative sleep timing with %d ms clock skew', + offsetMs => { + const observedAt = now - offsetMs; + const data = { + ...snapshot, + runtime, + observedAt, + estimatedSleepAt: observedAt + 120_000, + }; + expect(sandboxStatusPresentation({ ...observation, data, now: now + 500 })).toMatchObject({ + status: 'active', + provider: 'Cloudflare', + kiloCliVersion: runtime.kiloCliVersion, + estimatedSleepAt: data.estimatedSleepAt, + sleepMinutesRemaining: 2, + nextChangeAt: now + 15_000, + }); + expect(sandboxStatusPresentation({ ...observation, data, now: now + 15_000 })).toMatchObject({ + status: 'unknown', + estimatedSleepAt: null, + }); + expect( + sandboxStatusPresentation({ ...observation, data, estimateAfter: now + 1 }) + ).toMatchObject({ status: 'active', estimatedSleepAt: null }); + const finalMinute = { ...data, estimatedSleepAt: observedAt + 60_001 }; + expect(sandboxStatusPresentation({ ...observation, data: finalMinute })).toMatchObject({ + status: 'active', + sleepMinutesRemaining: 2, + nextChangeAt: now + 1, + }); + expect( + sandboxStatusPresentation({ ...observation, data: finalMinute, now: now + 1 }) + ).toMatchObject({ + status: 'sleeping-soon', + estimatedSleepAt: finalMinute.estimatedSleepAt, + sleepMinutesRemaining: 1, + nextChangeAt: now + 15_000, + }); + const expiring = { ...data, estimatedSleepAt: observedAt + 2_000 }; + expect( + sandboxStatusPresentation({ ...observation, data: expiring, now: now + 2_000 }) + ).toMatchObject({ status: 'active', estimatedSleepAt: null, nextChangeAt: now + 15_000 }); + } + ); + + it.each([ + ['active', 'sandbox_ready', 'Active'], + ['sleeping', 'sandbox_stopped', 'Sleeping'], + ['starting', 'sandbox_starting', 'Starting'], + ['stopping', 'sandbox_stopping', 'Stopping'], + ['error', 'sandbox_failed', 'Error'], + ['unreachable', 'connection_unavailable', 'Unreachable'], + ['unknown', 'insufficient_evidence', 'Unknown'], + ] as const)( + 'presents authoritative %s lifecycle without raw diagnostics', + (status, detailCode, label) => { + const view = sandboxStatusPresentation({ + ...observation, + data: { + ...snapshot, + status, + detailCode, + estimatedSleepAt: null, + privateRuntime: 'PRIVATE_SENTINEL', + }, + }); + expect(view).toMatchObject({ + status, + label, + provider: 'Cloudflare', + estimatedSleepAt: null, + sleepMinutesRemaining: null, + }); + expect(JSON.stringify(view)).not.toContain('PRIVATE_SENTINEL'); + } + ); + + it.each(['Cloudflare', 'Vercel', 'Unknown'] as const)( + 'uses the bounded %s provider', + provider => { + expect( + sandboxStatusPresentation({ ...observation, data: { ...snapshot, provider } }).provider + ).toBe(provider); + } + ); + + it.each([ + [180_000, 1], + [300_000, 3], + [600_000, 8], + ])( + 'reveals the estimate after two minutes using the returned %d ms policy', + (inactivityTimeoutMs, sleepMinutesRemaining) => { + const data = { + ...snapshot, + inactivityTimeoutMs, + estimatedSleepAt: now + inactivityTimeoutMs - 120_000 + 1, + }; + expect(sandboxStatusPresentation({ ...observation, data })).toMatchObject({ + estimatedSleepAt: null, + sleepMinutesRemaining: null, + nextChangeAt: now + 1, + }); + expect(sandboxStatusPresentation({ ...observation, data, now: now + 1 })).toMatchObject({ + estimatedSleepAt: data.estimatedSleepAt, + sleepMinutesRemaining, + }); + } + ); + + it('hides timing for a new idle window and an unknown policy', () => { + expect( + sandboxStatusPresentation({ + ...observation, + data: { ...snapshot, estimatedSleepAt: now + 300_000 }, + }) + ).toMatchObject({ + status: 'active', + estimatedSleepAt: null, + sleepMinutesRemaining: null, + nextChangeAt: now + 15_000, + }); + expect( + sandboxStatusPresentation({ + ...observation, + data: { ...snapshot, inactivityTimeoutMs: null, estimatedSleepAt: null }, + }) + ).toMatchObject({ status: 'active', estimatedSleepAt: null, sleepMinutesRemaining: null }); + }); + + it.each([ + [180_000, 3], + [120_001, 3], + [120_000, 2], + [119_999, 2], + [60_001, 2], + [60_000, 1], + [59_999, 1], + [1, 1], + ])('rounds %d ms remaining up to %d minutes', (remainingMs, sleepMinutesRemaining) => { + expect( + sandboxStatusPresentation({ + ...observation, + data: { ...snapshot, estimatedSleepAt: now + remainingMs }, + }) + ).toMatchObject({ + estimatedSleepAt: now + remainingMs, + sleepMinutesRemaining, + }); + }); + + it('schedules countdown changes before freshness expires without new polling evidence', () => { + const data = { ...snapshot, estimatedSleepAt: now + 121_000 }; + expect(sandboxStatusPresentation({ ...observation, data })).toMatchObject({ + status: 'active', + sleepMinutesRemaining: 3, + nextChangeAt: now + 1_000, + }); + expect(sandboxStatusPresentation({ ...observation, data, now: now + 999 })).toMatchObject({ + sleepMinutesRemaining: 3, + nextChangeAt: now + 1_000, + }); + expect(sandboxStatusPresentation({ ...observation, data, now: now + 1_000 })).toMatchObject({ + status: 'active', + estimatedSleepAt: data.estimatedSleepAt, + sleepMinutesRemaining: 2, + nextChangeAt: now + 15_000, + }); + }); + + it('expires freshness when it coincides with a countdown boundary', () => { + const data = { ...snapshot, estimatedSleepAt: now + 135_000 }; + expect(sandboxStatusPresentation({ ...observation, data })).toMatchObject({ + status: 'active', + sleepMinutesRemaining: 3, + nextChangeAt: now + 15_000, + }); + expect(sandboxStatusPresentation({ ...observation, data, now: now + 15_000 })).toMatchObject({ + status: 'unknown', + estimatedSleepAt: null, + sleepMinutesRemaining: null, + nextChangeAt: null, + }); + }); + + it('uses unknown for initial loading without a separate loading state', () => { + expect(sandboxStatusPresentation({ ...observation, observation: 'checking' })).toMatchObject({ + status: 'unknown', + label: 'Unknown', + kiloCliVersion: null, + wrapperVersion: null, + estimatedSleepAt: null, + sleepMinutesRemaining: null, + }); + }); + + it('enters sleeping soon at the final-minute boundary', () => { + const data = { ...snapshot, estimatedSleepAt: now + 60_001 }; + expect(sandboxStatusPresentation({ ...observation, data })).toMatchObject({ + status: 'active', + sleepMinutesRemaining: 2, + nextChangeAt: now + 1, + }); + expect(sandboxStatusPresentation({ ...observation, data, now: now + 1 })).toMatchObject({ + status: 'sleeping-soon', + label: 'Sleeping soon', + estimatedSleepAt: now + 60_001, + sleepMinutesRemaining: 1, + nextChangeAt: now + 15_000, + }); + }); + + it('cancels sleeping soon when activity invalidates the deadline', () => { + const data = { ...snapshot, estimatedSleepAt: now + 60_000 }; + for (const override of [{ sessionActive: true }, { estimateAfter: now + 1 }]) { + expect(sandboxStatusPresentation({ ...observation, data, ...override })).toMatchObject({ + status: 'active', + estimatedSleepAt: null, + sleepMinutesRemaining: null, + nextChangeAt: now + 15_000, + }); + } + }); + + it('requires fresh idle evidence after activity before restoring the countdown', () => { + const data = { ...snapshot, estimatedSleepAt: now + 121_000 }; + expect(sandboxStatusPresentation({ ...observation, data })).toMatchObject({ + sleepMinutesRemaining: 3, + nextChangeAt: now + 1_000, + }); + for (const override of [{ sessionActive: true }, { estimateAfter: now + 1 }]) { + expect(sandboxStatusPresentation({ ...observation, data, ...override })).toMatchObject({ + estimatedSleepAt: null, + sleepMinutesRemaining: null, + nextChangeAt: now + 15_000, + }); + } + expect( + sandboxStatusPresentation({ + ...observation, + data: { ...data, observedAt: now + 1_000 }, + requestedAt: now + 1_000, + receivedAt: now + 1_000, + estimateAfter: now + 1, + now: now + 1_000, + }) + ).toMatchObject({ + estimatedSleepAt: data.estimatedSleepAt, + sleepMinutesRemaining: 2, + nextChangeAt: now + 16_000, + }); + }); + + it.each(['paused', 'unavailable'] as const)( + 'suppresses all cached current evidence when %s', + state => { + expect(sandboxStatusPresentation({ ...observation, observation: state })).toMatchObject({ + status: 'unknown', + provider: 'Unknown', + kiloCliVersion: null, + wrapperVersion: null, + estimatedSleepAt: null, + sleepMinutesRemaining: null, + nextChangeAt: null, + }); + } + ); + + it('distinguishes observation failure from confirmed sandbox failure', () => { + const unavailable = sandboxStatusPresentation({ ...observation, observation: 'unavailable' }); + const failed = sandboxStatusPresentation({ + ...observation, + data: { ...snapshot, status: 'error', detailCode: 'sandbox_failed', estimatedSleepAt: null }, + }); + expect(unavailable.detail).toContain('does not mean the sandbox failed'); + expect(failed.detail).toContain('encountered an error'); + }); + + it.each([ + { now: now + 15_000 }, + { requestedAt: now - 15_000, freshAfter: 0 }, + { freshAfter: now + 1 }, + { requestedAt: now + 1 }, + ])('bounds freshness and requires fresh evidence after resuming: %j', override => { + expect( + sandboxStatusPresentation({ ...observation, data: { ...snapshot, runtime }, ...override }) + ).toMatchObject({ + status: 'unknown', + provider: 'Unknown', + sandboxType: 'Unknown', + kiloCliVersion: null, + wrapperVersion: null, + startedAt: null, + stoppedAt: null, + estimatedSleepAt: null, + sleepMinutesRemaining: null, + nextChangeAt: null, + }); + }); + + it('remains current before the 15-second boundary and schedules its expiry', () => { + expect( + sandboxStatusPresentation({ + ...observation, + data: { ...snapshot, runtime }, + now: now + 14_999, + }) + ).toMatchObject({ + status: 'active', + kiloCliVersion: '7.4.20', + wrapperVersion: '2.4.0', + sleepMinutesRemaining: 3, + nextChangeAt: now + 15_000, + }); + }); + + it.each([ + { sessionActive: true }, + { estimateAfter: now + 1 }, + { data: { ...snapshot, estimatedSleepAt: null } }, + { data: { ...snapshot, provider: 'Unknown' } }, + ])('hides unsupported or invalidated sleep estimates: %j', override => { + expect(sandboxStatusPresentation({ ...observation, ...override })).toMatchObject({ + status: 'active', + estimatedSleepAt: null, + sleepMinutesRemaining: null, + }); + }); + + it('expires an estimate without inventing sleeping', () => { + const data = { ...snapshot, estimatedSleepAt: now + 2_000 }; + expect(sandboxStatusPresentation({ ...observation, data })).toMatchObject({ + status: 'sleeping-soon', + estimatedSleepAt: now + 2_000, + sleepMinutesRemaining: 1, + nextChangeAt: now + 2_000, + }); + for (const elapsedMs of [2_000, 2_001]) { + expect( + sandboxStatusPresentation({ ...observation, data, now: now + elapsedMs }) + ).toMatchObject({ + status: 'active', + estimatedSleepAt: null, + sleepMinutesRemaining: null, + nextChangeAt: now + 15_000, + }); + } + }); + + it('exposes validated runtime versions without sandbox identities', () => { + const view = sandboxStatusPresentation({ + ...observation, + data: { + ...snapshot, + sandboxId: 'PRIVATE_SANDBOX_ID', + runtime: { ...runtime, providerRef: 'PRIVATE_PROVIDER_REF' }, + }, + }); + expect(view).toMatchObject({ + provider: 'Cloudflare', + sandboxType: 'Standard', + kiloCliVersion: '7.4.20', + wrapperVersion: '2.4.0', + startedAt: now - 600_000, + stoppedAt: null, + }); + expect(view).not.toHaveProperty('runtimeCode'); + expect(JSON.stringify(view)).not.toContain('PRIVATE_'); + }); + + it('preserves known lifecycle dates on a sleeping snapshot', () => { + expect( + sandboxStatusPresentation({ + ...observation, + data: { + ...snapshot, + status: 'sleeping', + detailCode: 'sandbox_stopped', + estimatedSleepAt: null, + provider: 'Vercel', + runtime: { + ...runtime, + sandboxType: 'isolated-small', + stoppedAt: now - 60_000, + }, + }, + }) + ).toMatchObject({ + status: 'sleeping', + detail: 'Send a message to resume.', + provider: 'Vercel', + sandboxType: 'Small', + kiloCliVersion: '7.4.20', + wrapperVersion: '2.4.0', + startedAt: now - 600_000, + stoppedAt: now - 60_000, + estimatedSleepAt: null, + sleepMinutesRemaining: null, + }); + }); + + it('does not invent versions or dates for older snapshots', () => { + expect(sandboxStatusPresentation(observation)).toMatchObject({ + kiloCliVersion: null, + wrapperVersion: null, + sandboxType: 'Unknown', + startedAt: null, + stoppedAt: null, + }); + }); + + it.each([ + { kiloCliVersion: null, wrapperVersion: '2.4.0' }, + { kiloCliVersion: '7.4.20', wrapperVersion: null }, + { kiloCliVersion: null, wrapperVersion: null }, + { kiloCliVersion: '7.4.20-canary.123', wrapperVersion: '2.4.0-rc.1' }, + ])('preserves validated nullable runtime versions: %j', versions => { + expect( + sandboxStatusPresentation({ + ...observation, + data: { ...snapshot, runtime: { ...runtime, ...versions } }, + }) + ).toMatchObject({ + ...versions, + status: 'active', + sandboxType: 'Standard', + startedAt: now - 600_000, + stoppedAt: null, + }); + }); + + it('preserves runtime versions when the authoritative lifecycle is unknown', () => { + expect( + sandboxStatusPresentation({ + ...observation, + data: { + ...snapshot, + runtime, + status: 'unknown', + detailCode: 'insufficient_evidence', + estimatedSleepAt: null, + }, + }) + ).toMatchObject({ + status: 'unknown', + provider: 'Cloudflare', + sandboxType: 'Standard', + kiloCliVersion: '7.4.20', + wrapperVersion: '2.4.0', + startedAt: now - 600_000, + stoppedAt: null, + estimatedSleepAt: null, + sleepMinutesRemaining: null, + }); + }); + + it.each(['paused', 'unavailable', 'checking'] as const)( + 'does not retain cached runtime details when %s', + state => { + expect( + sandboxStatusPresentation({ + ...observation, + observation: state, + data: { ...snapshot, runtime: { ...runtime, sandboxType: 'devcontainer' } }, + }) + ).toMatchObject({ + status: 'unknown', + provider: 'Unknown', + sandboxType: 'Unknown', + kiloCliVersion: null, + wrapperVersion: null, + startedAt: null, + stoppedAt: null, + estimatedSleepAt: null, + sleepMinutesRemaining: null, + }); + } + ); + + it.each([ + null, + undefined, + { ...snapshot, provider: 'PRIVATE_SENTINEL' }, + { ...snapshot, detailCode: 'PRIVATE_SENTINEL' }, + { ...snapshot, status: 'PRIVATE_SENTINEL' }, + { ...snapshot, status: 'sleeping' }, + { ...snapshot, observedAt: NaN }, + { ...snapshot, estimatedSleepAt: Infinity }, + { ...snapshot, runtime: { ...runtime, kiloCliVersion: 'PRIVATE_SENTINEL' } }, + { ...snapshot, runtime: { ...runtime, wrapperVersion: 'PRIVATE_SENTINEL' } }, + { ...snapshot, runtime: { ...runtime, kiloCliVersion: '7.4.20\nPRIVATE_SENTINEL' } }, + { ...snapshot, runtime: { ...runtime, wrapperVersion: '2.4.0+PRIVATE_SENTINEL' } }, + { ...snapshot, runtime: { ...runtime, sandboxType: 'PRIVATE_SENTINEL' } }, + ])('fails closed for malformed data without exposing values: %j', data => { + const view = sandboxStatusPresentation({ ...observation, data }); + expect(view).toMatchObject({ + status: 'unknown', + provider: 'Unknown', + kiloCliVersion: null, + wrapperVersion: null, + estimatedSleepAt: null, + sleepMinutesRemaining: null, + }); + expect(JSON.stringify(view)).not.toContain('PRIVATE_SENTINEL'); + }); +}); diff --git a/apps/web/src/components/cloud-agent-next/sandbox-status.ts b/apps/web/src/components/cloud-agent-next/sandbox-status.ts new file mode 100644 index 0000000000..18a5d4b163 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/sandbox-status.ts @@ -0,0 +1,204 @@ +import { + baseGetSandboxStatusNextOutputSchema, + baseGetSandboxStatusNextSchema, + SANDBOX_STATUS_DETAIL_MESSAGES, + type SandboxLifecycleStatus, + type SandboxProviderLabel, +} from '@/routers/cloud-agent-next-schemas'; +import type { FetchedSessionData, ResolvedSession } from '@kilocode/cloud-agent-sdk'; + +export const SANDBOX_STATUS_POLL_INTERVAL_MS = 5_000; +export const SANDBOX_STATUS_FRESHNESS_MS = 15_000; +export const SANDBOX_SLEEP_ESTIMATE_DELAY_MS = 120_000; +export const SANDBOX_SLEEP_SOON_MS = 60_000; + +export async function observeSandboxStatus(read: () => Promise) { + const requestedAt = Date.now(); + const snapshot = await read(); + return { snapshot, requestedAt, receivedAt: Date.now() }; +} + +export function isSandboxStatusEligible({ + currentUserId, + sessionId, + sessionIdFromParams, + organizationId, + activeSessionType, + isReadOnly, + fetchedSessionData, +}: { + currentUserId?: string; + sessionId: string | null; + sessionIdFromParams: string | null; + organizationId?: string; + activeSessionType: ResolvedSession['type'] | null; + isReadOnly: boolean; + fetchedSessionData: Pick< + FetchedSessionData, + 'kiloSessionId' | 'cloudAgentSessionId' | 'organizationId' + > | null; +}): boolean { + return Boolean( + currentUserId && + activeSessionType === 'cloud-agent' && + !isReadOnly && + fetchedSessionData && + fetchedSessionData.kiloSessionId === sessionIdFromParams && + fetchedSessionData.cloudAgentSessionId === sessionId && + (fetchedSessionData.organizationId ?? undefined) === organizationId && + baseGetSandboxStatusNextSchema.safeParse({ cloudAgentSessionId: sessionId }).success + ); +} + +const statusLabels = { + active: 'Active', + sleeping: 'Sleeping', + starting: 'Starting', + stopping: 'Stopping', + error: 'Error', + unreachable: 'Unreachable', + unknown: 'Unknown', +} satisfies Record; + +const sandboxTypes = { + shared: 'Shared', + 'isolated-small': 'Small', + 'isolated-standard': 'Standard', + 'code-review': 'Code review', + devcontainer: 'Custom environment', + unknown: 'Unknown', +}; + +export type SandboxStatusPresentation = { + status: SandboxLifecycleStatus | 'sleeping-soon'; + label: string; + detail: string; + provider: SandboxProviderLabel; + sandboxType: string; + kiloCliVersion: string | null; + wrapperVersion: string | null; + startedAt: number | null; + stoppedAt: number | null; + estimatedSleepAt: number | null; + sleepMinutesRemaining: number | null; + nextChangeAt: number | null; +}; + +export function sandboxStatusPresentation({ + data, + observation, + requestedAt, + receivedAt, + freshAfter, + estimateAfter, + sessionActive, + now, +}: { + data: unknown; + observation: 'checking' | 'paused' | 'unavailable' | 'observing'; + requestedAt: number; + receivedAt: number; + freshAfter: number; + estimateAfter: number; + sessionActive: boolean; + now: number; +}): SandboxStatusPresentation { + const unavailable: SandboxStatusPresentation = { + status: 'unknown', + label: 'Unknown', + detail: SANDBOX_STATUS_DETAIL_MESSAGES.status_unavailable, + provider: 'Unknown', + sandboxType: 'Unknown', + kiloCliVersion: null, + wrapperVersion: null, + startedAt: null, + stoppedAt: null, + estimatedSleepAt: null, + sleepMinutesRemaining: null, + nextChangeAt: null, + }; + + if (observation === 'checking') { + return { ...unavailable, detail: 'Sandbox status is not available yet.' }; + } + if (observation === 'paused') { + return { + ...unavailable, + detail: 'Status updates are paused while this page is hidden or offline.', + }; + } + if (observation === 'unavailable') return unavailable; + + const parsed = baseGetSandboxStatusNextOutputSchema.safeParse(data); + if (!parsed.success) return unavailable; + const snapshot = parsed.data; + const freshUntil = requestedAt + SANDBOX_STATUS_FRESHNESS_MS; + if ( + requestedAt < freshAfter || + receivedAt < requestedAt || + receivedAt > now || + now >= freshUntil + ) { + return { ...unavailable, detail: 'Sandbox status is out of date. Waiting for a fresh update.' }; + } + + const localSleepDeadline = + snapshot.estimatedSleepAt !== null + ? requestedAt + (snapshot.estimatedSleepAt - snapshot.observedAt) + : null; + const sleepDeadline = + !sessionActive && + snapshot.status === 'active' && + snapshot.provider !== 'Unknown' && + snapshot.inactivityTimeoutMs !== null && + requestedAt >= estimateAfter && + localSleepDeadline !== null && + localSleepDeadline > now + ? localSleepDeadline + : null; + const estimateVisibleAt = + sleepDeadline !== null && snapshot.inactivityTimeoutMs !== null + ? sleepDeadline - + snapshot.inactivityTimeoutMs + + SANDBOX_SLEEP_ESTIMATE_DELAY_MS + + (receivedAt - requestedAt) + : null; + const estimatedSleepAt = + estimateVisibleAt !== null && now >= estimateVisibleAt ? snapshot.estimatedSleepAt : null; + const sleepMinutesRemaining = + estimatedSleepAt !== null && sleepDeadline !== null + ? Math.ceil((sleepDeadline - now) / 60_000) + : null; + const sleepingSoon = sleepDeadline !== null && sleepDeadline - now <= SANDBOX_SLEEP_SOON_MS; + const deadlines = [freshUntil]; + if (sleepDeadline !== null) { + deadlines.push(sleepDeadline); + const sleepingSoonAt = sleepDeadline - SANDBOX_SLEEP_SOON_MS; + if (sleepingSoonAt > now) deadlines.push(sleepingSoonAt); + } + if (estimateVisibleAt !== null && estimateVisibleAt > now) deadlines.push(estimateVisibleAt); + if (sleepDeadline !== null && sleepMinutesRemaining !== null) { + deadlines.push(sleepDeadline - (sleepMinutesRemaining - 1) * 60_000); + } + + const runtime = snapshot.runtime; + + return { + status: sleepingSoon ? 'sleeping-soon' : snapshot.status, + label: sleepingSoon ? 'Sleeping soon' : statusLabels[snapshot.status], + detail: sleepingSoon + ? 'The sandbox will sleep soon if inactivity continues.' + : snapshot.status === 'sleeping' + ? 'Send a message to resume.' + : SANDBOX_STATUS_DETAIL_MESSAGES[snapshot.detailCode], + provider: snapshot.provider, + sandboxType: sandboxTypes[runtime?.sandboxType ?? 'unknown'], + kiloCliVersion: runtime?.kiloCliVersion ?? null, + wrapperVersion: runtime?.wrapperVersion ?? null, + startedAt: runtime?.startedAt ?? null, + stoppedAt: runtime?.stoppedAt ?? null, + estimatedSleepAt, + sleepMinutesRemaining, + nextChangeAt: Math.min(...deadlines), + }; +} diff --git a/apps/web/src/components/shared/SoundToggleButton.tsx b/apps/web/src/components/shared/SoundToggleButton.tsx index 06bcdbd81e..d5459d3657 100644 --- a/apps/web/src/components/shared/SoundToggleButton.tsx +++ b/apps/web/src/components/shared/SoundToggleButton.tsx @@ -10,7 +10,7 @@ type SoundToggleButtonProps = { /** Callback when toggle is clicked */ onToggle: () => void; /** Size variant - affects icon and button dimensions */ - size?: 'sm' | 'default'; + size?: 'sm' | 'default' | 'toolbar'; /** Additional CSS classes */ className?: string; }; @@ -27,14 +27,17 @@ export function SoundToggleButton({ className, }: SoundToggleButtonProps) { const iconSize = size === 'sm' ? 'h-3 w-3' : 'h-4 w-4'; - const buttonSize = size === 'sm' ? 'h-6 w-6' : 'h-8 w-8'; + const buttonClassName = + size === 'toolbar' + ? 'text-muted-foreground' + : cn(size === 'sm' ? 'h-6 w-6' : 'h-8 w-8', 'hover:text-blue-400'); return (