// State + payload mapping for the rich create-session form. // // The composer is organised around ONE top-level question — "Where should this // run?" — modelled here as a discriminated `ComputeTarget`. That choice is the // parent that gates the rest of the form (repos/env on a managed sandbox; a // machine + working folder on a connected machine). `SessionDraft` collapses the // old split between the per-mount "advanced" draft and the global selection so // the compute target and everything it gates live in one consistent state. // // Wire fields are unchanged (PR-1, no contract change): a managed sandbox still // sends `sandboxBackend`/`rigId`; a connected machine still sends the // top-level `targetSandboxId` (+ Stage A's `workingDir`). Only the form shape and // the gating change. import { CAPABILITY_DESCRIPTORS, DEFAULT_FIRST_PARTY_MCP_TOOLS, FIRST_PARTY_MCP_TOOL_CAPABILITIES, isDerivedAgentToolOwner, mergeResourceRefs, Permission, stableJson, type AgentCapabilities, type CapabilityDescriptor, type FirstPartyMcpToolName, type MachineView, } from "@opengeni/contracts"; import type { AgentConfigRequest, CreateSessionRequest, NewSessionDraftOptions, NewSessionSelectionHistory, PersonalResourceAttachmentIntent, } from "@opengeni/sdk"; import { capabilityOn, draftFromRequest } from "./agent-capabilities"; import { sessionMcpPermissionGroups } from "./permissions"; import type { GoalSpec, LatencyMode, ReasoningEffort, ResourceRef, SandboxBackend, ToolRef, TurnSubmission, } from "../types"; import { buildTools } from "./session-tools"; // ── Compute target — the promoted top-level "Where should this run?" choice ── /** A platform-owned ephemeral sandbox. `backend === ""` is the deployment * default. The composer no longer exposes a managed-backend override; keep the * field so drafts/API mapping stay stable if we re-enable it later. */ export type ManagedSandboxTarget = { kind: "sandbox"; backend: SandboxBackend | ""; }; /** The working folder on a connected machine. `root` selects the reported * launch root; `path` is absolute or relative to it. */ export type MachineFolder = { kind: "root" } | { kind: "path"; path: string }; /** A user-owned enrolled machine the platform attaches to (no clone, no teardown, * the machine's own env & git auth). `sandboxId` is `null` until one is picked. */ export type ConnectedMachineTarget = { kind: "machine"; sandboxId: string | null; folder: MachineFolder; }; export type ComputeTarget = ManagedSandboxTarget | ConnectedMachineTarget; export function rememberedProjectCompute( history: NewSessionSelectionHistory, channelId: string | null, defaultSandboxBackend?: SandboxBackend, ): ComputeTarget | null { const project = history.projects.find((candidate) => candidate.channelId === channelId); if (!project) return null; if (!project.targetSandboxId) { return defaultSandboxBackend === "selfhosted" ? { kind: "machine", sandboxId: null, folder: { kind: "root" } } : { kind: "sandbox", backend: "" }; } const machine = project.machines.find( (candidate) => candidate.sandboxId === project.targetSandboxId, ); return { kind: "machine", sandboxId: project.targetSandboxId, folder: machine?.workingDir ? { kind: "path", path: machine.workingDir } : { kind: "root" }, }; } export function rememberedMachineFolder( history: NewSessionSelectionHistory, channelId: string | null, sandboxId: string, ): MachineFolder { const machine = history.projects .find((project) => project.channelId === channelId) ?.machines.find((candidate) => candidate.sandboxId === sandboxId); return machine?.workingDir ? { kind: "path", path: machine.workingDir } : { kind: "root" }; } export type SessionDraft = { agentLearning?: import("@opengeni/sdk").AgentLearningOverrides | undefined; visibility: "private" | "workspace"; // PROMOTED — the parent that gates the compute-dependent band. compute: ComputeTarget; // The rig the session materializes (managed sandbox only; a connected machine // is the user's own box, so it never rides a rig). "" ⇒ the workspace default // rig, resolved server-side. rigId: string; goalText: string; goalSuccessCriteria: string; goalMaxAutoContinuations: string; customMcpPermissions: boolean; mcpPermissions: Set; firstPartyMcpTools: Set; /** * "Customize for this chat" capabilities. Undefined follows the workspace's * defaults and sends no `agent` at all. */ agentCapabilities?: AgentCapabilities | undefined; /** * This chat's own instructions, from a draft another page prepared (the * signup setup chat). Not edited in the composer; sent as `agent.instructions`. */ agentInstructions?: string | undefined; /** * The chat's "+" > Secrets choice. Undefined means every vault the person * can reach (no narrowing); sent as `agent.vaultPolicy`. */ vaultPolicy?: import("@opengeni/sdk").VaultPolicy | undefined; }; /** * Label for the composer's empty Sandbox Environment choice. The empty choice * sends no rigId, so the server binds the workspace default. Name that * default so people can see what they * would replace by picking another environment; say "None" when the * workspace has no default, because the empty choice then means no * environment at all. */ export function workspaceDefaultRigOptionLabel( workspaceDefaultRigId: string | null | undefined, rigs: readonly { id: string; name: string }[], ): string { if (!workspaceDefaultRigId) return "None"; const rig = rigs.find((candidate) => candidate.id === workspaceDefaultRigId); return rig ? `Workspace default: ${rig.name}` : "Workspace default"; } export function emptySessionDraft( defaultFirstPartyMcpTools: readonly FirstPartyMcpToolName[] = DEFAULT_FIRST_PARTY_MCP_TOOLS, defaultSandboxBackend?: SandboxBackend, ): SessionDraft { return { visibility: "workspace", compute: defaultSandboxBackend === "selfhosted" ? { kind: "machine", sandboxId: null, folder: { kind: "root" } } : { kind: "sandbox", backend: "" }, rigId: "", goalText: "", goalSuccessCriteria: "", goalMaxAutoContinuations: "", customMcpPermissions: false, mcpPermissions: new Set(sessionMcpPermissionGroups().flatMap((group) => group.permissions)), firstPartyMcpTools: new Set(defaultFirstPartyMcpTools), }; } function explicitFirstPartyTools( draft: SessionDraft, defaultFirstPartyMcpTools: readonly FirstPartyMcpToolName[], ): { firstPartyMcpTools?: FirstPartyMcpToolName[] | undefined; } { const selected = [...draft.firstPartyMcpTools]; if ( selected.length === defaultFirstPartyMcpTools.length && defaultFirstPartyMcpTools.every((tool) => draft.firstPartyMcpTools.has(tool)) ) { return {}; } if (draft.agentCapabilities === undefined) return { firstPartyMcpTools: selected }; // A chat with its own capabilities never names a tool of one that is off. const values = draftFromRequest(draft.agentCapabilities).values; return { firstPartyMcpTools: selected.filter((tool) => { const owner = FIRST_PARTY_MCP_TOOL_CAPABILITIES[tool]; return isDerivedAgentToolOwner(owner) || capabilityOn(values, owner); }), }; } /** * The composer's connector policy for a chat with its own capabilities: * Workspace connectors off sends no connector choice at all (the server then * attaches none), and a built-in server of a capability that is off is left * out instead of being refused. Applied to the draft and the create alike, so * the draft snapshot the server checks matches the request. */ export function fitToolPolicyToAgentCapabilities< Policy extends { tools: ToolRef[]; toolsProvided: boolean; excludedMcpServerIds?: string[] | undefined; }, >(policy: Policy, capabilities: AgentCapabilities | undefined): Policy { if (capabilities === undefined) return policy; const values = draftFromRequest(capabilities).values; if (!values.workspaceConnectors) { const { excludedMcpServerIds: _excluded, ...rest } = policy; return { ...rest, tools: [], toolsProvided: false } as unknown as Policy; } return { ...policy, tools: policy.tools.filter( (tool) => !(tool.id === "files" && !values.workspaceFiles) && !(tool.id === "docs" && !values.knowledge), ), }; } /** True once the draft can be submitted: a connected machine needs a picked * machine; a managed sandbox is always ready. */ export function isSessionDraftComputeReady(draft: SessionDraft): boolean { return draft.compute.kind !== "machine" || draft.compute.sandboxId !== null; } /** Personal workspace access is already owner-only. Use private session tenancy * when the server supports it, without making its activation a prerequisite. */ export function newSessionCreateVisibility( personalWorkspace: boolean, selectedVisibility: "private" | "workspace", canCreatePrivate: boolean, ): "private" | "workspace" { return personalWorkspace ? (canCreatePrivate ? "private" : "workspace") : selectedVisibility; } export type SessionDraftSubmission = { /** TurnSubmission extras merged into the create payload. */ extras: Omit; /** Top-level create fields threaded into `startSession` separately. */ options: { targetSandboxId: string | null; workingDir: string | null; visibility?: "private" | "workspace" | undefined; }; /** When true (a connected machine) the workspace's selected repos must NOT be * cloned: the machine uses its own checkout & git auth (D3). This is the UI * half of the clone-gating footgun fix — the selection is retained in context * (lossless toggle-back) but excluded from the create's `resources[]`. */ omitWorkspaceResources: boolean; }; export type BuildCreateSessionRequestInput = { currentResources: ResourceRef[]; submission: TurnSubmission; /** Session-scoped system guidance that is not rendered in the chat timeline. */ instructions?: string | undefined; /** Installed session-selected Skills to freeze onto the new session. */ installedSkillIds?: string[] | undefined; startMode?: "realtime" | undefined; visibility?: "private" | "workspace" | undefined; omitWorkspaceResources?: boolean | undefined; selectedTools: ToolRef[]; /** Exact policy acknowledged by the revision-fenced new-session draft. */ newSessionDraftToolPolicy?: | { tools: ToolRef[]; toolsProvided: boolean; excludedMcpServerIds?: string[] | undefined; } | undefined; defaultModel: string; defaultReasoningEffort: ReasoningEffort; defaultLatencyMode: LatencyMode; clientEventId: string; idempotencyKey: string; targetSandboxId?: string | null | undefined; workingDir?: string | null | undefined; channelId?: string | null | undefined; expectedNewSessionDraftRevision?: number | undefined; agentLearning?: import("@opengeni/sdk").AgentLearningOverrides | undefined; /** * Per-task usage limit in USD micros. Only sent when the server advertises * `sessionUsageLimits.enabled` and a limit was chosen; null or omitted sends * nothing (no limit), so older servers never see the field. */ usageLimitMicros?: number | null | undefined; /** Server-authoritative omitted-tools defaults, including mandatory opengeni. */ workspaceDefaultMcpServerIds?: string[] | undefined; /** Prevent a partially hydrated capability catalog from becoming a pin. */ workspaceMcpCatalogReady?: boolean | undefined; }; export type PendingCreateAttempt = { client: object; workspaceId: string; signature: string; idempotencyKey: string; eventId: string; }; export function classifyCreateSessionFailure(error: unknown): { error: Error; outcomeUnknown: boolean; } { return { error: error instanceof Error ? error : new Error(String(error)), outcomeUnknown: typeof error === "object" && error !== null && (error as { outcomeUnknown?: unknown }).outcomeUnknown === true, }; } /** A create may be retried with the same key only when the transport cannot * prove whether the server committed it. Definitive HTTP results end that * logical attempt so a corrected, reconfirmed request gets a fresh key. */ export function retainCreateSessionAttemptAfterFailure(input: { current: PendingCreateAttempt | null; attempted: PendingCreateAttempt; outcomeUnknown: boolean; }): PendingCreateAttempt | null { if (input.current?.idempotencyKey !== input.attempted.idempotencyKey) { return input.current; } return input.outcomeUnknown ? input.current : null; } /** * Build the one canonical create payload without mutating UI state. Resource * identity and mount conflicts are resolved by the shared contract helper; * exact duplicates collapse while order remains first-seen stable. */ export function buildCreateSessionRequest( input: BuildCreateSessionRequestInput, ): CreateSessionRequest { const baseResources = input.omitWorkspaceResources ? [] : input.currentResources; const resources = mergeResourceRefs( [], [...baseResources, ...(input.submission.resources ?? [])], { rejectConflicts: true }, ); const selectedToolIds = [ ...new Set( buildTools( [], input.selectedTools.map((tool) => tool.id), ).map((tool) => tool.id), ), ].sort(); const defaultToolIds = input.workspaceDefaultMcpServerIds ? [...new Set(input.workspaceDefaultMcpServerIds)].sort() : null; const draftPolicy = input.newSessionDraftToolPolicy; const exclusionOnlyCustomize = draftPolicy?.toolsProvided === true && draftPolicy.tools.length === 0 && draftPolicy.excludedMcpServerIds !== undefined; const tools = draftPolicy ? draftPolicy.toolsProvided && !exclusionOnlyCustomize ? [...draftPolicy.tools] : undefined : input.workspaceMcpCatalogReady === true && defaultToolIds && selectedToolIds.join("\u0000") === [...new Set(buildTools([], defaultToolIds).map((tool) => tool.id))].sort().join("\u0000") ? undefined : [...input.selectedTools]; return { ...(input.startMode === "realtime" ? { startMode: "realtime" as const } : { initialMessage: input.submission.text }), visibility: input.visibility ?? "workspace", instructions: input.instructions || undefined, ...(input.installedSkillIds?.length ? { installedSkillIds: input.installedSkillIds } : {}), resources, ...(tools === undefined ? {} : { tools }), ...(input.newSessionDraftToolPolicy?.excludedMcpServerIds !== undefined ? { excludedMcpServerIds: input.newSessionDraftToolPolicy.excludedMcpServerIds } : {}), model: input.submission.model ?? input.defaultModel, reasoningEffort: input.submission.reasoningEffort ?? input.defaultReasoningEffort, latencyMode: input.submission.latencyMode ?? input.defaultLatencyMode, clientEventId: input.clientEventId, idempotencyKey: input.idempotencyKey, ...(input.submission.sandboxBackend ? { sandboxBackend: input.submission.sandboxBackend } : {}), ...(input.submission.rigId ? { rigId: input.submission.rigId } : {}), ...(input.submission.goal ? { goal: input.submission.goal } : {}), ...(input.submission.firstPartyMcpPermissions ? { firstPartyMcpPermissions: input.submission.firstPartyMcpPermissions } : {}), ...(input.submission.firstPartyMcpTools ? { firstPartyMcpTools: input.submission.firstPartyMcpTools } : {}), ...(input.submission.personalResourceAttachment ? { personalResourceAttachment: input.submission.personalResourceAttachment, } : {}), ...(input.submission.connectionAccounts ? { connectionAccounts: input.submission.connectionAccounts } : {}), ...(input.submission.agent ? { agent: input.submission.agent } : {}), ...(input.targetSandboxId ? { targetSandboxId: input.targetSandboxId } : {}), ...(input.workingDir ? { workingDir: input.workingDir } : {}), ...(input.channelId ? { channelId: input.channelId } : {}), ...(input.agentLearning && Object.keys(input.agentLearning).length ? { agentLearning: input.agentLearning } : {}), ...(typeof input.usageLimitMicros === "number" ? { usageLimitMicros: input.usageLimitMicros } : {}), ...(input.expectedNewSessionDraftRevision !== undefined ? { expectedNewSessionDraftRevision: input.expectedNewSessionDraftRevision, } : {}), }; } /** * Bind create and first-message identities to the exact logical request. A * transport retry must retain both: otherwise a committed first event cannot * reconcile with the optimistic handoff returned by the replay. A draft may * acquire a new OCC revision while retaining the same create value. * A changed value, workspace, or authenticated client starts a new logical * create instead of reviving a partially initialized session with stale input. */ export function prepareCreateSessionAttempt(input: { pending: PendingCreateAttempt | null; client: object; workspaceId: string; request: CreateSessionRequest; freshIdempotencyKey: string; }): { pending: PendingCreateAttempt; request: CreateSessionRequest } { const { clientEventId: _clientEventId, idempotencyKey: _idempotencyKey, expectedNewSessionDraftRevision: _expectedNewSessionDraftRevision, ...logicalRequest } = input.request; const signature = stableJson(logicalRequest); const idempotencyKey = input.pending?.client === input.client && input.pending.workspaceId === input.workspaceId && input.pending.signature === signature ? input.pending.idempotencyKey : input.freshIdempotencyKey; const clientEventId = idempotencyKey === input.pending?.idempotencyKey ? input.pending.eventId : (input.request.clientEventId ?? idempotencyKey); return { pending: { client: input.client, workspaceId: input.workspaceId, signature, idempotencyKey, eventId: clientEventId, }, request: { ...input.request, idempotencyKey, clientEventId }, }; } /** The single submit mapper: turns a `SessionDraft` into the create payload, * branching on the compute kind (the one discriminant). */ export function submissionFromSessionDraft( draft: SessionDraft, defaultFirstPartyMcpTools: readonly FirstPartyMcpToolName[] = DEFAULT_FIRST_PARTY_MCP_TOOLS, personalResourceAttachment?: PersonalResourceAttachmentIntent | undefined, ): SessionDraftSubmission { const goal = goalFromDraft(draft); const mcp = draft.customMcpPermissions ? { firstPartyMcpPermissions: [...draft.mcpPermissions] } : {}; const visibleTools = explicitFirstPartyTools(draft, defaultFirstPartyMcpTools); if (draft.compute.kind === "machine") { return { // No sandboxBackend (forced `selfhosted` server-side); the machine's own // env and git auth apply (D2). Secrets arrive per command by reference. extras: { ...(personalResourceAttachment ? { personalResourceAttachment } : {}), ...(goal ? { goal } : {}), ...mcp, ...visibleTools, ...agentExtras(draft), }, options: { targetSandboxId: draft.compute.sandboxId, workingDir: workingDirFromFolder(draft.compute.folder), visibility: draft.visibility, }, omitWorkspaceResources: true, }; } return { extras: { ...(draft.compute.backend ? { sandboxBackend: draft.compute.backend } : {}), ...(draft.rigId ? { rigId: draft.rigId } : {}), ...(personalResourceAttachment ? { personalResourceAttachment } : {}), ...(goal ? { goal } : {}), ...mcp, ...visibleTools, ...agentExtras(draft), }, options: { targetSandboxId: null, workingDir: null, visibility: draft.visibility, }, omitWorkspaceResources: false, }; } /** * Project the editable create form into the deliberately narrow set of options * that may survive before a session exists. Attempt-scoped ids and credential * material have no representation here by construction. */ export function newSessionDraftOptionsFromSessionDraft( draft: SessionDraft, defaultFirstPartyMcpTools: readonly FirstPartyMcpToolName[] = DEFAULT_FIRST_PARTY_MCP_TOOLS, effectiveVisibility: "private" | "workspace" = draft.visibility, ): NewSessionDraftOptions { const goal = goalFromDraft(draft); const permissions = draft.customMcpPermissions ? { firstPartyMcpPermissions: [...draft.mcpPermissions].map((permission) => Permission.parse(permission), ), } : {}; const visibleTools = explicitFirstPartyTools(draft, defaultFirstPartyMcpTools); if (draft.compute.kind === "machine") { const workingDir = workingDirFromFolder(draft.compute.folder); return { visibility: effectiveVisibility, ...(draft.agentLearning ? { agentLearning: draft.agentLearning } : {}), ...(draft.compute.sandboxId ? { targetSandboxId: draft.compute.sandboxId } : {}), ...(workingDir ? { workingDir } : {}), ...(goal ? { goal } : {}), ...permissions, ...visibleTools, ...agentExtras(draft), }; } return { visibility: effectiveVisibility, ...(draft.agentLearning ? { agentLearning: draft.agentLearning } : {}), ...(draft.compute.backend ? { sandboxBackend: draft.compute.backend } : {}), ...(draft.rigId ? { rigId: draft.rigId } : {}), ...(goal ? { goal } : {}), ...permissions, ...visibleTools, ...agentExtras(draft), }; } function agentExtras(draft: SessionDraft): { agent?: AgentConfigRequest | undefined } { const vaultPolicy = draft.vaultPolicy?.mode === "selected" ? draft.vaultPolicy : undefined; if ( draft.agentCapabilities === undefined && draft.agentInstructions === undefined && vaultPolicy === undefined ) { return {}; } return { agent: { ...(draft.agentCapabilities !== undefined ? { capabilities: draft.agentCapabilities } : {}), ...(draft.agentInstructions !== undefined ? { instructions: draft.agentInstructions } : {}), ...(vaultPolicy !== undefined ? { vaultPolicy } : {}), }, }; } /** Restore server-authoritative create options into the single UI draft form. */ export function sessionDraftFromNewSessionDraftOptions( options: NewSessionDraftOptions, defaultFirstPartyMcpTools: readonly FirstPartyMcpToolName[] = DEFAULT_FIRST_PARTY_MCP_TOOLS, defaultSandboxBackend?: SandboxBackend, ): SessionDraft { const base = emptySessionDraft(defaultFirstPartyMcpTools, defaultSandboxBackend); const machine = Boolean( options.targetSandboxId || options.workingDir || options.sandboxBackend === "selfhosted" || defaultSandboxBackend === "selfhosted", ); return { ...base, visibility: options.visibility ?? "workspace", ...(options.agentLearning ? { agentLearning: options.agentLearning } : {}), compute: machine ? { kind: "machine", sandboxId: options.targetSandboxId ?? null, folder: options.workingDir ? { kind: "path", path: options.workingDir } : { kind: "root" }, } : { kind: "sandbox", // Preserve an explicit managed choice when the workspace defaults to a machine. backend: options.sandboxBackend && options.sandboxBackend !== "selfhosted" ? options.sandboxBackend : "", }, rigId: options.rigId ?? "", goalText: options.goal?.text ?? "", goalSuccessCriteria: options.goal?.successCriteria ?? "", goalMaxAutoContinuations: options.goal?.maxAutoContinuations === undefined ? "" : String(options.goal.maxAutoContinuations), customMcpPermissions: options.firstPartyMcpPermissions !== undefined, mcpPermissions: options.firstPartyMcpPermissions === undefined ? base.mcpPermissions : new Set(options.firstPartyMcpPermissions), firstPartyMcpTools: options.firstPartyMcpTools === undefined ? base.firstPartyMcpTools : new Set(options.firstPartyMcpTools), ...(options.agent?.capabilities !== undefined ? { agentCapabilities: options.agent.capabilities } : {}), ...(options.agent?.instructions !== undefined ? { agentInstructions: options.agent.instructions } : {}), ...(options.agent?.vaultPolicy?.mode === "selected" ? { vaultPolicy: options.agent.vaultPolicy } : {}), }; } /** The requested machine directory, or null for the reported launch root. The * server resolves relative values to one effective absolute path. */ function workingDirFromFolder(folder: MachineFolder): string | null { return folder.kind === "path" ? folder.path.trim() || null : null; } function goalFromDraft(draft: SessionDraft): GoalSpec | null { if (!draft.goalText.trim()) { return null; } const maxAutoContinuations = nonNegativeInteger(draft.goalMaxAutoContinuations); return { text: draft.goalText.trim(), ...(draft.goalSuccessCriteria.trim() ? { successCriteria: draft.goalSuccessCriteria.trim() } : {}), ...(maxAutoContinuations !== null ? { maxAutoContinuations } : {}), }; } function nonNegativeInteger(value: string): number | null { const parsed = Number(value); return value.trim() && Number.isInteger(parsed) && parsed > 0 ? parsed : null; } // ── Managed sandbox backend options (descriptor-driven) ────────────────────── // // Replaces the hand-maintained literal: every managed backend is sourced from // `CAPABILITY_DESCRIPTORS` (the `selfhosted` row is the Connected Machine kind, so // it is excluded), and each option surfaces the capability metadata the table // already carries (Desktop/Recording/lifetime). export type ManagedBackendOption = { value: SandboxBackend | ""; label: string; /** Capability summary chips, e.g. ["Desktop", "Recording", "24h"]. */ chips: string[]; }; const MANAGED_BACKEND_LABELS: Partial> = { docker: "Docker", modal: "Modal", local: "Local", none: "None (no sandbox)", daytona: "Daytona", runloop: "Runloop", e2b: "E2B", blaxel: "Blaxel", cloudflare: "Cloudflare", vercel: "Vercel", opensandbox: "OpenSandbox", compute: "Opengeni compute", }; function backendLabel(backend: SandboxBackend): string { return MANAGED_BACKEND_LABELS[backend] ?? backend.slice(0, 1).toUpperCase() + backend.slice(1); } function descriptorChips(descriptor: CapabilityDescriptor): string[] { const chips: string[] = []; if (descriptor.capabilities.DesktopStream.available) { chips.push("Desktop"); } if (descriptor.capabilities.Recording.available) { chips.push("Recording"); } const hardLifetimeMs = descriptor.lifetime.hardLifetimeMs; if (hardLifetimeMs) { chips.push(formatLifetime(hardLifetimeMs)); } // Fall back to the tier so a headless/dev/none backend still reads as something. if (chips.length === 0 && descriptor.tier !== "none") { chips.push(descriptor.tier.slice(0, 1).toUpperCase() + descriptor.tier.slice(1)); } return chips; } function formatLifetime(ms: number): string { const hours = ms / (60 * 60 * 1000); return Number.isInteger(hours) ? `${hours}h` : `${Math.round(hours)}h`; } /** Managed backend choices (deployment default first). Excludes `selfhosted` * (Connected Machine kind). Kept for tests / a possible future override UI — * the create composer no longer surfaces this control. */ export function managedBackendOptions(): ManagedBackendOption[] { const managed = ( Object.entries(CAPABILITY_DESCRIPTORS) as Array<[SandboxBackend, CapabilityDescriptor]> ) .filter(([backend]) => backend !== "selfhosted") .map(([backend, descriptor]) => ({ value: backend, label: backendLabel(backend), chips: descriptorChips(descriptor), })); return [{ value: "", label: "Deployment default", chips: [] }, ...managed]; } /** The capability chips for a connected (selfhosted) machine, reflecting the * SELECTED machine's real capabilities — not the static descriptor. * FileSystem/Terminal/Git are always available, so they show even before a * machine is picked. The static descriptor *proclaims* DesktopStream, but that * is consent-gated at enrollment and absent on a headless machine — so the * "Desktop" chip is shown only when the picked machine actually has a display * (`hasDisplay`). A headless machine therefore never shows "Desktop"; the caller * surfaces a distinct "no display" indicator instead. */ export function selfhostedCapabilityChips(machine?: MachineView | null): string[] { const descriptor = CAPABILITY_DESCRIPTORS.selfhosted; const chips: string[] = []; if (descriptor.capabilities.FileSystem.available) { chips.push("FileSystem"); } if (descriptor.capabilities.Terminal.available) { chips.push("Terminal"); } if (descriptor.capabilities.Git.available) { chips.push("Git"); } // Per-machine truth, not the proclaimed static descriptor: only a machine that // actually reports a display can offer the desktop stream. if (machine?.hasDisplay) { chips.push("Desktop"); } return chips; }