import type { SandboxRecoveryProjection, SandboxRecoverySelection, SandboxRecoveryTarget, SandboxRecoveryRequest, } from "@opengeni/sdk"; import { OpenGeniApiError } from "@opengeni/sdk/browser"; export type { SandboxRecoveryRequest } from "@opengeni/sdk"; export function sameRecoveryTarget( left: SandboxRecoveryTarget | undefined, right: SandboxRecoveryTarget, ): boolean { return ( !!left && (Object.keys(right) as Array).every( (key) => left[key] === right[key], ) ); } export type SandboxRecoveryClient = { getSandboxRecovery: ( workspaceId: string, sessionId: string, ) => Promise; recoverSandbox: ( workspaceId: string, sessionId: string, request: SandboxRecoveryRequest, ) => Promise<{ operationId: string; recovery: SandboxRecoveryProjection }>; }; export function sameRecoverySelection( left: SandboxRecoverySelection | null, right: SandboxRecoverySelection | null, ): boolean { if (!left || !right) return false; return ( left.version === right.version && left.sessionId === right.sessionId && left.sandboxGroupId === right.sandboxGroupId && left.leaseId === right.leaseId && left.routeEpoch === right.routeEpoch && left.authorityEpoch === right.authorityEpoch && left.leaseEpoch === right.leaseEpoch && left.workspaceGeneration === right.workspaceGeneration && left.archiveGeneration === right.archiveGeneration && left.artifactId === right.artifactId && left.revision === right.revision && left.capturedAt === right.capturedAt ); } export type SandboxRecoveryState = { projection: SandboxRecoveryProjection | null; reading: boolean; submitting: boolean; request: SandboxRecoveryRequest | null; uncertain: boolean; error: string | null; /** * The API refused the read with 403 and no consent request is retained: * recovery requires the canonical managed-human cookie session plus session * control, so it cannot apply to this viewer (local mode, API keys, delegated * or read-only principals). */ notApplicable: boolean; }; /** A read-only 403 means this viewer can never consent, not that the check failed. */ export function isRecoveryNotApplicableError(error: unknown): boolean { return error instanceof OpenGeniApiError && error.status === 403; } /** Times are shown to the minute; round up so a Retry at the shown minute is * never refused for the remaining seconds. */ function nextMinute(iso: string): string { const time = Date.parse(iso); return Number.isFinite(time) ? new Date(Math.ceil(time / 60_000) * 60_000).toISOString() : iso; } /** Public blocker codes are stable; UI copy must not expose persistence jargon. * A timed wait never implies Opengeni proceeds by itself: only Retry or a new * message decides again, so the copy names when that becomes possible. */ export function sandboxRecoveryBlocker(reason: string, availableAt?: string | null): string { const message = sandboxRecoveryBlockerMessage(reason); if (!TIMED_RECOVERY_WAITS.has(reason)) return message; return availableAt ? `${message} You can retry after ${formatCheckpointTime(nextMinute(availableAt))}.` : `${message} You can retry later.`; } const TIMED_RECOVERY_WAITS: ReadonlySet = new Set([ "restore_retry_backoff", "provider_lifetime_unexpired", "capture_unresolved", ]); function sandboxRecoveryBlockerMessage(reason: string): string { const messages: Record = { group_control_required: "Starting an empty workspace affects every chat sharing it. Ask your operator if you do not control them all.", fresh_workspace_selected: "An empty workspace was selected. Previous files were not restored; their archive is retained. Your conversation and external changes are unchanged.", archive_retry_selected: "The retained archive was selected for another restore attempt. No commands were replayed.", recovery_not_enabled: "Checkpoint recovery has not been enabled by your operator.", managed_modal_home_required: "Recovery supports only this session's managed cloud sandbox.", connected_machine_selected: "This session now uses a Connected Machine. Check prior execution outcomes before retrying.", singleton_required: "Checkpoint consent is available only when this session does not share its sandbox. Ask your operator to review this session.", shared_sandbox_member_active: "Another session sharing this sandbox is still running or waiting for input. Retry becomes available once it settles.", restore_retry_backoff: "The last checkpoint restore failed. The checkpoint is kept for the next attempt.", restore_retry_exhausted: "Restoring the checkpoint failed repeatedly. The checkpoint is kept; ask your operator to review this session.", provider_lifetime_unexpired: "No checkpoint can be restored automatically. Retry can continue with an empty workspace once the lost sandbox's provider lifetime has ended.", automatic_recovery_pending: "Opengeni is already recovering this sandbox automatically. Retry to continue.", retry_tool_outcome_unresolved: "A tool call in the failed turn has no recorded outcome, so Retry cannot safely reopen it. Send a new message to continue; the lost sandbox then recovers automatically.", checkpoint_unavailable: "No recoverable checkpoint is available.", registered_current_checkpoint_required: "No verified current checkpoint is available for this recovery.", checkpoint_metadata_invalid: "Checkpoint details could not be verified.", checkpoint_artifact_invalid: "The selected checkpoint could not be verified.", historical_checkpoint_not_required: "An older checkpoint restore is not required for this sandbox.", lease_not_quiescent: "The sandbox has not reached a safe state for recovery.", session_not_quiescent: "This session is active or cancelled and cannot accept recovery.", execution_unresolved: "Execution may still be active. Recovery must wait until its outcome is settled.", capture_unresolved: "A checkpoint capture is still unresolved. Recovery can continue once it settles.", restore_failed: "Restoration failed. Operator review is required; no commands were replayed.", consent_stale: "The accepted checkpoint consent is no longer current. Operator review is required.", restored_checkpoint_no_longer_ready: "The restored sandbox is no longer ready. Operator review is required.", session_unavailable: "This session is unavailable.", }; return messages[reason] ?? "Recovery is blocked. Ask your operator to review this session."; } /** What an automatic Retry will do after the managed sandbox was lost. */ export function automaticRecoveryRetryNotice(projection: SandboxRecoveryProjection): string { if (projection.automaticLane === "fresh_workspace") { return "Retry will continue with an empty workspace. Opengeni cannot restore the previous sandbox files automatically."; } const capturedAt = (projection.automaticCheckpoint ?? projection.checkpoint)?.capturedAt; return capturedAt ? `Retry will use the latest verified checkpoint from ${formatCheckpointTime(capturedAt)}. Newer files are unavailable.` : "Retry will use the latest verified checkpoint. Newer files are unavailable."; } function formatCheckpointTime(value: string): string { const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return value; return parsed.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", }); } /** GETs may repeat; a consented mutation never repeats automatically. */ export function createSandboxRecoveryController( client: SandboxRecoveryClient, workspaceId: string, sessionId: string, ) { let state: SandboxRecoveryState = { projection: null, reading: false, submitting: false, request: null, uncertain: false, error: null, notApplicable: false, }; const listeners = new Set<() => void>(); let read: Promise | null = null; let revision = 0; let confirming = false; const update = (patch: Partial) => { state = { ...state, ...patch }; for (const listener of listeners) listener(); }; function refresh(): Promise { if (read) return read; const startedRevision = revision; update({ reading: true }); read = (async () => { try { const projection = await client.getSandboxRecovery(workspaceId, sessionId); if (revision !== startedRevision) return null; const observedRequest = Boolean( state.request && projection.operationId === state.request.operationId && (["consent_accepted", "restoring", "restored"].includes(projection.status) || "action" in state.request), ); update({ projection, error: null, notApplicable: false, ...(observedRequest ? { uncertain: false } : {}), ...(observedRequest && state.request && "action" in state.request && ["eligible", "blocked", "restored"].includes(projection.status) ? { request: null } : {}), }); return projection; } catch (error) { if (revision === startedRevision) { // Never leave an old eligible action live after an unavailable read. // A retained consent request stays fail-closed: losing read access // after consent is not evidence the lane stopped applying. const notApplicable = isRecoveryNotApplicableError(error) && !state.request; update({ projection: null, notApplicable, error: notApplicable ? null : "Could not check checkpoint recovery. No new recovery request was sent.", }); } return null; } finally { read = null; update({ reading: false }); } })(); return read; } async function submit(request: SandboxRecoveryRequest): Promise { revision++; update({ request, submitting: true, error: null }); try { const response = await client.recoverSandbox(workspaceId, sessionId, request); if (response.operationId !== request.operationId) throw new Error("Mismatched recovery receipt"); revision++; const settled = "action" in request && ["eligible", "blocked", "restored"].includes(response.recovery.status); update({ projection: response.recovery, uncertain: false, ...(settled ? { request: null } : {}), }); return true; } catch (error) { revision++; const rejected = error instanceof OpenGeniApiError && !error.outcomeUnknown && error.status >= 400 && error.status < 500 && ![401, 403, 404].includes(error.status); update({ projection: null, request: rejected ? null : request, uncertain: !rejected, error: rejected ? "Recovery was not accepted. Check availability and review the selection again." : "The recovery outcome is not confirmed. Check status; the original request is retained and no request will be sent again automatically.", }); return false; } finally { update({ submitting: false }); } } return { getSnapshot: () => state, subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }, refresh, async recoverWorkspace( action: "retry" | "continue_fresh", target: SandboxRecoveryTarget, ): Promise { if (confirming || state.request || state.submitting) return false; confirming = true; try { const current = await refresh(); if ( !current || target.sessionId !== sessionId || !sameRecoveryTarget(current.target, target) || !(action === "retry" ? current.retryAvailable : current.freshWorkspaceAvailable) ) { update({ error: "Recovery availability changed. Review the current workspace before continuing.", }); return false; } const common = { operationId: crypto.randomUUID(), target: Object.freeze({ ...target }) }; return await submit( Object.freeze( action === "retry" ? { ...common, action } : { ...common, action, acceptEmptyWorkspace: true as const }, ), ); } finally { confirming = false; } }, async consent(selection: SandboxRecoverySelection): Promise { if (confirming || state.request || state.submitting) return false; confirming = true; try { // Consent is bound to what was displayed, not a newer polling result. const current = await refresh(); if ( current?.status !== "eligible" || selection.sessionId !== sessionId || !sameRecoverySelection(current.checkpoint, selection) ) { update({ error: "Recovery availability changed. Review the current checkpoint before consenting again.", }); return false; } const request: SandboxRecoveryRequest = Object.freeze({ operationId: crypto.randomUUID(), acceptHistoricalCheckpoint: true, selection: Object.freeze({ ...selection }), }); return await submit(request); } finally { confirming = false; } }, }; }