import type { WorkspaceCommandDefinition, RuntimeExposureStatus, WorkspaceRuntimeControlTarget, WorkspaceRuntimeService, } from "@paperclipai/shared"; import { listWorkspaceCommandDefinitions, matchWorkspaceRuntimeServiceToCommand, } from "@paperclipai/shared"; import { Activity, ExternalLink, Loader2, Play, RotateCcw, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useManagedSandboxOnly } from "@/hooks/useManagedSandboxOnly"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { timeAgo } from "@/lib/timeAgo"; import type { WorkspaceServiceControlAction, WorkspaceServiceControlEntry, } from "@/components/WorkspaceServiceControlBar"; export type WorkspaceRuntimeAction = "start" | "stop" | "restart" | "run"; export type WorkspaceRuntimeControlRequest = WorkspaceRuntimeControlTarget & { action: WorkspaceRuntimeAction; }; export type WorkspaceRuntimeControlItem = { key: string; title: string; kind: "service" | "job"; statusLabel: string; lifecycle: "shared" | "ephemeral" | null; healthStatus: "unknown" | "healthy" | "unhealthy" | null; exposure: RuntimeExposureStatus | null; command: string | null; cwd: string | null; port: number | null; url: string | null; canStart: boolean; canRun: boolean; workspaceCommandId?: string | null; runtimeServiceId?: string | null; serviceIndex?: number | null; disabledReason?: string | null; }; export type WorkspaceRuntimeControlSections = { services: WorkspaceRuntimeControlItem[]; jobs: WorkspaceRuntimeControlItem[]; otherServices: WorkspaceRuntimeControlItem[]; }; type LegacyWorkspaceRuntimeControlItem = WorkspaceRuntimeControlItem & { status?: string | null; }; type WorkspaceRuntimeControlsProps = { sections: WorkspaceRuntimeControlSections; items?: never; isPending?: boolean; pendingRequest?: WorkspaceRuntimeControlRequest | null; serviceEmptyMessage?: string; jobEmptyMessage?: string; emptyMessage?: never; disabledHint?: string | null; onAction: (request: WorkspaceRuntimeControlRequest) => void; className?: string; square?: boolean; } | { sections?: never; items: LegacyWorkspaceRuntimeControlItem[]; isPending?: boolean; pendingRequest?: WorkspaceRuntimeControlRequest | null; serviceEmptyMessage?: never; jobEmptyMessage?: never; emptyMessage?: string; disabledHint?: string | null; onAction: (request: WorkspaceRuntimeControlRequest) => void; className?: string; square?: boolean; }; export function hasRunningRuntimeServices( runtimeServices: Array<{ status: string }> | null | undefined, ) { return (runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running"); } function buildServiceItem( command: WorkspaceCommandDefinition, runtimeService: WorkspaceRuntimeService | null, canStartServices: boolean, ): WorkspaceRuntimeControlItem { return { key: `command:${command.id}:${runtimeService?.id ?? "idle"}`, title: command.name, kind: "service", statusLabel: runtimeService?.status ?? "stopped", lifecycle: runtimeService?.lifecycle ?? command.lifecycle, healthStatus: runtimeService?.healthStatus ?? "unknown", exposure: runtimeService?.exposure ?? null, command: runtimeService?.command ?? command.command, cwd: runtimeService?.cwd ?? command.cwd, port: runtimeService?.port ?? null, url: runtimeService?.url ?? null, canStart: canStartServices && !command.disabledReason, canRun: false, workspaceCommandId: command.id, runtimeServiceId: runtimeService?.id ?? null, serviceIndex: command.serviceIndex, disabledReason: command.disabledReason, }; } function buildJobItem( command: WorkspaceCommandDefinition, canRunJobs: boolean, ): WorkspaceRuntimeControlItem { return { key: `command:${command.id}`, title: command.name, kind: "job", statusLabel: "run once", lifecycle: null, healthStatus: null, exposure: null, command: command.command, cwd: command.cwd, port: null, url: null, canStart: false, canRun: canRunJobs && !command.disabledReason && Boolean(command.command), workspaceCommandId: command.id, runtimeServiceId: null, serviceIndex: null, disabledReason: command.disabledReason ?? (!command.command ? "This job is missing a command." : null), }; } export function buildWorkspaceRuntimeControlSections(input: { runtimeConfig: Record | null | undefined; runtimeServices: WorkspaceRuntimeService[] | null | undefined; canStartServices: boolean; canRunJobs?: boolean; }): WorkspaceRuntimeControlSections { const commands = listWorkspaceCommandDefinitions(input.runtimeConfig); const runtimeServices = [...(input.runtimeServices ?? [])]; const matchedRuntimeServiceIds = new Set(); const services: WorkspaceRuntimeControlItem[] = []; const jobs: WorkspaceRuntimeControlItem[] = []; for (const command of commands) { if (command.kind === "job") { jobs.push(buildJobItem(command, input.canRunJobs ?? input.canStartServices)); continue; } const runtimeService = matchWorkspaceRuntimeServiceToCommand(command, runtimeServices); if (runtimeService) matchedRuntimeServiceIds.add(runtimeService.id); services.push(buildServiceItem(command, runtimeService, input.canStartServices)); } const otherServices = runtimeServices .filter((runtimeService) => !matchedRuntimeServiceIds.has(runtimeService.id) && (runtimeService.status === "provisioning" || runtimeService.status === "starting" || runtimeService.status === "running")) .map((runtimeService) => ({ key: `runtime:${runtimeService.id}`, title: runtimeService.serviceName, kind: "service" as const, statusLabel: runtimeService.status, lifecycle: runtimeService.lifecycle, healthStatus: runtimeService.healthStatus, exposure: runtimeService.exposure ?? null, command: runtimeService.command ?? null, cwd: runtimeService.cwd ?? null, port: runtimeService.port ?? null, url: runtimeService.url ?? null, canStart: false, canRun: false, workspaceCommandId: null, runtimeServiceId: runtimeService.id, serviceIndex: runtimeService.configIndex ?? null, disabledReason: "This runtime service no longer matches a configured workspace command.", })); return { services, jobs, otherServices, }; } export function buildWorkspaceRuntimeControlItems(input: { runtimeConfig: Record | null | undefined; runtimeServices: WorkspaceRuntimeService[] | null | undefined; canStartServices: boolean; canRunJobs?: boolean; }): LegacyWorkspaceRuntimeControlItem[] { return buildWorkspaceRuntimeControlSections(input).services.map((item) => ({ ...item, status: item.statusLabel, })); } export function getRunningRuntimeServiceUrl( sections: WorkspaceRuntimeControlSections, ) { const runningService = [...sections.services, ...sections.otherServices].find( (item) => (item.statusLabel === "running" || item.statusLabel === "starting") && item.url, ); return runningService?.url ?? null; } function isActiveStatusLabel(statusLabel: string) { return statusLabel === "running" || statusLabel === "starting" || statusLabel === "provisioning"; } function exposureFailureCopy(exposure: RuntimeExposureStatus | null) { if (exposure?.state === "failed") { return { label: "HTTPS unavailable", remediation: "Check the Tailscale broker and node HTTPS configuration.", }; } if (exposure?.state === "cleanup_pending") { return { label: "HTTPS cleanup pending", remediation: "Restart the host broker before reusing this port.", }; } return null; } function ExposureFailureDetail({ exposure }: { exposure: RuntimeExposureStatus | null }) { const copy = exposureFailureCopy(exposure); if (!copy) return null; return (
{copy.label} {exposure?.lastError ? ` · ${exposure.lastError}` : ""}
{copy.remediation}
); } /** * Maps runtime control sections onto the fixed-geometry service control bar * model. In-flight mutations overlay the transitional states (starting / * stopping / restarting) that the server status enum does not carry. */ export function buildWorkspaceServiceControlEntries(input: { sections: WorkspaceRuntimeControlSections; runtimeServices?: WorkspaceRuntimeService[] | null; isPending?: boolean; pendingRequest?: WorkspaceRuntimeControlRequest | null; pendingRequests?: WorkspaceRuntimeControlRequest[]; }): WorkspaceServiceControlEntry[] { const runtimeServicesById = new Map( (input.runtimeServices ?? []).map((runtimeService) => [runtimeService.id, runtimeService]), ); const pendingRequests = input.pendingRequests ?? (input.isPending && input.pendingRequest ? [input.pendingRequest] : []); return [...input.sections.services, ...input.sections.otherServices].map((item) => { let state: WorkspaceServiceControlEntry["state"] = item.statusLabel === "running" ? "running" : item.statusLabel === "provisioning" ? "provisioning" : item.statusLabel === "starting" ? "starting" : item.statusLabel === "failed" ? "failed" : "stopped"; const pendingRequest = pendingRequests.find((request) => request.action !== "run" && (request.workspaceCommandId ?? null) === (item.workspaceCommandId ?? null) && (request.runtimeServiceId ?? null) === (item.runtimeServiceId ?? null) && (request.serviceIndex ?? null) === (item.serviceIndex ?? null)); if (pendingRequest) { state = pendingRequest.action === "stop" ? "stopping" : pendingRequest.action === "restart" ? "restarting" : "starting"; } const runtimeService = item.runtimeServiceId ? runtimeServicesById.get(item.runtimeServiceId) ?? null : null; const failureDetail = state === "failed" ? `Service failed${runtimeService?.stoppedAt ? ` · ${timeAgo(runtimeService.stoppedAt)}` : ""}` : null; const exposure = runtimeService?.exposure ?? item.exposure; const exposureFailure = exposureFailureCopy(exposure); const exposureDetail = exposure?.state === "pending" ? "Provisioning HTTPS…" : exposure?.state === "ready" ? "HTTPS ready" : exposureFailure ? `${exposureFailure.label} · ${exposureFailure.remediation}` : null; return { key: item.key, name: item.title, state, healthStatus: item.healthStatus, url: item.url, port: item.port, failureDetail, exposureState: exposure?.state ?? null, exposureDetail, canStart: item.canStart, }; }); } /** * Resolves a control-bar action into the runtime control requests to fire. * A null serviceKey targets every applicable service (the aggregate bar and * popover bulk actions). */ export function resolveWorkspaceServiceControlRequests( sections: WorkspaceRuntimeControlSections, action: WorkspaceServiceControlAction, serviceKey: string | null, ): WorkspaceRuntimeControlRequest[] { const items = [...sections.services, ...sections.otherServices]; if (serviceKey !== null) { const item = items.find((candidate) => candidate.key === serviceKey); return item ? [buildRequest(item, action)] : []; } if (action === "stop") { return items .filter((item) => isActiveStatusLabel(item.statusLabel)) .map((item) => buildRequest(item, "stop")); } if (action === "start") { return items .filter((item) => !isActiveStatusLabel(item.statusLabel) && item.canStart) .map((item) => buildRequest(item, "start")); } return items.flatMap((item) => { if (isActiveStatusLabel(item.statusLabel)) return [buildRequest(item, "restart")]; if (item.canStart) return [buildRequest(item, "start")]; return []; }); } function requestMatchesPending( pendingRequest: WorkspaceRuntimeControlRequest | null | undefined, nextRequest: WorkspaceRuntimeControlRequest, ) { return pendingRequest?.action === nextRequest.action && (pendingRequest?.workspaceCommandId ?? null) === (nextRequest.workspaceCommandId ?? null) && (pendingRequest?.runtimeServiceId ?? null) === (nextRequest.runtimeServiceId ?? null) && (pendingRequest?.serviceIndex ?? null) === (nextRequest.serviceIndex ?? null); } function buildRequest(item: WorkspaceRuntimeControlItem, action: WorkspaceRuntimeAction): WorkspaceRuntimeControlRequest { return { action, workspaceCommandId: item.workspaceCommandId ?? null, runtimeServiceId: item.runtimeServiceId ?? null, serviceIndex: item.serviceIndex ?? null, }; } function CommandActionButtons({ item, isPending, pendingRequest, onAction, square, iconOnly, }: { item: WorkspaceRuntimeControlItem; isPending: boolean; pendingRequest: WorkspaceRuntimeControlRequest | null | undefined; onAction: (request: WorkspaceRuntimeControlRequest) => void; square?: boolean; iconOnly?: boolean; }) { const actions: WorkspaceRuntimeAction[] = item.kind === "job" ? ["run"] : item.statusLabel === "running" || item.statusLabel === "starting" ? ["stop", ...(item.canStart ? ["restart" as const] : [])] : ["start"]; return (
{actions.map((action) => { const request = buildRequest(item, action); const Icon = action === "stop" ? Square : action === "restart" ? RotateCcw : Play; const label = action === "run" ? "Run" : action === "start" ? "Start" : action === "stop" ? "Stop" : "Restart"; const showSpinner = isPending && requestMatchesPending(pendingRequest, request); const disabled = isPending || (action === "run" && !item.canRun) || ((action === "start" || action === "restart") && !item.canStart); return ( ); })}
); } function CommandSection({ title, description, items, emptyMessage, disabledHint, isPending, pendingRequest, onAction, square, iconOnly, }: { title: string; description: string; items: WorkspaceRuntimeControlItem[]; emptyMessage: string; disabledHint?: string | null; isPending: boolean; pendingRequest: WorkspaceRuntimeControlRequest | null | undefined; onAction: (request: WorkspaceRuntimeControlRequest) => void; square?: boolean; iconOnly?: boolean; }) { // Managed-sandbox-only policy: the working directory is a path on the // execution host, so the command rows drop it, and keep dropping it until the // policy is known. The URL, the port, and the command itself stay — they // describe the service, not the host filesystem. const { hideHostPaths } = useManagedSandboxOnly(); return (
{title}

{description}

{items.length === 0 ? (
{emptyMessage} {disabledHint ?

{disabledHint}

: null}
) : (
{items.map((item) => (
{item.title}
{item.kind} · {item.statusLabel} {item.lifecycle ? ` · ${item.lifecycle}` : ""}
{item.url ? ( {item.url} ) : null} {item.port ?
Port {item.port}
: null} {item.command ?
{item.command}
: null} {item.cwd && !hideHostPaths ?
{item.cwd}
: null} {item.disabledReason ?
{item.disabledReason}
: null}
{item.healthStatus && item.statusLabel !== "stopped" ? (
{item.healthStatus}
) : null}
))}
)}
); } export function WorkspaceRuntimeControls({ sections, items, isPending = false, pendingRequest = null, serviceEmptyMessage = "No services are configured for this workspace.", jobEmptyMessage = "No one-shot jobs are configured for this workspace.", emptyMessage, disabledHint = null, onAction, className, square, }: WorkspaceRuntimeControlsProps) { const resolvedSections = sections ?? { services: (items ?? []).map((item) => ({ ...item, statusLabel: item.statusLabel ?? item.status ?? "stopped", })), jobs: [], otherServices: [], }; const resolvedServiceEmptyMessage = emptyMessage ?? serviceEmptyMessage; const runningCount = [...resolvedSections.services, ...resolvedSections.otherServices].filter( (item) => item.statusLabel === "running" || item.statusLabel === "starting", ).length; const visibleDisabledHint = runningCount > 0 || disabledHint === null ? null : disabledHint; return (
Workspace commands
0 ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : "border-border bg-background text-muted-foreground", )} > {runningCount > 0 ? `${runningCount} services running` : "No services running"} {resolvedSections.jobs.length > 0 ? `${resolvedSections.jobs.length} job${resolvedSections.jobs.length === 1 ? "" : "s"} available to run on demand.` : "Each command can be controlled independently."}
{visibleDisabledHint ?

{visibleDisabledHint}

: null}
{resolvedSections.otherServices.length > 0 ? ( ) : null}
); } export function WorkspaceRuntimeQuickControls({ sections, isPending = false, pendingRequest = null, onAction, square, align = "end", iconOnly = false, }: { sections: WorkspaceRuntimeControlSections; isPending?: boolean; pendingRequest?: WorkspaceRuntimeControlRequest | null; onAction: (request: WorkspaceRuntimeControlRequest) => void; square?: boolean; align?: "start" | "end"; iconOnly?: boolean; }) { const controlItems = sections.services.length > 0 ? sections.services : sections.otherServices; const serviceUrl = getRunningRuntimeServiceUrl(sections); const alignEnd = align === "end"; if (controlItems.length === 0 && !serviceUrl) return null; return (
{controlItems.length > 0 ? (
{controlItems.map((item) => (
{controlItems.length > 1 ? ( {item.title} ) : null}
))}
) : null} {serviceUrl ? ( {serviceUrl} ) : null}
); }