From 58850f646bcb809c21ca35ad9a813bdf6566b8bd Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 13 Jan 2026 00:48:28 +1100 Subject: [PATCH 01/28] =?UTF-8?q?=F0=9F=A4=96=20feat:=20Coder=20workspace?= =?UTF-8?q?=20integration=20for=20SSH=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for Coder workspaces as a sub-option of SSH runtime: Backend: - Add CoderService for CLI interactions (version check, templates, presets, workspaces) - Add CoderSSHRuntime extending SSHRuntime with Coder-specific provisioning - Workspace creation derives Coder name from mux branch, transforms to valid format - Handle collision detection, parent dir creation, abort with SIGKILL escalation Frontend: - Add CoderControls component with New/Existing mode toggle - Template and preset dropdowns (presets shown only when 2+) - useCoderWorkspace hook manages async data fetching - Allow disabling Coder when CLI becomes unavailable Schema: - CoderWorkspaceConfigSchema with optional workspaceName (backend derives for new) - ORPC endpoints: coder.getInfo, listTemplates, listPresets, listWorkspaces - Filter unknown statuses to prevent validation errors Fixes applied: - Bounded collision retry loop - Derive KNOWN_STATUSES from schema - Clear SIGKILL timeout on normal exit - Pass coderProps when enabled OR available --- .../components/ChatInput/CoderControls.tsx | 299 ++++++++++++ .../components/ChatInput/CreationControls.tsx | 58 ++- src/browser/components/ChatInput/index.tsx | 142 ++++-- src/browser/hooks/useCoderWorkspace.ts | 274 +++++++++++ .../hooks/useDraftWorkspaceSettings.ts | 114 +++-- src/browser/stories/App.coder.stories.tsx | 384 +++++++++++++++ src/browser/stories/mocks/orpc.ts | 32 ++ src/cli/cli.test.ts | 1 + src/cli/run.ts | 26 +- src/cli/server.test.ts | 1 + src/cli/server.ts | 1 + src/common/orpc/schemas.ts | 7 + src/common/orpc/schemas/api.ts | 11 + src/common/orpc/schemas/coder.ts | 92 ++++ src/common/orpc/schemas/runtime.ts | 4 + src/common/types/runtime.ts | 7 +- src/desktop/main.ts | 1 + src/node/config.ts | 3 +- src/node/orpc/context.ts | 2 + src/node/orpc/router.ts | 26 + src/node/runtime/CoderSSHRuntime.ts | 308 ++++++++++++ src/node/runtime/DockerRuntime.ts | 145 +++--- src/node/runtime/Runtime.ts | 21 + src/node/runtime/runtimeFactory.ts | 82 +++- src/node/services/coderService.test.ts | 455 ++++++++++++++++++ src/node/services/coderService.ts | 361 ++++++++++++++ src/node/services/serviceContainer.ts | 6 + src/node/services/taskService.ts | 32 +- src/node/services/workspaceService.ts | 105 +++- tests/ipc/setup.ts | 1 + tests/runtime/runtime.test.ts | 206 +++++++- 31 files changed, 2982 insertions(+), 225 deletions(-) create mode 100644 src/browser/components/ChatInput/CoderControls.tsx create mode 100644 src/browser/hooks/useCoderWorkspace.ts create mode 100644 src/browser/stories/App.coder.stories.tsx create mode 100644 src/common/orpc/schemas/coder.ts create mode 100644 src/node/runtime/CoderSSHRuntime.ts create mode 100644 src/node/services/coderService.test.ts create mode 100644 src/node/services/coderService.ts diff --git a/src/browser/components/ChatInput/CoderControls.tsx b/src/browser/components/ChatInput/CoderControls.tsx new file mode 100644 index 0000000000..1f62645801 --- /dev/null +++ b/src/browser/components/ChatInput/CoderControls.tsx @@ -0,0 +1,299 @@ +/** + * Coder workspace controls for SSH runtime. + * Enables creating or connecting to Coder cloud workspaces. + */ +import React from "react"; +import type { + CoderInfo, + CoderTemplate, + CoderPreset, + CoderWorkspace, +} from "@/common/orpc/schemas/coder"; +import type { CoderWorkspaceConfig } from "@/common/types/runtime"; +import { cn } from "@/common/lib/utils"; +import { Loader2 } from "lucide-react"; +import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; + +export interface CoderControlsProps { + /** Whether to use Coder workspace (checkbox state) */ + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + + /** Whether Coder CLI is available */ + coderInfo: CoderInfo | null; + + /** Current Coder configuration */ + coderConfig: CoderWorkspaceConfig | null; + onCoderConfigChange: (config: CoderWorkspaceConfig | null) => void; + + /** Data for dropdowns (loaded async) */ + templates: CoderTemplate[]; + presets: CoderPreset[]; + existingWorkspaces: CoderWorkspace[]; + + /** Loading states */ + loadingTemplates: boolean; + loadingPresets: boolean; + loadingWorkspaces: boolean; + + /** Disabled state (e.g., during creation) */ + disabled: boolean; + + /** Error state for visual feedback */ + hasError?: boolean; +} + +type CoderMode = "new" | "existing"; + +/** + * Coder workspace controls component. + * Shows checkbox to enable Coder, then New/Existing toggle with appropriate dropdowns. + */ +export function CoderControls(props: CoderControlsProps) { + const { + enabled, + onEnabledChange, + coderInfo, + coderConfig, + onCoderConfigChange, + templates, + presets, + existingWorkspaces, + loadingTemplates, + loadingPresets, + loadingWorkspaces, + disabled, + hasError, + } = props; + + // Coder CLI not available + if (!coderInfo?.available) { + // If user previously enabled Coder but CLI is now unavailable, show checkbox so they can disable it + if (enabled) { + return ( +
+ +
+ ); + } + // Otherwise don't render anything + return null; + } + + const mode: CoderMode = coderConfig?.existingWorkspace ? "existing" : "new"; + + const handleModeChange = (newMode: CoderMode) => { + if (newMode === "existing") { + // Switch to existing workspace mode (workspaceName starts empty, user selects) + onCoderConfigChange({ + workspaceName: undefined, + existingWorkspace: true, + }); + } else { + // Switch to new workspace mode (workspaceName omitted; backend derives from branch) + onCoderConfigChange({ + template: templates[0]?.name, + }); + } + }; + + const handleTemplateChange = (templateName: string) => { + if (!coderConfig) return; + + onCoderConfigChange({ + ...coderConfig, + template: templateName, + preset: undefined, // Reset preset when template changes + }); + // Presets will be loaded by parent via effect + }; + + const handlePresetChange = (presetName: string) => { + if (!coderConfig) return; + + onCoderConfigChange({ + ...coderConfig, + preset: presetName || undefined, + }); + }; + + const handleExistingWorkspaceChange = (workspaceName: string) => { + onCoderConfigChange({ + workspaceName, + existingWorkspace: true, + }); + }; + + // Auto-select default preset if 0 or 1 presets (per spec) + const effectivePreset = + presets.length === 0 ? undefined : presets.length === 1 ? presets[0].name : coderConfig?.preset; + + // Show preset dropdown only when 2+ presets + const showPresetDropdown = presets.length >= 2; + + return ( +
+ {/* Coder checkbox */} + + + {/* Coder controls - only shown when enabled */} + {enabled && ( +
+ {/* Left column: New/Existing toggle buttons */} +
+ + + + + Create a new Coder workspace from a template + + + + + + Connect to an existing Coder workspace + +
+ + {/* Right column: Mode-specific controls */} + {/* New workspace controls - template/preset stacked vertically */} + {mode === "new" && ( +
+
+ + {loadingTemplates ? ( + + ) : ( + + )} +
+ {showPresetDropdown && ( +
+ + {loadingPresets ? ( + + ) : ( + + )} +
+ )} +
+ )} + + {/* Existing workspace controls */} + {mode === "existing" && ( +
+ + {loadingWorkspaces ? ( + + ) : ( + + )} +
+ )} +
+ )} +
+ ); +} diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 2716f80888..8af07b0136 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -10,6 +10,7 @@ import { DocsLink } from "../DocsLink"; import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName"; import type { SectionConfig } from "@/common/types/project"; import { resolveSectionColor } from "@/common/constants/ui"; +import { CoderControls, type CoderControlsProps } from "./CoderControls"; interface CreationControlsProps { branches: string[]; @@ -38,6 +39,8 @@ interface CreationControlsProps { onSectionChange?: (sectionId: string | null) => void; /** Which runtime field (if any) is in error state for visual feedback */ runtimeFieldError?: "docker" | "ssh" | null; + /** Coder workspace controls props (optional - only rendered when provided) */ + coderProps?: Omit; } /** Runtime type button group with icons and colors */ @@ -416,8 +419,8 @@ export function CreationControls(props: CreationControlsProps) { )} - {/* SSH Host Input */} - {selectedRuntime.mode === "ssh" && ( + {/* SSH Host Input - hidden when Coder is enabled */} + {selectedRuntime.mode === "ssh" && !props.coderProps?.enabled && (
)} - - {/* Docker Credential Sharing */} - {selectedRuntime.mode === "docker" && ( - - )} + + {/* Docker Credential Sharing - separate row for consistency with Coder controls */} + {selectedRuntime.mode === "docker" && ( + + )} + + {/* Coder Controls - shown when SSH mode is selected and Coder is available */} + {selectedRuntime.mode === "ssh" && props.coderProps && ( + + )} ); diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 5a3b92d50d..89f33cbf1d 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -84,6 +84,7 @@ import { } from "@/browser/utils/imageHandling"; import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; +import type { ParsedRuntime } from "@/common/types/runtime"; import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; import type { MuxFrontendMetadata } from "@/common/types/message"; import { prepareUserMessageForSend } from "@/common/types/message"; @@ -94,6 +95,7 @@ import { CreationCenterContent } from "./CreationCenterContent"; import { cn } from "@/common/lib/utils"; import { CreationControls } from "./CreationControls"; import { useCreationWorkspace } from "./useCreationWorkspace"; +import { useCoderWorkspace } from "@/browser/hooks/useCoderWorkspace"; import { useTutorial } from "@/browser/contexts/TutorialContext"; import { useVoiceInput } from "@/browser/hooks/useVoiceInput"; import { VoiceInputButton } from "./VoiceInputButton"; @@ -113,6 +115,47 @@ const MAX_PERSISTED_IMAGE_DRAFT_CHARS = 4_000_000; import type { ChatInputProps, ChatInputAPI } from "./types"; import type { ImagePart } from "@/common/orpc/types"; +type CreationRuntimeValidationError = + | { mode: "docker"; kind: "missingImage" } + | { mode: "ssh"; kind: "missingHost" } + | { mode: "ssh"; kind: "missingCoderWorkspace" } + | { mode: "ssh"; kind: "missingCoderTemplate" } + | { mode: "ssh"; kind: "missingCoderPreset" }; + +function validateCreationRuntime( + runtime: ParsedRuntime, + coderPresetCount: number +): CreationRuntimeValidationError | null { + if (runtime.mode === "docker") { + return runtime.image.trim() ? null : { mode: "docker", kind: "missingImage" }; + } + + if (runtime.mode === "ssh") { + if (runtime.coder) { + if (runtime.coder.existingWorkspace) { + // Existing mode: workspace name is required + if (!(runtime.coder.workspaceName ?? "").trim()) { + return { mode: "ssh", kind: "missingCoderWorkspace" }; + } + } else { + // New mode: template is required + if (!(runtime.coder.template ?? "").trim()) { + return { mode: "ssh", kind: "missingCoderTemplate" }; + } + // Preset required when 2+ presets exist + const requiresPreset = coderPresetCount >= 2; + if (requiresPreset && !(runtime.coder.preset ?? "").trim()) { + return { mode: "ssh", kind: "missingCoderPreset" }; + } + } + return null; + } + + return runtime.host.trim() ? null : { mode: "ssh", kind: "missingHost" }; + } + + return null; +} function imagePartsToAttachments(imageParts: ImagePart[], idPrefix: string): ImageAttachment[] { return imageParts.map((img, index) => ({ id: `${idPrefix}-${index}`, @@ -470,7 +513,7 @@ const ChatInputInner: React.FC = (props) => { const creationSections = creationProject?.sections ?? []; const [selectedSectionId, setSelectedSectionId] = useState(() => pendingSectionId); - const [runtimeFieldError, setRuntimeFieldError] = useState<"docker" | "ssh" | null>(null); + const [hasAttemptedCreateSend, setHasAttemptedCreateSend] = useState(false); // Keep local selection in sync with the URL-driven pending section (sidebar "+" button). useEffect(() => { @@ -522,6 +565,33 @@ const ChatInputInner: React.FC = (props) => { const isSendInFlight = variant === "creation" ? creationState.isSending : isSending; + // Coder workspace state - config is owned by selectedRuntime.coder, this hook manages async data + const currentRuntime = creationState.selectedRuntime; + const coderState = useCoderWorkspace({ + coderConfig: currentRuntime.mode === "ssh" ? (currentRuntime.coder ?? null) : null, + onCoderConfigChange: (config) => { + if (currentRuntime.mode !== "ssh") return; + // Compute host from workspace name for "existing" mode. + // For "new" mode, workspaceName is omitted/undefined and backend derives it later. + const computedHost = config?.workspaceName + ? `${config.workspaceName}.coder` + : currentRuntime.host; + creationState.setSelectedRuntime({ + mode: "ssh", + host: computedHost, + coder: config ?? undefined, + }); + }, + }); + + const creationRuntimeError = + variant === "creation" + ? validateCreationRuntime(creationState.selectedRuntime, coderState.presets.length) + : null; + + const runtimeFieldError = + variant === "creation" && hasAttemptedCreateSend ? (creationRuntimeError?.mode ?? null) : null; + const creationControlsProps = variant === "creation" ? ({ @@ -541,39 +611,39 @@ const ChatInputInner: React.FC = (props) => { selectedSectionId, onSectionChange: setSelectedSectionId, runtimeFieldError, + // Pass coderProps when CLI is available OR when Coder is enabled (so user can disable) + coderProps: + coderState.coderInfo?.available || coderState.enabled + ? { + enabled: coderState.enabled, + onEnabledChange: coderState.setEnabled, + coderInfo: coderState.coderInfo, + coderConfig: coderState.coderConfig, + onCoderConfigChange: coderState.setCoderConfig, + templates: coderState.templates, + presets: coderState.presets, + existingWorkspaces: coderState.existingWorkspaces, + loadingTemplates: coderState.loadingTemplates, + loadingPresets: coderState.loadingPresets, + loadingWorkspaces: coderState.loadingWorkspaces, + } + : undefined, } satisfies React.ComponentProps) : null; const hasTypedText = input.trim().length > 0; const hasImages = imageAttachments.length > 0; const hasReviews = attachedReviews.length > 0; - const isDockerMissingImage = - variant === "creation" && - creationState.selectedRuntime.mode === "docker" && - !creationState.selectedRuntime.image.trim(); - const isSshMissingHost = - variant === "creation" && - creationState.selectedRuntime.mode === "ssh" && - !creationState.selectedRuntime.host.trim(); - const canSend = (hasTypedText || hasImages || hasReviews) && !disabled && !isSendInFlight; + // Disable send while Coder presets are loading (user could bypass preset validation) + const coderPresetsLoading = + coderState.enabled && !coderState.coderConfig?.existingWorkspace && coderState.loadingPresets; + const canSend = + (hasTypedText || hasImages || hasReviews) && + !disabled && + !isSendInFlight && + !coderPresetsLoading; const creationProjectPath = variant === "creation" ? props.projectPath : ""; - // Clear runtime field error when runtime mode changes or field becomes valid - useEffect(() => { - if (variant !== "creation" || !runtimeFieldError) { - return; - } - const rt = creationState.selectedRuntime; - // Clear if mode changed or if the relevant field is now filled - if (rt.mode !== runtimeFieldError) { - setRuntimeFieldError(null); - } else if (rt.mode === "docker" && rt.image.trim()) { - setRuntimeFieldError(null); - } else if (rt.mode === "ssh" && rt.host.trim()) { - setRuntimeFieldError(null); - } - }, [variant, runtimeFieldError, creationState.selectedRuntime]); - // Creation variant: keep the project-scoped model/thinking in sync with global per-mode defaults // so switching Plan/Exec uses the configured defaults (and respects "inherit" semantics). useEffect(() => { @@ -1258,16 +1328,6 @@ const ChatInputInner: React.FC = (props) => { // Route to creation handler for creation variant if (variant === "creation") { - // Validate runtime fields before creating workspace - if (isDockerMissingImage) { - setRuntimeFieldError("docker"); - return; - } - if (isSshMissingHost) { - setRuntimeFieldError("ssh"); - return; - } - // Handle /init command in creation variant - populate input with init message if (messageText.startsWith("/")) { const parsed = parseCommand(messageText); @@ -1278,6 +1338,16 @@ const ChatInputInner: React.FC = (props) => { } } + setHasAttemptedCreateSend(true); + + const runtimeError = validateCreationRuntime( + creationState.selectedRuntime, + coderState.presets.length + ); + if (runtimeError) { + return; + } + // Creation variant: simple message send + workspace creation const creationImageParts = imageAttachmentsToImageParts(imageAttachments); const ok = await creationState.handleSend( diff --git a/src/browser/hooks/useCoderWorkspace.ts b/src/browser/hooks/useCoderWorkspace.ts new file mode 100644 index 0000000000..776b875084 --- /dev/null +++ b/src/browser/hooks/useCoderWorkspace.ts @@ -0,0 +1,274 @@ +/** + * Hook for managing Coder workspace async data in the creation flow. + * Fetches Coder CLI info, templates, presets, and existing workspaces. + * + * The `coderConfig` state is owned by the parent (via selectedRuntime.coder) and passed in. + * This hook only manages async-fetched data and derived state. + */ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAPI } from "@/browser/contexts/API"; +import type { + CoderInfo, + CoderTemplate, + CoderPreset, + CoderWorkspace, +} from "@/common/orpc/schemas/coder"; +import type { CoderWorkspaceConfig } from "@/common/types/runtime"; + +interface UseCoderWorkspaceOptions { + /** Current Coder config (null = disabled, owned by parent via selectedRuntime.coder) */ + coderConfig: CoderWorkspaceConfig | null; + /** Callback to update Coder config (updates selectedRuntime.coder) */ + onCoderConfigChange: (config: CoderWorkspaceConfig | null) => void; +} + +interface UseCoderWorkspaceReturn { + /** Whether Coder is enabled (derived: coderConfig != null) */ + enabled: boolean; + /** Toggle Coder on/off (calls onCoderConfigChange with config or null) */ + setEnabled: (enabled: boolean) => void; + + /** Coder CLI availability info */ + coderInfo: CoderInfo | null; + + /** Current Coder configuration (passed through from props) */ + coderConfig: CoderWorkspaceConfig | null; + /** Update Coder config (passed through from props) */ + setCoderConfig: (config: CoderWorkspaceConfig | null) => void; + + /** Available templates */ + templates: CoderTemplate[]; + /** Presets for the currently selected template */ + presets: CoderPreset[]; + /** Running Coder workspaces */ + existingWorkspaces: CoderWorkspace[]; + + /** Loading states */ + loadingTemplates: boolean; + loadingPresets: boolean; + loadingWorkspaces: boolean; +} + +/** + * Manages Coder workspace async data for the creation flow. + * + * Fetches data lazily: + * - Coder info is fetched on mount + * - Templates are fetched when Coder is enabled + * - Presets are fetched when a template is selected + * - Workspaces are fetched when Coder is enabled + * + * State ownership: coderConfig is owned by parent (selectedRuntime.coder). + * This hook derives `enabled` from coderConfig and manages only async data. + */ +export function useCoderWorkspace({ + coderConfig, + onCoderConfigChange, +}: UseCoderWorkspaceOptions): UseCoderWorkspaceReturn { + const { api } = useAPI(); + + // Derived state: enabled when coderConfig is present + const enabled = coderConfig != null; + + // Ref to access current coderConfig in async callbacks (avoids stale closures) + const coderConfigRef = useRef(coderConfig); + useEffect(() => { + coderConfigRef.current = coderConfig; + }, [coderConfig]); + + // Async-fetched data (owned by this hook) + const [coderInfo, setCoderInfo] = useState(null); + const [templates, setTemplates] = useState([]); + const [presets, setPresets] = useState([]); + const [existingWorkspaces, setExistingWorkspaces] = useState([]); + + // Loading states + const [loadingTemplates, setLoadingTemplates] = useState(false); + const [loadingPresets, setLoadingPresets] = useState(false); + const [loadingWorkspaces, setLoadingWorkspaces] = useState(false); + + // Fetch Coder info on mount + useEffect(() => { + if (!api) return; + + let mounted = true; + + api.coder + .getInfo() + .then((info) => { + if (mounted) { + setCoderInfo(info); + } + }) + .catch(() => { + if (mounted) { + setCoderInfo({ available: false }); + } + }); + + return () => { + mounted = false; + }; + }, [api]); + + // Fetch templates when Coder is enabled + useEffect(() => { + if (!api || !enabled || !coderInfo?.available) { + setTemplates([]); + setLoadingTemplates(false); + return; + } + + let mounted = true; + setLoadingTemplates(true); + + api.coder + .listTemplates() + .then((result) => { + if (mounted) { + setTemplates(result); + // Auto-select first template if none selected + // Use ref to get current config (avoids stale closure if user toggled modes during fetch) + const currentConfig = coderConfigRef.current; + if (result.length > 0 && !currentConfig?.template && !currentConfig?.existingWorkspace) { + onCoderConfigChange({ + template: result[0].name, + }); + } + } + }) + .catch(() => { + if (mounted) { + setTemplates([]); + } + }) + .finally(() => { + if (mounted) { + setLoadingTemplates(false); + } + }); + + return () => { + mounted = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally only re-fetch on enable/available changes, not on coderConfig changes + }, [api, enabled, coderInfo?.available]); + + // Fetch existing workspaces when Coder is enabled + useEffect(() => { + if (!api || !enabled || !coderInfo?.available) { + setExistingWorkspaces([]); + setLoadingWorkspaces(false); + return; + } + + let mounted = true; + setLoadingWorkspaces(true); + + api.coder + .listWorkspaces() + .then((result) => { + if (mounted) { + // Only show running workspaces (per spec) + setExistingWorkspaces(result.filter((w) => w.status === "running")); + } + }) + .catch(() => { + if (mounted) { + setExistingWorkspaces([]); + } + }) + .finally(() => { + if (mounted) { + setLoadingWorkspaces(false); + } + }); + + return () => { + mounted = false; + }; + }, [api, enabled, coderInfo?.available]); + + // Fetch presets when template changes (only for "new" mode) + useEffect(() => { + if (!api || !enabled || !coderConfig?.template || coderConfig.existingWorkspace) { + setPresets([]); + setLoadingPresets(false); + return; + } + + let mounted = true; + setLoadingPresets(true); + + api.coder + .listPresets({ template: coderConfig.template }) + .then((result) => { + if (!mounted) { + return; + } + + setPresets(result); + + // Presets rules (per spec): + // - 0 presets: no dropdown + // - 1 preset: auto-select silently + // - 2+ presets: dropdown shown and selection is required (validated in ChatInput) + // Use ref to get current config (avoids stale closure if user changed config during fetch) + const currentConfig = coderConfigRef.current; + if (currentConfig && !currentConfig.existingWorkspace) { + if (result.length === 1) { + const onlyPreset = result[0]; + if (onlyPreset && currentConfig.preset !== onlyPreset.name) { + onCoderConfigChange({ ...currentConfig, preset: onlyPreset.name }); + } + } else if (result.length === 0 && currentConfig.preset) { + onCoderConfigChange({ ...currentConfig, preset: undefined }); + } + } + }) + .catch(() => { + if (mounted) { + setPresets([]); + } + }) + .finally(() => { + if (mounted) { + setLoadingPresets(false); + } + }); + + return () => { + mounted = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-fetch on template/existingWorkspace changes, not on preset changes (would cause loop) + }, [api, enabled, coderConfig?.template, coderConfig?.existingWorkspace]); + + // Handle enabled toggle + const handleSetEnabled = useCallback( + (newEnabled: boolean) => { + if (newEnabled) { + // Initialize config for new workspace mode (workspaceName omitted; backend derives) + onCoderConfigChange({ + template: templates[0]?.name, + }); + } else { + onCoderConfigChange(null); + } + }, + [templates, onCoderConfigChange] + ); + + return { + enabled, + setEnabled: handleSetEnabled, + coderInfo, + coderConfig, + setCoderConfig: onCoderConfigChange, + templates, + presets, + existingWorkspaces, + loadingTemplates, + loadingPresets, + loadingWorkspaces, + }; +} diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index 8ae3a10eeb..458657985f 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -6,6 +6,7 @@ import { getDefaultModel } from "./useModelsFromSettings"; import { type RuntimeMode, type ParsedRuntime, + type CoderWorkspaceConfig, parseRuntimeModeAndHost, buildRuntimeString, RUNTIME_MODE, @@ -101,32 +102,39 @@ export function useDraftWorkspaceSettings( { listener: true } ); - const readLastRuntimeConfigString = (mode: RuntimeMode, field: string): string => { - const value = lastRuntimeConfigs[mode]; - if (!value || typeof value !== "object" || Array.isArray(value)) { - return ""; + // Generic reader for lastRuntimeConfigs fields + const readRuntimeConfig = (mode: RuntimeMode, field: string, defaultValue: T): T => { + const modeConfig = lastRuntimeConfigs[mode]; + if (!modeConfig || typeof modeConfig !== "object" || Array.isArray(modeConfig)) { + return defaultValue; } - const fieldValue = (value as Record)[field]; - return typeof fieldValue === "string" ? fieldValue : ""; - }; - - const readLastRuntimeConfigBoolean = (mode: RuntimeMode, field: string): boolean => { - const value = lastRuntimeConfigs[mode]; - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false; + const fieldValue = (modeConfig as Record)[field]; + // Type-specific validation based on default value type + if (typeof defaultValue === "string") { + return (typeof fieldValue === "string" ? fieldValue : defaultValue) as T; + } + if (typeof defaultValue === "boolean") { + return (fieldValue === true) as unknown as T; + } + // Object type (null default means optional object) + if (fieldValue && typeof fieldValue === "object" && !Array.isArray(fieldValue)) { + return fieldValue as T; } - return (value as Record)[field] === true; + return defaultValue; }; - const lastSshHost = readLastRuntimeConfigString(RUNTIME_MODE.SSH, "host"); - const lastDockerImage = readLastRuntimeConfigString(RUNTIME_MODE.DOCKER, "image"); - const lastShareCredentials = readLastRuntimeConfigBoolean( - RUNTIME_MODE.DOCKER, - "shareCredentials" + const lastSshHost = readRuntimeConfig(RUNTIME_MODE.SSH, "host", ""); + const lastCoderEnabled = readRuntimeConfig(RUNTIME_MODE.SSH, "coderEnabled", false); + const lastCoderConfig = readRuntimeConfig( + RUNTIME_MODE.SSH, + "coderConfig", + null ); + const lastDockerImage = readRuntimeConfig(RUNTIME_MODE.DOCKER, "image", ""); + const lastShareCredentials = readRuntimeConfig(RUNTIME_MODE.DOCKER, "shareCredentials", false); const setLastRuntimeConfig = useCallback( - (mode: RuntimeMode, field: string, value: string | boolean) => { + (mode: RuntimeMode, field: string, value: string | boolean | object | null) => { setLastRuntimeConfigs((prev) => { const existing = prev[mode]; const existingObj = @@ -164,19 +172,25 @@ export function useDraftWorkspaceSettings( const defaultDockerImage = parsedDefault?.mode === RUNTIME_MODE.DOCKER ? parsedDefault.image : lastDockerImage; - // Build ParsedRuntime from mode + stored host/image/shareCredentials + // Build ParsedRuntime from mode + stored host/image/shareCredentials/coder // Defined as a function so it can be used in both useState init and useEffect const buildRuntimeForMode = ( mode: RuntimeMode, sshHost: string, dockerImage: string, - shareCredentials: boolean + shareCredentials: boolean, + coderEnabled: boolean, + coderConfig: CoderWorkspaceConfig | null ): ParsedRuntime => { switch (mode) { case RUNTIME_MODE.LOCAL: return { mode: "local" }; case RUNTIME_MODE.SSH: - return { mode: "ssh", host: sshHost }; + return { + mode: "ssh", + host: sshHost, + coder: coderEnabled && coderConfig ? coderConfig : undefined, + }; case RUNTIME_MODE.DOCKER: return { mode: "docker", image: dockerImage, shareCredentials }; case RUNTIME_MODE.WORKTREE: @@ -192,7 +206,9 @@ export function useDraftWorkspaceSettings( defaultRuntimeMode, defaultSshHost, defaultDockerImage, - lastShareCredentials + lastShareCredentials, + lastCoderEnabled, + lastCoderConfig ) ); @@ -211,24 +227,41 @@ export function useDraftWorkspaceSettings( defaultRuntimeMode, defaultSshHost, defaultDockerImage, - lastShareCredentials + lastShareCredentials, + lastCoderEnabled, + lastCoderConfig ) ); } prevProjectPathRef.current = projectPath; prevDefaultRuntimeModeRef.current = defaultRuntimeMode; - }, [projectPath, defaultRuntimeMode, defaultSshHost, defaultDockerImage, lastShareCredentials]); + }, [ + projectPath, + defaultRuntimeMode, + defaultSshHost, + defaultDockerImage, + lastShareCredentials, + lastCoderEnabled, + lastCoderConfig, + ]); - // When the user switches into SSH/Docker mode, seed the field with the remembered host/image. - // This avoids clearing the last host/image when the UI switches modes with an empty field. + // When the user switches into SSH/Docker mode, seed the field with the remembered host/image/coder. + // This avoids clearing the last values when the UI switches modes with an empty field. const prevSelectedRuntimeModeRef = useRef(null); useEffect(() => { const prevMode = prevSelectedRuntimeModeRef.current; if (prevMode !== selectedRuntime.mode) { if (selectedRuntime.mode === RUNTIME_MODE.SSH) { - if (!selectedRuntime.host.trim() && lastSshHost.trim()) { - setSelectedRuntimeState({ mode: RUNTIME_MODE.SSH, host: lastSshHost }); + const needsHostRestore = !selectedRuntime.host.trim() && lastSshHost.trim(); + const needsCoderRestore = + selectedRuntime.coder === undefined && lastCoderEnabled && lastCoderConfig; + if (needsHostRestore || needsCoderRestore) { + setSelectedRuntimeState({ + mode: RUNTIME_MODE.SSH, + host: needsHostRestore ? lastSshHost : selectedRuntime.host, + coder: needsCoderRestore ? lastCoderConfig : selectedRuntime.coder, + }); } } @@ -247,7 +280,14 @@ export function useDraftWorkspaceSettings( } prevSelectedRuntimeModeRef.current = selectedRuntime.mode; - }, [selectedRuntime, lastSshHost, lastDockerImage, lastShareCredentials]); + }, [ + selectedRuntime, + lastSshHost, + lastDockerImage, + lastShareCredentials, + lastCoderEnabled, + lastCoderConfig, + ]); // Initialize trunk branch from backend recommendation or first branch useEffect(() => { @@ -257,16 +297,22 @@ export function useDraftWorkspaceSettings( } }, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]); - // Setter for selected runtime (also persists host/image for future mode switches) + // Setter for selected runtime (also persists host/image/coder for future mode switches) const setSelectedRuntime = (runtime: ParsedRuntime) => { setSelectedRuntimeState(runtime); - // Persist host/image so they're remembered when switching modes. + // Persist host/image/coder so they're remembered when switching modes. // Avoid wiping the remembered value when the UI switches modes with an empty field. if (runtime.mode === RUNTIME_MODE.SSH) { if (runtime.host.trim()) { setLastRuntimeConfig(RUNTIME_MODE.SSH, "host", runtime.host); } + // Persist Coder enabled state and config + const coderEnabled = runtime.coder !== undefined; + setLastRuntimeConfig(RUNTIME_MODE.SSH, "coderEnabled", coderEnabled); + if (runtime.coder) { + setLastRuntimeConfig(RUNTIME_MODE.SSH, "coderConfig", runtime.coder); + } } else if (runtime.mode === RUNTIME_MODE.DOCKER) { if (runtime.image.trim()) { setLastRuntimeConfig(RUNTIME_MODE.DOCKER, "image", runtime.image); @@ -283,7 +329,9 @@ export function useDraftWorkspaceSettings( newMode, lastSshHost, lastDockerImage, - lastShareCredentials + lastShareCredentials, + lastCoderEnabled, + lastCoderConfig ); const newRuntimeString = buildRuntimeString(newRuntime); setDefaultRuntimeString(newRuntimeString); diff --git a/src/browser/stories/App.coder.stories.tsx b/src/browser/stories/App.coder.stories.tsx new file mode 100644 index 0000000000..5e39f21207 --- /dev/null +++ b/src/browser/stories/App.coder.stories.tsx @@ -0,0 +1,384 @@ +/** + * Coder workspace integration stories. + * Tests the UI for creating and connecting to Coder cloud workspaces. + */ + +import { within, userEvent, waitFor } from "@storybook/test"; + +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; +import { expandProjects } from "./storyHelpers"; +import type { ProjectConfig } from "@/node/config"; +import type { CoderTemplate, CoderPreset, CoderWorkspace } from "@/common/orpc/schemas/coder"; + +export default { + ...appMeta, + title: "App/Coder", +}; + +/** Helper to create a project config for a path with no workspaces */ +function projectWithNoWorkspaces(path: string): [string, ProjectConfig] { + return [path, { workspaces: [] }]; +} + +/** Mock Coder templates */ +const mockTemplates: CoderTemplate[] = [ + { + name: "coder-on-coder", + displayName: "Coder on Coder", + organizationName: "default", + }, + { + name: "kubernetes-dev", + displayName: "Kubernetes Development", + organizationName: "default", + }, + { + name: "aws-windows", + displayName: "AWS Windows Instance", + organizationName: "default", + }, +]; + +/** Mock presets for coder-on-coder template */ +const mockPresetsCoderOnCoder: CoderPreset[] = [ + { + id: "preset-sydney", + name: "Sydney", + description: "Australia region", + isDefault: false, + }, + { + id: "preset-helsinki", + name: "Helsinki", + description: "Europe region", + isDefault: false, + }, + { + id: "preset-pittsburgh", + name: "Pittsburgh", + description: "US East region", + isDefault: true, + }, +]; + +/** Mock presets for kubernetes template (only one) */ +const mockPresetsK8s: CoderPreset[] = [ + { + id: "preset-k8s-1", + name: "Standard", + description: "Default configuration", + isDefault: true, + }, +]; + +/** Mock existing Coder workspaces */ +const mockWorkspaces: CoderWorkspace[] = [ + { name: "mux-dev", templateName: "coder-on-coder", status: "running" }, + { name: "api-testing", templateName: "kubernetes-dev", status: "running" }, + { name: "frontend-v2", templateName: "coder-on-coder", status: "running" }, +]; + +/** + * SSH runtime with Coder available - shows Coder checkbox. + * When user selects SSH runtime, they can enable Coder workspace mode. + */ +export const SSHWithCoderAvailable: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: [], + coderInfo: { available: true, version: "2.28.0" }, + coderTemplates: mockTemplates, + coderPresets: new Map([ + ["coder-on-coder", mockPresetsCoderOnCoder], + ["kubernetes-dev", mockPresetsK8s], + ["aws-windows", []], + ]), + coderWorkspaces: mockWorkspaces, + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + const canvas = within(storyRoot); + + // Wait for the runtime button group to appear + await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 }); + + // Click SSH runtime button + const sshButton = canvas.getByRole("button", { name: /SSH/i }); + await userEvent.click(sshButton); + + // Wait for SSH mode to be active and Coder checkbox to appear + await waitFor( + () => { + const coderCheckbox = canvas.queryByTestId("coder-checkbox"); + if (!coderCheckbox) throw new Error("Coder checkbox not found"); + }, + { timeout: 5000 } + ); + }, +}; + +/** + * Coder new workspace flow - shows template and preset dropdowns. + * User enables Coder, selects template, and optionally a preset. + */ +export const CoderNewWorkspace: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: [], + coderInfo: { available: true, version: "2.28.0" }, + coderTemplates: mockTemplates, + coderPresets: new Map([ + ["coder-on-coder", mockPresetsCoderOnCoder], + ["kubernetes-dev", mockPresetsK8s], + ["aws-windows", []], + ]), + coderWorkspaces: mockWorkspaces, + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + const canvas = within(storyRoot); + + // Wait for runtime controls + await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 }); + + // Click SSH runtime button + const sshButton = canvas.getByRole("button", { name: /SSH/i }); + await userEvent.click(sshButton); + + // Enable Coder + const coderCheckbox = await canvas.findByTestId("coder-checkbox", {}, { timeout: 5000 }); + await userEvent.click(coderCheckbox); + + // Wait for Coder controls to appear + await canvas.findByTestId("coder-controls-inner", {}, { timeout: 5000 }); + + // The template dropdown should be visible with templates loaded + await canvas.findByTestId("coder-template-select", {}, { timeout: 5000 }); + }, +}; + +/** + * Coder existing workspace flow - shows workspace dropdown. + * User switches to "Existing" mode and selects from running workspaces. + */ +export const CoderExistingWorkspace: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: [], + coderInfo: { available: true, version: "2.28.0" }, + coderTemplates: mockTemplates, + coderPresets: new Map([ + ["coder-on-coder", mockPresetsCoderOnCoder], + ["kubernetes-dev", mockPresetsK8s], + ["aws-windows", []], + ]), + coderWorkspaces: mockWorkspaces, + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + const canvas = within(storyRoot); + + // Wait for runtime controls + await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 }); + + // Click SSH runtime button + const sshButton = canvas.getByRole("button", { name: /SSH/i }); + await userEvent.click(sshButton); + + // Enable Coder + const coderCheckbox = await canvas.findByTestId("coder-checkbox", {}, { timeout: 5000 }); + await userEvent.click(coderCheckbox); + + // Wait for Coder controls + await canvas.findByTestId("coder-controls-inner", {}, { timeout: 5000 }); + + // Click "Existing" button + const existingButton = canvas.getByRole("button", { name: "Existing" }); + await userEvent.click(existingButton); + + // Wait for workspace dropdown to appear + await canvas.findByTestId("coder-workspace-select", {}, { timeout: 5000 }); + }, +}; + +/** + * Coder not available - checkbox should not appear. + * When Coder CLI is not installed, the SSH runtime shows normal host input. + */ +export const CoderNotAvailable: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: [], + coderInfo: { available: false }, + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + const canvas = within(storyRoot); + + // Wait for runtime controls + await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 }); + + // Click SSH runtime button + const sshButton = canvas.getByRole("button", { name: /SSH/i }); + await userEvent.click(sshButton); + + // SSH host input should appear (normal SSH mode) + await waitFor( + () => { + const hostInput = canvas.queryByPlaceholderText("user@host"); + if (!hostInput) throw new Error("SSH host input not found"); + }, + { timeout: 5000 } + ); + + // Coder checkbox should NOT appear + const coderCheckbox = canvas.queryByTestId("coder-checkbox"); + if (coderCheckbox) { + throw new Error("Coder checkbox should not appear when Coder is unavailable"); + } + }, +}; + +/** + * Coder with template that has no presets. + * When selecting a template with 0 presets, the preset dropdown is hidden. + */ +export const CoderNoPresets: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: [], + coderInfo: { available: true, version: "2.28.0" }, + coderTemplates: [ + { name: "simple-vm", displayName: "Simple VM", organizationName: "default" }, + ], + coderPresets: new Map([["simple-vm", []]]), + coderWorkspaces: [], + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + const canvas = within(storyRoot); + + // Wait for runtime controls + await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 }); + + // Click SSH runtime button + const sshButton = canvas.getByRole("button", { name: /SSH/i }); + await userEvent.click(sshButton); + + // Enable Coder + const coderCheckbox = await canvas.findByTestId("coder-checkbox", {}, { timeout: 5000 }); + await userEvent.click(coderCheckbox); + + // Wait for Coder controls + await canvas.findByTestId("coder-controls-inner", {}, { timeout: 5000 }); + + // Template dropdown should be visible + await canvas.findByTestId("coder-template-select", {}, { timeout: 5000 }); + + // Preset dropdown should NOT be visible (0 presets) + const presetSelect = canvas.queryByTestId("coder-preset-select"); + if (presetSelect) { + throw new Error("Preset dropdown should not appear when template has no presets"); + } + }, +}; + +/** + * Coder with no running workspaces. + * When switching to "Existing" mode with no running workspaces, shows empty state. + */ +export const CoderNoRunningWorkspaces: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: [], + coderInfo: { available: true, version: "2.28.0" }, + coderTemplates: mockTemplates, + coderPresets: new Map([ + ["coder-on-coder", mockPresetsCoderOnCoder], + ["kubernetes-dev", mockPresetsK8s], + ]), + coderWorkspaces: [], // No running workspaces + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + const canvas = within(storyRoot); + + // Wait for runtime controls + await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 }); + + // Click SSH runtime button + const sshButton = canvas.getByRole("button", { name: /SSH/i }); + await userEvent.click(sshButton); + + // Enable Coder + const coderCheckbox = await canvas.findByTestId("coder-checkbox", {}, { timeout: 5000 }); + await userEvent.click(coderCheckbox); + + // Click "Existing" button + const existingButton = await canvas.findByRole( + "button", + { name: "Existing" }, + { timeout: 5000 } + ); + await userEvent.click(existingButton); + + // Workspace dropdown should show "No running workspaces" + const workspaceSelect = await canvas.findByTestId( + "coder-workspace-select", + {}, + { timeout: 5000 } + ); + await waitFor(() => { + const options = workspaceSelect.querySelectorAll("option"); + const hasNoWorkspacesOption = Array.from(options).some((opt) => + opt.textContent?.includes("No running workspaces") + ); + if (!hasNoWorkspacesOption) { + throw new Error("Should show 'No running workspaces' option"); + } + }); + }, +}; diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 67aadd72e7..2549a84bca 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -28,6 +28,12 @@ import { import { normalizeModeAiDefaults, type ModeAiDefaults } from "@/common/types/modeAiDefaults"; import { normalizeAgentAiDefaults, type AgentAiDefaults } from "@/common/types/agentAiDefaults"; import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue"; +import type { + CoderInfo, + CoderTemplate, + CoderPreset, + CoderWorkspace, +} from "@/common/orpc/schemas/coder"; import { isWorkspaceArchived } from "@/common/utils/archive"; /** Session usage data structure matching SessionUsageFileSchema */ @@ -145,6 +151,14 @@ export interface MockORPCClientOptions { githubUser: string | null; error: { message: string; hasEncryptedKey: boolean } | null; }; + /** Coder CLI availability info */ + coderInfo?: CoderInfo; + /** Coder templates available for workspace creation */ + coderTemplates?: CoderTemplate[]; + /** Coder presets per template name */ + coderPresets?: Map; + /** Existing Coder workspaces */ + coderWorkspaces?: CoderWorkspace[]; } interface MockBackgroundProcess { @@ -213,6 +227,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl gitInit: customGitInit, runtimeAvailability: customRuntimeAvailability, signingCapabilities: customSigningCapabilities, + coderInfo = { available: false }, + coderTemplates = [], + coderPresets = new Map(), + coderWorkspaces = [], } = options; // Feature flags @@ -664,6 +682,20 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl window: { setTitle: () => Promise.resolve(undefined), }, + coder: { + getInfo: () => Promise.resolve(coderInfo), + listTemplates: () => Promise.resolve(coderTemplates), + listPresets: (input: { template: string }) => + Promise.resolve(coderPresets.get(input.template) ?? []), + listWorkspaces: () => Promise.resolve(coderWorkspaces), + }, + nameGeneration: { + generate: () => + Promise.resolve({ + success: true, + data: { name: "generated-workspace", title: "Generated Workspace", modelUsed: "mock" }, + }), + }, terminal: { listSessions: (_input: { workspaceId: string }) => Promise.resolve([]), create: () => diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index cd9a5a7887..102814c0ff 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -81,6 +81,7 @@ async function createTestServer(authToken?: string): Promise { telemetryService: services.telemetryService, sessionUsageService: services.sessionUsageService, signingService: services.signingService, + coderService: services.coderService, }; // Use the actual createOrpcServer function diff --git a/src/cli/run.ts b/src/cli/run.ts index 22acbb2030..6c266e96a3 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -65,8 +65,9 @@ import assert from "@/common/utils/assert"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { log, type LogLevel } from "@/node/services/log"; import chalk from "chalk"; -import type { InitLogger } from "@/node/runtime/Runtime"; +import type { InitLogger, WorkspaceInitResult } from "@/node/runtime/Runtime"; import { DockerRuntime } from "@/node/runtime/DockerRuntime"; +import { runFullInit } from "@/node/runtime/runtimeFactory"; import { execSync } from "child_process"; import { getParseOptions } from "./argv"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; @@ -494,13 +495,22 @@ async function main(): Promise { process.exit(1); } - const initResult = await runtime.initWorkspace({ - projectPath: projectDir, - branchName, - trunkBranch, - workspacePath: createResult.workspacePath!, - initLogger, - }); + // Use runFullInit to ensure postCreateSetup runs before initWorkspace + let initResult: WorkspaceInitResult; + try { + initResult = await runFullInit(runtime, { + projectPath: projectDir, + branchName, + trunkBranch, + workspacePath: createResult.workspacePath!, + initLogger, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + initLogger.logStderr(`Initialization failed: ${errorMessage}`); + initLogger.logComplete(-1); + initResult = { success: false, error: errorMessage }; + } if (!initResult.success) { // Clean up orphaned container // eslint-disable-next-line @typescript-eslint/no-empty-function diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index cafff4c591..c8ab80d6ed 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -84,6 +84,7 @@ async function createTestServer(): Promise { telemetryService: services.telemetryService, sessionUsageService: services.sessionUsageService, signingService: services.signingService, + coderService: services.coderService, }; // Use the actual createOrpcServer function diff --git a/src/cli/server.ts b/src/cli/server.ts index 70aa20be75..0d47c99fe4 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -117,6 +117,7 @@ const mockWindow: BrowserWindow = { experimentsService: serviceContainer.experimentsService, sessionUsageService: serviceContainer.sessionUsageService, signingService: serviceContainer.signingService, + coderService: serviceContainer.coderService, }; const mdnsAdvertiser = new MdnsAdvertiserService(); diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index fb770a28a2..1a0d1155a4 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -133,6 +133,13 @@ export { export { ApiServerStatusSchema, AWSCredentialStatusSchema, + coder, + CoderInfoSchema, + CoderPresetSchema, + CoderTemplateSchema, + CoderWorkspaceConfigSchema, + CoderWorkspaceSchema, + CoderWorkspaceStatusSchema, config, debug, features, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index a84bd8ed4b..fbe76fa105 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -321,6 +321,17 @@ export const projects = { }, }; +// Re-export Coder schemas from dedicated file +export { + coder, + CoderInfoSchema, + CoderPresetSchema, + CoderTemplateSchema, + CoderWorkspaceConfigSchema, + CoderWorkspaceSchema, + CoderWorkspaceStatusSchema, +} from "./coder"; + // Workspace export const workspace = { list: { diff --git a/src/common/orpc/schemas/coder.ts b/src/common/orpc/schemas/coder.ts new file mode 100644 index 0000000000..3defda9067 --- /dev/null +++ b/src/common/orpc/schemas/coder.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; + +// Coder workspace config - attached to SSH runtime when using Coder +export const CoderWorkspaceConfigSchema = z.object({ + /** + * Coder workspace name. + * - For new workspaces: omit or undefined (backend derives from mux branch name) + * - For existing workspaces: required (the selected Coder workspace name) + * - After creation: populated with the actual Coder workspace name for reference + */ + workspaceName: z.string().optional().meta({ description: "Coder workspace name" }), + template: z.string().optional().meta({ description: "Template used to create workspace" }), + preset: z.string().optional().meta({ description: "Preset used during creation" }), + existingWorkspace: z + .boolean() + .optional() + .meta({ description: "True if connected to pre-existing Coder workspace" }), +}); + +export type CoderWorkspaceConfig = z.infer; + +// Coder CLI availability info +export const CoderInfoSchema = z.object({ + available: z.boolean(), + version: z.string().optional(), +}); + +export type CoderInfo = z.infer; + +// Coder template +export const CoderTemplateSchema = z.object({ + name: z.string(), + displayName: z.string(), + organizationName: z.string(), +}); + +export type CoderTemplate = z.infer; + +// Coder preset for a template +export const CoderPresetSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + isDefault: z.boolean(), +}); + +export type CoderPreset = z.infer; + +// Coder workspace status +export const CoderWorkspaceStatusSchema = z.enum([ + "running", + "stopped", + "starting", + "stopping", + "failed", + "pending", + "canceling", + "canceled", + "deleting", + "deleted", +]); + +export type CoderWorkspaceStatus = z.infer; + +// Coder workspace +export const CoderWorkspaceSchema = z.object({ + name: z.string(), + templateName: z.string(), + status: CoderWorkspaceStatusSchema, +}); + +export type CoderWorkspace = z.infer; + +// API schemas for coder namespace +export const coder = { + getInfo: { + input: z.void(), + output: CoderInfoSchema, + }, + listTemplates: { + input: z.void(), + output: z.array(CoderTemplateSchema), + }, + listPresets: { + input: z.object({ template: z.string() }), + output: z.array(CoderPresetSchema), + }, + listWorkspaces: { + input: z.void(), + output: z.array(CoderWorkspaceSchema), + }, +}; diff --git a/src/common/orpc/schemas/runtime.ts b/src/common/orpc/schemas/runtime.ts index a1a73de490..ce180340e1 100644 --- a/src/common/orpc/schemas/runtime.ts +++ b/src/common/orpc/schemas/runtime.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { CoderWorkspaceConfigSchema } from "./coder"; export const RuntimeModeSchema = z.enum(["local", "worktree", "ssh", "docker"]); @@ -55,6 +56,9 @@ export const RuntimeConfigSchema = z.union([ .optional() .meta({ description: "Path to SSH private key (if not using ~/.ssh/config or ssh-agent)" }), port: z.number().optional().meta({ description: "SSH port (default: 22)" }), + coder: CoderWorkspaceConfigSchema.optional().meta({ + description: "Coder workspace configuration (when using Coder as SSH backend)", + }), }), // Docker runtime - each workspace runs in its own container z.object({ diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index bbf4147dcf..0fb7f531a6 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -5,6 +5,10 @@ import type { z } from "zod"; import type { RuntimeConfigSchema } from "../orpc/schemas"; import { RuntimeModeSchema } from "../orpc/schemas"; +import type { CoderWorkspaceConfig } from "../orpc/schemas/coder"; + +// Re-export CoderWorkspaceConfig type from schema (single source of truth) +export type { CoderWorkspaceConfig }; /** Runtime mode type - used in UI and runtime string parsing */ export type RuntimeMode = z.infer; @@ -44,7 +48,7 @@ export type RuntimeConfig = z.infer; export type ParsedRuntime = | { mode: "local" } | { mode: "worktree" } - | { mode: "ssh"; host: string } + | { mode: "ssh"; host: string; coder?: CoderWorkspaceConfig } | { mode: "docker"; image: string; shareCredentials?: boolean }; /** @@ -141,6 +145,7 @@ export function buildRuntimeConfig(parsed: ParsedRuntime): RuntimeConfig | undef type: RUNTIME_MODE.SSH, host: parsed.host.trim(), srcBaseDir: "~/mux", // Default remote base directory (tilde resolved by backend) + coder: parsed.coder, }; case RUNTIME_MODE.DOCKER: return { diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 35321f3ce6..8221c69877 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -368,6 +368,7 @@ async function loadServices(): Promise { experimentsService: services.experimentsService, sessionUsageService: services.sessionUsageService, signingService: services.signingService, + coderService: services.coderService, }; electronIpcMain.handle("mux:get-is-rosetta", async () => { diff --git a/src/node/config.ts b/src/node/config.ts index 4f8feb5c5c..e4fefd2adf 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -781,13 +781,14 @@ export class Config { */ async updateWorkspaceMetadata( workspaceId: string, - updates: Partial> + updates: Partial> ): Promise { await this.editConfig((config) => { for (const [_projectPath, projectConfig] of config.projects) { const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId); if (workspace) { if (updates.name !== undefined) workspace.name = updates.name; + if (updates.runtimeConfig !== undefined) workspace.runtimeConfig = updates.runtimeConfig; return config; } } diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index be0a8ac192..3cd5493476 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -23,6 +23,7 @@ import type { FeatureFlagService } from "@/node/services/featureFlagService"; import type { SessionTimingService } from "@/node/services/sessionTimingService"; import type { SessionUsageService } from "@/node/services/sessionUsageService"; import type { TaskService } from "@/node/services/taskService"; +import type { CoderService } from "@/node/services/coderService"; export interface ORPCContext { config: Config; @@ -49,5 +50,6 @@ export interface ORPCContext { experimentsService: ExperimentsService; sessionUsageService: SessionUsageService; signingService: SigningService; + coderService: CoderService; headers?: IncomingHttpHeaders; } diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 24c16e395c..3ef652f9af 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1015,6 +1015,32 @@ export const router = (authToken?: string) => { }; }), }, + coder: { + getInfo: t + .input(schemas.coder.getInfo.input) + .output(schemas.coder.getInfo.output) + .handler(async ({ context }) => { + return context.coderService.getCoderInfo(); + }), + listTemplates: t + .input(schemas.coder.listTemplates.input) + .output(schemas.coder.listTemplates.output) + .handler(async ({ context }) => { + return context.coderService.listTemplates(); + }), + listPresets: t + .input(schemas.coder.listPresets.input) + .output(schemas.coder.listPresets.output) + .handler(async ({ context, input }) => { + return context.coderService.listPresets(input.template); + }), + listWorkspaces: t + .input(schemas.coder.listWorkspaces.input) + .output(schemas.coder.listWorkspaces.output) + .handler(async ({ context }) => { + return context.coderService.listWorkspaces(); + }), + }, workspace: { list: t .input(schemas.workspace.list.input) diff --git a/src/node/runtime/CoderSSHRuntime.ts b/src/node/runtime/CoderSSHRuntime.ts new file mode 100644 index 0000000000..746555a349 --- /dev/null +++ b/src/node/runtime/CoderSSHRuntime.ts @@ -0,0 +1,308 @@ +/** + * CoderSSHRuntime - SSH runtime wrapper for Coder workspaces. + * + * Extends SSHRuntime to add Coder-specific provisioning via postCreateSetup(): + * - Creates Coder workspace (if not connecting to existing) + * - Runs `coder config-ssh --yes` to set up SSH proxy + * + * This ensures mux workspace metadata is persisted before the long-running + * Coder build starts, allowing build logs to stream to init logs (like Docker). + */ + +import type { + WorkspaceCreationParams, + WorkspaceCreationResult, + WorkspaceForkParams, + WorkspaceForkResult, + WorkspaceInitParams, +} from "./Runtime"; +import { SSHRuntime, type SSHRuntimeConfig } from "./SSHRuntime"; +import type { CoderWorkspaceConfig } from "@/common/types/runtime"; +import type { CoderService } from "@/node/services/coderService"; +import { log } from "@/node/services/log"; +import { execBuffered } from "@/node/utils/runtime/helpers"; +import { expandTildeForSSH } from "./tildeExpansion"; +import * as path from "path"; + +export interface CoderSSHRuntimeConfig extends SSHRuntimeConfig { + /** Coder-specific configuration */ + coder: CoderWorkspaceConfig; +} + +/** + * SSH runtime that handles Coder workspace provisioning. + * + * IMPORTANT: This extends SSHRuntime (rather than delegating) so other backend + * code that checks `runtime instanceof SSHRuntime` (PTY, tools, path handling) + * continues to behave correctly for Coder workspaces. + */ +export class CoderSSHRuntime extends SSHRuntime { + private coderConfig: CoderWorkspaceConfig; + private readonly coderService: CoderService; + + constructor(config: CoderSSHRuntimeConfig, coderService: CoderService) { + super({ + host: config.host, + srcBaseDir: config.srcBaseDir, + bgOutputDir: config.bgOutputDir, + identityFile: config.identityFile, + port: config.port, + }); + this.coderConfig = config.coder; + this.coderService = coderService; + } + + /** + * Create workspace (fast path only - no SSH needed). + * The Coder workspace may not exist yet, so we can't reach the SSH host. + * Just compute the workspace path locally. + */ + override createWorkspace(params: WorkspaceCreationParams): Promise { + const workspacePath = this.getWorkspacePath(params.projectPath, params.directoryName); + + params.initLogger.logStep("Workspace path computed (Coder provisioning will follow)"); + + return Promise.resolve({ + success: true, + workspacePath, + }); + } + + /** + * Delete workspace: removes SSH files AND deletes Coder workspace (if Mux-managed). + * + * IMPORTANT: Only delete the Coder workspace once we're confident mux will commit + * the deletion. In the non-force path, WorkspaceService.remove() aborts and keeps + * workspace metadata when runtime.deleteWorkspace() fails. + */ + override async deleteWorkspace( + projectPath: string, + workspaceName: string, + force: boolean, + abortSignal?: AbortSignal + ): Promise<{ success: true; deletedPath: string } | { success: false; error: string }> { + const sshResult = await super.deleteWorkspace(projectPath, workspaceName, force, abortSignal); + + // If this workspace is an existing Coder workspace that mux didn't create, never delete it. + if (this.coderConfig.existingWorkspace) { + return sshResult; + } + + // In the normal (force=false) delete path, only delete the Coder workspace if the SSH delete + // succeeded. If SSH delete failed (e.g., dirty workspace), WorkspaceService.remove() keeps the + // workspace metadata and the user can retry. + if (!sshResult.success && !force) { + return sshResult; + } + + // workspaceName should always be set after workspace creation (prepareCoderConfigForCreate sets it) + const coderWorkspaceName = this.coderConfig.workspaceName; + if (!coderWorkspaceName) { + log.warn("Coder workspace name not set, skipping Coder workspace deletion"); + return sshResult; + } + + try { + log.debug(`Deleting Coder workspace "${coderWorkspaceName}"`); + await this.coderService.deleteWorkspace(coderWorkspaceName); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error("Failed to delete Coder workspace", { + coderWorkspaceName, + error: message, + }); + + if (sshResult.success) { + return { + success: false, + error: `SSH delete succeeded, but failed to delete Coder workspace: ${message}`, + }; + } + + return { + success: false, + error: `SSH delete failed: ${sshResult.error}; Coder delete also failed: ${message}`, + }; + } + + return sshResult; + } + + /** + * Fork workspace: delegates to SSHRuntime, but marks both source and fork + * as existingWorkspace=true so neither can delete the shared Coder workspace. + * + * IMPORTANT: Also updates this instance's coderConfig so that if postCreateSetup + * runs on this same runtime instance (for the forked workspace), it won't attempt + * to create a new Coder workspace. + */ + override async forkWorkspace(params: WorkspaceForkParams): Promise { + const result = await super.forkWorkspace(params); + if (!result.success) return result; + + // Both workspaces now share the Coder workspace - mark as existing so + // deleting either mux workspace won't destroy the underlying Coder workspace + const sharedCoderConfig = { ...this.coderConfig, existingWorkspace: true }; + + // Update this instance's config so postCreateSetup() skips coder create + this.coderConfig = sharedCoderConfig; + + const sshConfig = this.getConfig(); + const sharedRuntimeConfig = { type: "ssh" as const, ...sshConfig, coder: sharedCoderConfig }; + + return { + ...result, + forkedRuntimeConfig: sharedRuntimeConfig, + sourceRuntimeConfig: sharedRuntimeConfig, + }; + } + + /** + * Post-create setup: provision Coder workspace and configure SSH. + * This runs after mux persists workspace metadata, so build logs stream to UI. + */ + async postCreateSetup(params: WorkspaceInitParams): Promise { + const { initLogger, abortSignal } = params; + + // Create Coder workspace if not connecting to an existing one + if (!this.coderConfig.existingWorkspace) { + // Validate required fields (workspaceName is set by prepareCoderConfigForCreate before this runs) + const coderWorkspaceName = this.coderConfig.workspaceName; + if (!coderWorkspaceName) { + throw new Error( + "Coder workspace name is required (should be set by prepareCoderConfigForCreate)" + ); + } + if (!this.coderConfig.template) { + throw new Error("Coder template is required for new workspaces"); + } + + initLogger.logStep(`Creating Coder workspace "${coderWorkspaceName}"...`); + + try { + for await (const line of this.coderService.createWorkspace( + coderWorkspaceName, + this.coderConfig.template, + this.coderConfig.preset, + abortSignal + )) { + initLogger.logStdout(line); + } + initLogger.logStep("Coder workspace created successfully"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + log.error("Failed to create Coder workspace", { error, config: this.coderConfig }); + initLogger.logStderr(`Failed to create Coder workspace: ${errorMsg}`); + throw new Error(`Failed to create Coder workspace: ${errorMsg}`); + } + } + + // Ensure SSH config is set up for Coder workspaces + initLogger.logStep("Configuring SSH for Coder..."); + try { + await this.coderService.ensureSSHConfig(); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + log.error("Failed to configure SSH for Coder", { error }); + initLogger.logStderr(`Failed to configure SSH: ${errorMsg}`); + throw new Error(`Failed to configure SSH for Coder: ${errorMsg}`); + } + + // Create parent directory for workspace (git clone won't create it) + // This must happen after ensureSSHConfig() so SSH is configured + initLogger.logStep("Preparing workspace directory..."); + const parentDir = path.posix.dirname(params.workspacePath); + const mkdirResult = await execBuffered(this, `mkdir -p ${expandTildeForSSH(parentDir)}`, { + cwd: "/tmp", + timeout: 10, + abortSignal, + }); + if (mkdirResult.exitCode !== 0) { + const errorMsg = mkdirResult.stderr || mkdirResult.stdout || "Unknown error"; + log.error("Failed to create workspace parent directory", { parentDir, error: errorMsg }); + initLogger.logStderr(`Failed to prepare workspace directory: ${errorMsg}`); + throw new Error(`Failed to prepare workspace directory: ${errorMsg}`); + } + } +} +// ============================================================================ +// Coder RuntimeConfig helpers (called by workspaceService before persistence) +// ============================================================================ + +/** + * Result of preparing a Coder SSH runtime config for workspace creation. + */ +export type PrepareCoderConfigResult = + | { success: true; host: string; coder: CoderWorkspaceConfig } + | { success: false; error: string }; + +/** + * Coder workspace name regex: ^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$ + * - Must start with alphanumeric + * - Can contain hyphens, but only between alphanumeric segments + * - No underscores (unlike mux workspace names) + */ +const CODER_NAME_REGEX = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/; + +/** + * Transform a mux workspace name to be Coder-compatible. + * - Replace underscores with hyphens + * - Remove leading/trailing hyphens + * - Collapse multiple consecutive hyphens + */ +function toCoderCompatibleName(name: string): string { + return name + .replace(/_/g, "-") // Replace underscores with hyphens + .replace(/^-+|-+$/g, "") // Remove leading/trailing hyphens + .replace(/-{2,}/g, "-"); // Collapse multiple hyphens +} + +/** + * Prepare Coder config for workspace creation. + * + * For new workspaces: derives workspaceName from mux workspace name if not set, + * transforming it to be Coder-compatible (no underscores, valid format). + * For existing workspaces: validates workspaceName is present. + * Always normalizes host to `.coder`. + * + * Call this before persisting RuntimeConfig to ensure correct values are stored. + */ +export function prepareCoderConfigForCreate( + coder: CoderWorkspaceConfig, + muxWorkspaceName: string +): PrepareCoderConfigResult { + let workspaceName = coder.workspaceName?.trim() ?? ""; + + if (!coder.existingWorkspace) { + // New workspace: derive name from mux workspace name if not provided + if (!workspaceName) { + workspaceName = muxWorkspaceName; + } + // Transform to Coder-compatible name (handles underscores, etc.) + workspaceName = toCoderCompatibleName(workspaceName); + + // Validate against Coder's regex + if (!CODER_NAME_REGEX.test(workspaceName)) { + return { + success: false, + error: `Workspace name "${muxWorkspaceName}" cannot be converted to a valid Coder name. Use only letters, numbers, and hyphens.`, + }; + } + } else { + // Existing workspace: name must be provided (selected from dropdown) + if (!workspaceName) { + return { success: false, error: "Coder workspace name is required for existing workspaces" }; + } + } + + // Final validation + if (!workspaceName) { + return { success: false, error: "Coder workspace name is required" }; + } + + return { + success: true, + host: `${workspaceName}.coder`, + coder: { ...coder, workspaceName }, + }; +} diff --git a/src/node/runtime/DockerRuntime.ts b/src/node/runtime/DockerRuntime.ts index f999986af3..fa30b69ac5 100644 --- a/src/node/runtime/DockerRuntime.ts +++ b/src/node/runtime/DockerRuntime.ts @@ -424,7 +424,7 @@ export class DockerRuntime extends RemoteRuntime { }; } - // Store container name - actual container creation happens in initWorkspace + // Store container name - actual container creation happens in postCreateSetup // so that image pull progress is visible in the init section this.containerName = containerName; @@ -434,12 +434,68 @@ export class DockerRuntime extends RemoteRuntime { }; } - async initWorkspace(params: WorkspaceInitParams): Promise { + /** + * Post-create setup: provision container OR detect fork and setup credentials. + * Runs after mux persists workspace metadata so build logs stream to UI in real-time. + * + * Handles ALL environment setup: + * - Fresh workspace: provisions container (create, sync, checkout, credentials) + * - Fork: detects existing container, logs "from fork", sets up credentials + * - Stale container: removes and re-provisions + * + * After this completes, the container is ready for initWorkspace() to run the hook. + */ + async postCreateSetup(params: WorkspaceInitParams): Promise { const { projectPath, branchName, trunkBranch, workspacePath, initLogger, abortSignal, env } = params; - // Hoisted outside try so catch block can check whether we created the container - let skipContainerSetup = false; + if (!this.containerName) { + throw new Error("Container not initialized. Call createWorkspace first."); + } + const containerName = this.containerName; + + // Check if container already exists (e.g., from successful fork or aborted previous attempt) + const containerCheck = await this.checkExistingContainer( + containerName, + workspacePath, + branchName + ); + switch (containerCheck.action) { + case "skip": + // Fork path: container already valid, just log and setup credentials + initLogger.logStep("Container already running (from fork), running init hook..."); + await this.setupCredentials(containerName, env); + return; + case "cleanup": + initLogger.logStep(containerCheck.reason); + await runDockerCommand(`docker rm -f ${containerName}`, 10000); + break; + case "create": + break; + } + + // Provision container (throws on error - caller handles) + await this.provisionContainer({ + containerName, + projectPath, + workspacePath, + branchName, + trunkBranch, + initLogger, + abortSignal, + env, + }); + } + + /** + * Initialize workspace by running .mux/init hook. + * Assumes postCreateSetup() has already been called to provision/prepare the container. + * + * This method ONLY runs the hook - all container provisioning and credential setup + * is handled by postCreateSetup(). + */ + async initWorkspace(params: WorkspaceInitParams): Promise { + const { projectPath, branchName, workspacePath, initLogger, abortSignal, env } = params; try { if (!this.containerName) { @@ -448,45 +504,8 @@ export class DockerRuntime extends RemoteRuntime { error: "Container not initialized. Call createWorkspace first.", }; } - const containerName = this.containerName; - - // Check if container already exists (e.g., from successful fork or aborted previous attempt) - const containerCheck = await this.checkExistingContainer( - containerName, - workspacePath, - branchName - ); - switch (containerCheck.action) { - case "skip": - initLogger.logStep("Container already running (from fork), running init hook..."); - await this.setupCredentials(containerName, env); - skipContainerSetup = true; - break; - case "cleanup": - initLogger.logStep(containerCheck.reason); - await runDockerCommand(`docker rm -f ${containerName}`, 10000); - break; - case "create": - break; - } - - if (!skipContainerSetup) { - const setupResult = await this.setupContainerAndSyncProject({ - containerName, - projectPath, - workspacePath, - branchName, - trunkBranch, - initLogger, - abortSignal, - env, - }); - if (!setupResult.success) { - return setupResult; - } - } - // 4. Run .mux/init hook if it exists + // Run .mux/init hook if it exists const hookExists = await checkInitHookExists(projectPath); if (hookExists) { const muxEnv = { ...env, ...getMuxEnv(projectPath, "docker", branchName) }; @@ -501,10 +520,7 @@ export class DockerRuntime extends RemoteRuntime { const errorMsg = getErrorMessage(error); initLogger.logStderr(`Initialization failed: ${errorMsg}`); initLogger.logComplete(-1); - // Only clean up container if we created it (preserve forked containers on init hook failure) - if (this.containerName && !skipContainerSetup) { - await runDockerCommand(`docker rm -f ${this.containerName}`, 10000); - } + // Do NOT delete container on hook failure - user can debug return { success: false, error: errorMsg, @@ -585,10 +601,11 @@ export class DockerRuntime extends RemoteRuntime { } /** - * Create container, sync project files, and checkout branch. - * This is the full setup path for new workspaces (not forked ones). + * Provision container: create, sync project, checkout branch. + * Throws on error (does not call logComplete - caller handles that). + * Used by postCreateSetup() for streaming logs before initWorkspace(). */ - private async setupContainerAndSyncProject(params: { + private async provisionContainer(params: { containerName: string; projectPath: string; workspacePath: string; @@ -597,7 +614,7 @@ export class DockerRuntime extends RemoteRuntime { initLogger: InitLogger; abortSignal?: AbortSignal; env?: Record; - }): Promise { + }): Promise { const { containerName, projectPath, @@ -609,20 +626,11 @@ export class DockerRuntime extends RemoteRuntime { env, } = params; - // Helper to log error, mark complete, clean up container, and return failure - const failWithCleanup = async (errorMsg: string): Promise => { - initLogger.logStderr(errorMsg); - initLogger.logComplete(-1); - await runDockerCommand(`docker rm -f ${containerName}`, 10000); - return { success: false, error: errorMsg }; - }; - // 1. Create container (with image pull if needed) initLogger.logStep(`Creating container from ${this.config.image}...`); if (abortSignal?.aborted) { - initLogger.logComplete(-1); - return { success: false, error: "Workspace creation aborted" }; + throw new Error("Workspace creation aborted"); } // Create and start container with streaming output for image pull progress @@ -631,7 +639,8 @@ export class DockerRuntime extends RemoteRuntime { shareCredentials: this.config.shareCredentials, }); if (runResult.exitCode !== 0) { - return failWithCleanup(`Failed to create container: ${runResult.stderr}`); + await runDockerCommand(`docker rm -f ${containerName}`, 10000); + throw new Error(`Failed to create container: ${runResult.stderr}`); } // Detect container's default user (may be non-root, e.g., codercom/enterprise-base runs as "coder") @@ -654,7 +663,8 @@ export class DockerRuntime extends RemoteRuntime { 10000 ); if (mkdirResult.exitCode !== 0) { - return failWithCleanup(`Failed to create workspace directory: ${mkdirResult.stderr}`); + await runDockerCommand(`docker rm -f ${containerName}`, 10000); + throw new Error(`Failed to create workspace directory: ${mkdirResult.stderr}`); } initLogger.logStep("Container ready"); @@ -673,7 +683,8 @@ export class DockerRuntime extends RemoteRuntime { abortSignal ); } catch (error) { - return failWithCleanup(`Failed to sync project: ${getErrorMessage(error)}`); + await runDockerCommand(`docker rm -f ${containerName}`, 10000); + throw new Error(`Failed to sync project: ${getErrorMessage(error)}`); } initLogger.logStep("Files synced successfully"); @@ -694,16 +705,12 @@ export class DockerRuntime extends RemoteRuntime { ]); if (exitCode !== 0) { - return failWithCleanup(`Failed to checkout branch: ${stderr || stdout}`); + await runDockerCommand(`docker rm -f ${containerName}`, 10000); + throw new Error(`Failed to checkout branch: ${stderr || stdout}`); } initLogger.logStep("Branch checked out successfully"); - - return { success: true }; } - /** - * Sync project to container using git bundle - */ private async syncProjectToContainer( projectPath: string, containerName: string, diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index e1d5c763b8..266ddfcee7 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -1,3 +1,5 @@ +import type { RuntimeConfig } from "@/common/types/runtime"; + /** * Runtime abstraction for executing tools in different environments. * @@ -237,6 +239,10 @@ export interface WorkspaceForkResult { sourceBranch?: string; /** Error message (if failed) */ error?: string; + /** Runtime config for the forked workspace (if different from source) */ + forkedRuntimeConfig?: RuntimeConfig; + /** Updated runtime config for source workspace (e.g., mark as shared) */ + sourceRuntimeConfig?: RuntimeConfig; } /** @@ -353,6 +359,21 @@ export interface Runtime { */ createWorkspace(params: WorkspaceCreationParams): Promise; + /** + * Optional long-running setup that runs after mux persists workspace metadata. + * Used for provisioning steps that must happen before initWorkspace but after + * the workspace is registered (e.g., creating Coder workspaces, pulling Docker images). + * + * Contract: + * - MAY take minutes (streams progress via initLogger) + * - MUST NOT call initLogger.logComplete() - that's handled by the caller + * - On failure: throw; caller will log error and mark init failed + * - Runtimes with this hook expect callers to use runFullInit/runBackgroundInit + * + * @param params Same as initWorkspace params + */ + postCreateSetup?(params: WorkspaceInitParams): Promise; + /** * Initialize workspace asynchronously (may be slow, streams progress) * - LocalRuntime: Runs init hook if present diff --git a/src/node/runtime/runtimeFactory.ts b/src/node/runtime/runtimeFactory.ts index a656f7a2d0..ec71b367e7 100644 --- a/src/node/runtime/runtimeFactory.ts +++ b/src/node/runtime/runtimeFactory.ts @@ -1,18 +1,74 @@ import * as fs from "fs/promises"; import * as path from "path"; -import type { Runtime, RuntimeAvailability } from "./Runtime"; +import type { + Runtime, + RuntimeAvailability, + WorkspaceInitParams, + WorkspaceInitResult, +} from "./Runtime"; import { LocalRuntime } from "./LocalRuntime"; import { WorktreeRuntime } from "./WorktreeRuntime"; import { SSHRuntime } from "./SSHRuntime"; +import { CoderSSHRuntime } from "./CoderSSHRuntime"; import { DockerRuntime, getContainerName } from "./DockerRuntime"; import type { RuntimeConfig, RuntimeMode } from "@/common/types/runtime"; import { hasSrcBaseDir } from "@/common/types/runtime"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; import { execAsync } from "@/node/utils/disposableExec"; +import type { CoderService } from "@/node/services/coderService"; // Re-export for backward compatibility with existing imports export { isIncompatibleRuntimeConfig }; +// Global CoderService singleton - set during app init so all createRuntime calls can use it +let globalCoderService: CoderService | undefined; + +/** + * Set the global CoderService instance for runtime factory. + * Call this during app initialization so createRuntime() can create CoderSSHRuntime + * without requiring callers to pass coderService explicitly. + */ +export function setGlobalCoderService(service: CoderService): void { + globalCoderService = service; +} + +/** + * Run the full init sequence: postCreateSetup (if present) then initWorkspace. + * Use this everywhere instead of calling initWorkspace directly to ensure + * runtimes with provisioning steps (Docker, CoderSSH) work correctly. + */ +export async function runFullInit( + runtime: Runtime, + params: WorkspaceInitParams +): Promise { + if (runtime.postCreateSetup) { + await runtime.postCreateSetup(params); + } + return runtime.initWorkspace(params); +} + +/** + * Fire-and-forget init with standardized error handling. + * Use this for background init after workspace creation (workspaceService, taskService). + */ +export function runBackgroundInit( + runtime: Runtime, + params: WorkspaceInitParams, + workspaceId: string, + logger?: { error: (msg: string, ctx: object) => void } +): void { + void (async () => { + try { + await runFullInit(runtime, params); + } catch (error: unknown) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger?.error(`Workspace init failed for ${workspaceId}:`, { error }); + params.initLogger.logStderr(`Initialization failed: ${errorMsg}`); + params.initLogger.logComplete(-1); + } + })(); +} + /** * Error thrown when a workspace has an incompatible runtime configuration, * typically from a newer version of mux that added new runtime types. @@ -39,6 +95,11 @@ export interface CreateRuntimeOptions { * Used together with projectPath to derive the container name. */ workspaceName?: string; + /** + * Coder service - required for SSH runtimes with Coder configuration. + * When provided and config has coder field, returns CoderSSHRuntime instead of SSHRuntime. + */ + coderService?: CoderService; } /** @@ -79,14 +140,27 @@ export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOpti case "worktree": return new WorktreeRuntime(config.srcBaseDir); - case "ssh": - return new SSHRuntime({ + case "ssh": { + const sshConfig = { host: config.host, srcBaseDir: config.srcBaseDir, bgOutputDir: config.bgOutputDir, identityFile: config.identityFile, port: config.port, - }); + }; + + // Use CoderSSHRuntime for SSH+Coder when coderService is available (explicit or global) + const coderService = options?.coderService ?? globalCoderService; + + if (config.coder) { + if (!coderService) { + throw new Error("Coder runtime requested but CoderService is not initialized"); + } + return new CoderSSHRuntime({ ...sshConfig, coder: config.coder }, coderService); + } + + return new SSHRuntime(sshConfig); + } case "docker": { // For existing workspaces, derive container name from project+workspace diff --git a/src/node/services/coderService.test.ts b/src/node/services/coderService.test.ts new file mode 100644 index 0000000000..a2d2163f7c --- /dev/null +++ b/src/node/services/coderService.test.ts @@ -0,0 +1,455 @@ +import { EventEmitter } from "events"; +import { Readable } from "stream"; +import { describe, it, expect, vi, beforeEach, afterEach } from "bun:test"; +import { CoderService, compareVersions } from "./coderService"; + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const noop = () => {}; + +// Mock execAsync + +// Mock spawn for streaming createWorkspace() +void vi.mock("child_process", () => ({ + spawn: vi.fn(), +})); + +import { spawn } from "child_process"; + +const mockSpawn = spawn as ReturnType; +void vi.mock("@/node/utils/disposableExec", () => ({ + execAsync: vi.fn(), +})); + +// Import the mock after vi.mock +import { execAsync } from "@/node/utils/disposableExec"; + +const mockExecAsync = execAsync as ReturnType; + +describe("CoderService", () => { + let service: CoderService; + + beforeEach(() => { + service = new CoderService(); + vi.clearAllMocks(); + }); + + afterEach(() => { + service.clearCache(); + }); + + describe("getCoderInfo", () => { + it("returns available: true with valid version", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ stdout: JSON.stringify({ version: "2.28.2" }) }), + [Symbol.dispose]: noop, + }); + + const info = await service.getCoderInfo(); + + expect(info).toEqual({ available: true, version: "2.28.2" }); + }); + + it("returns available: true for exact minimum version", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ stdout: JSON.stringify({ version: "2.25.0" }) }), + [Symbol.dispose]: noop, + }); + + const info = await service.getCoderInfo(); + + expect(info).toEqual({ available: true, version: "2.25.0" }); + }); + + it("returns available: false for version below minimum", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ stdout: JSON.stringify({ version: "2.24.9" }) }), + [Symbol.dispose]: noop, + }); + + const info = await service.getCoderInfo(); + + expect(info).toEqual({ available: false }); + }); + + it("handles version with dev suffix", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ stdout: JSON.stringify({ version: "2.28.2-devel+903c045b9" }) }), + [Symbol.dispose]: noop, + }); + + const info = await service.getCoderInfo(); + + expect(info).toEqual({ available: true, version: "2.28.2-devel+903c045b9" }); + }); + + it("returns available: false when CLI not installed", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.reject(new Error("command not found: coder")), + [Symbol.dispose]: noop, + }); + + const info = await service.getCoderInfo(); + + expect(info).toEqual({ available: false }); + }); + + it("caches the result", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ stdout: JSON.stringify({ version: "2.28.2" }) }), + [Symbol.dispose]: noop, + }); + + await service.getCoderInfo(); + await service.getCoderInfo(); + + expect(mockExecAsync).toHaveBeenCalledTimes(1); + }); + }); + + describe("listTemplates", () => { + it("returns templates with display names", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ + stdout: JSON.stringify([ + { name: "template-1", display_name: "Template One", organization_name: "org1" }, + { name: "template-2", display_name: "Template Two" }, + ]), + }), + [Symbol.dispose]: noop, + }); + + const templates = await service.listTemplates(); + + expect(templates).toEqual([ + { name: "template-1", displayName: "Template One", organizationName: "org1" }, + { name: "template-2", displayName: "Template Two", organizationName: "default" }, + ]); + }); + + it("uses name as displayName when display_name not present", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ + stdout: JSON.stringify([{ name: "my-template" }]), + }), + [Symbol.dispose]: noop, + }); + + const templates = await service.listTemplates(); + + expect(templates).toEqual([ + { name: "my-template", displayName: "my-template", organizationName: "default" }, + ]); + }); + + it("returns empty array on error", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.reject(new Error("not logged in")), + [Symbol.dispose]: noop, + }); + + const templates = await service.listTemplates(); + + expect(templates).toEqual([]); + }); + + it("returns empty array for empty output", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ stdout: "" }), + [Symbol.dispose]: noop, + }); + + const templates = await service.listTemplates(); + + expect(templates).toEqual([]); + }); + }); + + describe("listPresets", () => { + it("returns presets for a template", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ + stdout: JSON.stringify([ + { id: "preset-1", name: "Small", description: "Small instance", is_default: true }, + { id: "preset-2", name: "Large", description: "Large instance" }, + ]), + }), + [Symbol.dispose]: noop, + }); + + const presets = await service.listPresets("my-template"); + + expect(presets).toEqual([ + { id: "preset-1", name: "Small", description: "Small instance", isDefault: true }, + { id: "preset-2", name: "Large", description: "Large instance", isDefault: false }, + ]); + }); + + it("returns empty array when template has no presets", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ stdout: "" }), + [Symbol.dispose]: noop, + }); + + const presets = await service.listPresets("no-presets-template"); + + expect(presets).toEqual([]); + }); + + it("returns empty array on error", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.reject(new Error("template not found")), + [Symbol.dispose]: noop, + }); + + const presets = await service.listPresets("nonexistent"); + + expect(presets).toEqual([]); + }); + }); + + describe("listWorkspaces", () => { + it("returns only running workspaces by default", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ + stdout: JSON.stringify([ + { name: "ws-1", template_name: "t1", latest_build: { status: "running" } }, + { name: "ws-2", template_name: "t2", latest_build: { status: "stopped" } }, + { name: "ws-3", template_name: "t3", latest_build: { status: "running" } }, + ]), + }), + [Symbol.dispose]: noop, + }); + + const workspaces = await service.listWorkspaces(); + + expect(workspaces).toEqual([ + { name: "ws-1", templateName: "t1", status: "running" }, + { name: "ws-3", templateName: "t3", status: "running" }, + ]); + }); + + it("returns all workspaces when filterRunning is false", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ + stdout: JSON.stringify([ + { name: "ws-1", template_name: "t1", latest_build: { status: "running" } }, + { name: "ws-2", template_name: "t2", latest_build: { status: "stopped" } }, + ]), + }), + [Symbol.dispose]: noop, + }); + + const workspaces = await service.listWorkspaces(false); + + expect(workspaces).toEqual([ + { name: "ws-1", templateName: "t1", status: "running" }, + { name: "ws-2", templateName: "t2", status: "stopped" }, + ]); + }); + + it("returns empty array on error", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.reject(new Error("not logged in")), + [Symbol.dispose]: noop, + }); + + const workspaces = await service.listWorkspaces(); + + expect(workspaces).toEqual([]); + }); + }); + + describe("createWorkspace", () => { + it("streams stdout/stderr lines and passes expected args", async () => { + const stdout = Readable.from([Buffer.from("out-1\nout-2\n")]); + const stderr = Readable.from([Buffer.from("err-1\n")]); + const events = new EventEmitter(); + + mockSpawn.mockReturnValue({ + stdout, + stderr, + kill: vi.fn(), + on: events.on.bind(events), + } as never); + + // Emit close after handlers are attached. + setTimeout(() => events.emit("close", 0), 0); + + const lines: string[] = []; + for await (const line of service.createWorkspace("my-workspace", "my-template")) { + lines.push(line); + } + + expect(mockSpawn).toHaveBeenCalledWith( + "coder", + ["create", "my-workspace", "-t", "my-template", "--yes"], + { stdio: ["ignore", "pipe", "pipe"] } + ); + + expect(lines.sort()).toEqual(["err-1", "out-1", "out-2"]); + }); + + it("includes --preset when provided", async () => { + const stdout = Readable.from([]); + const stderr = Readable.from([]); + const events = new EventEmitter(); + + mockSpawn.mockReturnValue({ + stdout, + stderr, + kill: vi.fn(), + on: events.on.bind(events), + } as never); + + setTimeout(() => events.emit("close", 0), 0); + + for await (const _line of service.createWorkspace("ws", "tmpl", "preset")) { + // drain + } + + expect(mockSpawn).toHaveBeenCalledWith( + "coder", + ["create", "ws", "-t", "tmpl", "--yes", "--preset", "preset"], + { stdio: ["ignore", "pipe", "pipe"] } + ); + }); + + it("throws when exit code is non-zero", async () => { + const stdout = Readable.from([]); + const stderr = Readable.from([]); + const events = new EventEmitter(); + + mockSpawn.mockReturnValue({ + stdout, + stderr, + kill: vi.fn(), + on: events.on.bind(events), + } as never); + + setTimeout(() => events.emit("close", 42), 0); + + let thrown: unknown; + try { + for await (const _line of service.createWorkspace("ws", "tmpl")) { + // drain + } + } catch (error) { + thrown = error; + } + + expect(thrown).toBeTruthy(); + expect(thrown instanceof Error ? thrown.message : String(thrown)).toContain("exit code 42"); + }); + + it("aborts by killing the child process", async () => { + const stdout = new Readable({ + read() { + // Keep stream open until aborted. + return; + }, + }); + const stderr = new Readable({ + read() { + // Keep stream open until aborted. + return; + }, + }); + const events = new EventEmitter(); + + const kill = vi.fn(() => { + stdout.destroy(); + stderr.destroy(); + events.emit("close", null); + }); + + mockSpawn.mockReturnValue({ + stdout, + stderr, + kill, + on: events.on.bind(events), + } as never); + + const abortController = new AbortController(); + const iterator = service.createWorkspace("ws", "tmpl", undefined, abortController.signal); + + const pending = iterator.next(); + abortController.abort(); + + let thrown: unknown; + try { + await pending; + } catch (error) { + thrown = error; + } + + expect(thrown).toBeTruthy(); + expect(thrown instanceof Error ? thrown.message : String(thrown)).toContain("aborted"); + expect(kill).toHaveBeenCalled(); + }); + }); + + describe("deleteWorkspace", () => { + it("calls coder delete with --yes flag", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ stdout: "", stderr: "" }), + [Symbol.dispose]: noop, + }); + + await service.deleteWorkspace("my-workspace"); + + expect(mockExecAsync).toHaveBeenCalledWith("coder delete 'my-workspace' --yes"); + }); + }); + + describe("ensureSSHConfig", () => { + it("calls coder config-ssh with --yes flag", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ stdout: "", stderr: "" }), + [Symbol.dispose]: noop, + }); + + await service.ensureSSHConfig(); + + expect(mockExecAsync).toHaveBeenCalledWith("coder config-ssh --yes"); + }); + }); +}); + +describe("compareVersions", () => { + it("returns 0 for equal versions", () => { + expect(compareVersions("2.28.6", "2.28.6")).toBe(0); + }); + + it("returns 0 for equal versions with different formats", () => { + expect(compareVersions("v2.28.6", "2.28.6")).toBe(0); + expect(compareVersions("v2.28.6+hash", "2.28.6")).toBe(0); + }); + + it("returns negative when first version is older", () => { + expect(compareVersions("2.25.0", "2.28.6")).toBeLessThan(0); + expect(compareVersions("2.28.5", "2.28.6")).toBeLessThan(0); + expect(compareVersions("1.0.0", "2.0.0")).toBeLessThan(0); + }); + + it("returns positive when first version is newer", () => { + expect(compareVersions("2.28.6", "2.25.0")).toBeGreaterThan(0); + expect(compareVersions("2.28.6", "2.28.5")).toBeGreaterThan(0); + expect(compareVersions("3.0.0", "2.28.6")).toBeGreaterThan(0); + }); + + it("handles versions with v prefix", () => { + expect(compareVersions("v2.28.6", "2.25.0")).toBeGreaterThan(0); + expect(compareVersions("v2.25.0", "v2.28.6")).toBeLessThan(0); + }); + + it("handles dev versions correctly", () => { + // v2.28.2-devel+903c045b9 should be compared as 2.28.2 + expect(compareVersions("v2.28.2-devel+903c045b9", "2.25.0")).toBeGreaterThan(0); + expect(compareVersions("v2.28.2-devel+903c045b9", "2.28.2")).toBe(0); + }); + + it("handles missing patch version", () => { + expect(compareVersions("2.28", "2.28.0")).toBe(0); + expect(compareVersions("2.28", "2.28.1")).toBeLessThan(0); + }); +}); diff --git a/src/node/services/coderService.ts b/src/node/services/coderService.ts new file mode 100644 index 0000000000..4e7bcc42f4 --- /dev/null +++ b/src/node/services/coderService.ts @@ -0,0 +1,361 @@ +/** + * Service for interacting with the Coder CLI. + * Used to create/manage Coder workspaces as SSH targets for Mux workspaces. + */ +import { shescape } from "@/node/runtime/streamUtils"; +import { execAsync } from "@/node/utils/disposableExec"; +import { log } from "@/node/services/log"; +import { spawn } from "child_process"; +import { + CoderWorkspaceStatusSchema, + type CoderInfo, + type CoderTemplate, + type CoderPreset, + type CoderWorkspace, + type CoderWorkspaceStatus, +} from "@/common/orpc/schemas/coder"; + +// Re-export types for consumers that import from this module +export type { CoderInfo, CoderTemplate, CoderPreset, CoderWorkspace, CoderWorkspaceStatus }; + +// Minimum supported Coder CLI version +const MIN_CODER_VERSION = "2.25.0"; + +/** + * Normalize a version string for comparison. + * Strips leading "v", dev suffixes like "-devel+hash", and build metadata. + * Example: "v2.28.6+df47153" → "2.28.6" + */ +function normalizeVersion(v: string): string { + return v + .replace(/^v/i, "") // Strip leading v/V + .split("-")[0] // Remove pre-release suffix + .split("+")[0]; // Remove build metadata +} + +/** + * Compare two semver versions. Returns: + * - negative if a < b + * - 0 if a === b + * - positive if a > b + */ +export function compareVersions(a: string, b: string): number { + const aParts = normalizeVersion(a).split(".").map(Number); + const bParts = normalizeVersion(b).split(".").map(Number); + + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const aPart = aParts[i] ?? 0; + const bPart = bParts[i] ?? 0; + if (aPart !== bPart) return aPart - bPart; + } + return 0; +} + +export class CoderService { + private cachedInfo: CoderInfo | null = null; + + /** + * Get Coder CLI info. Caches result for the session. + * Returns { available: false } if CLI not installed or version too old. + */ + async getCoderInfo(): Promise { + if (this.cachedInfo) { + return this.cachedInfo; + } + + try { + using proc = execAsync("coder version --output=json"); + const { stdout } = await proc.result; + + // Parse JSON output + const data = JSON.parse(stdout) as { version?: string }; + const version = data.version; + + if (!version) { + this.cachedInfo = { available: false }; + return this.cachedInfo; + } + + // Check minimum version + if (compareVersions(version, MIN_CODER_VERSION) < 0) { + log.debug(`Coder CLI version ${version} is below minimum ${MIN_CODER_VERSION}`); + this.cachedInfo = { available: false }; + return this.cachedInfo; + } + + this.cachedInfo = { available: true, version }; + return this.cachedInfo; + } catch (error) { + log.debug("Coder CLI not available", { error }); + this.cachedInfo = { available: false }; + return this.cachedInfo; + } + } + + /** + * Clear cached Coder info. Used for testing. + */ + clearCache(): void { + this.cachedInfo = null; + } + + /** + * List available Coder templates. + */ + async listTemplates(): Promise { + try { + using proc = execAsync("coder templates list --output=json"); + const { stdout } = await proc.result; + + // Handle empty output (no templates) + if (!stdout.trim()) { + return []; + } + + const templates = JSON.parse(stdout) as Array<{ + name: string; + display_name?: string; + organization_name?: string; + }>; + + return templates.map((t) => ({ + name: t.name, + displayName: t.display_name ?? t.name, + organizationName: t.organization_name ?? "default", + })); + } catch (error) { + // Common user state: Coder CLI installed but not configured/logged in. + // Don't spam error logs for UI list calls. + log.debug("Failed to list Coder templates", { error }); + return []; + } + } + + /** + * List presets for a template. + */ + async listPresets(templateName: string): Promise { + try { + using proc = execAsync( + `coder templates presets list ${shescape.quote(templateName)} --output=json` + ); + const { stdout } = await proc.result; + + // Handle empty output (no presets) + if (!stdout.trim()) { + return []; + } + + const presets = JSON.parse(stdout) as Array<{ + id: string; + name: string; + description?: string; + is_default?: boolean; + }>; + + return presets.map((p) => ({ + id: p.id, + name: p.name, + description: p.description, + isDefault: p.is_default ?? false, + })); + } catch (error) { + log.debug("Failed to list Coder presets (may not exist for template)", { + templateName, + error, + }); + return []; + } + } + + /** + * List Coder workspaces. Only returns "running" workspaces by default. + */ + async listWorkspaces(filterRunning = true): Promise { + // Derive known statuses from schema to avoid duplication and prevent ORPC validation errors + const KNOWN_STATUSES = new Set(CoderWorkspaceStatusSchema.options); + + try { + using proc = execAsync("coder list --output=json"); + const { stdout } = await proc.result; + + // Handle empty output (no workspaces) + if (!stdout.trim()) { + return []; + } + + const workspaces = JSON.parse(stdout) as Array<{ + name: string; + template_name: string; + latest_build: { + status: string; + }; + }>; + + // Filter to known statuses first to avoid ORPC schema validation failures + const mapped = workspaces + .filter((w) => KNOWN_STATUSES.has(w.latest_build.status)) + .map((w) => ({ + name: w.name, + templateName: w.template_name, + status: w.latest_build.status as CoderWorkspaceStatus, + })); + + if (filterRunning) { + return mapped.filter((w) => w.status === "running"); + } + return mapped; + } catch (error) { + // Common user state: Coder CLI installed but not configured/logged in. + // Don't spam error logs for UI list calls. + log.debug("Failed to list Coder workspaces", { error }); + return []; + } + } + + /** + * Create a new Coder workspace. Yields build log lines as they arrive. + * Streams stdout and stderr concurrently to avoid blocking on either stream. + * @param name Workspace name + * @param template Template name + * @param preset Optional preset name + * @param abortSignal Optional signal to cancel workspace creation + */ + async *createWorkspace( + name: string, + template: string, + preset?: string, + abortSignal?: AbortSignal + ): AsyncGenerator { + const args = ["create", name, "-t", template, "--yes"]; + if (preset) { + args.push("--preset", preset); + } + + log.debug("Creating Coder workspace", { name, template, preset, args }); + + // Check if already aborted before spawning + if (abortSignal?.aborted) { + throw new Error("Coder workspace creation aborted"); + } + + const child = spawn("coder", args, { + stdio: ["ignore", "pipe", "pipe"], + }); + + // Set up abort handler to kill the child process + let aborted = false; + let sigkillTimer: ReturnType | null = null; + const abortHandler = () => { + aborted = true; + child.kill("SIGTERM"); + // Force kill after 5 seconds if SIGTERM doesn't work + sigkillTimer = setTimeout(() => child.kill("SIGKILL"), 5000); + }; + abortSignal?.addEventListener("abort", abortHandler); + + try { + // Collect lines from both streams into a shared queue + const lines: string[] = []; + let readIndex = 0; // Index-based iteration to avoid O(n²) shift() + let streamsDone = false; + let streamError: Error | null = null; + + const processStream = async (stream: NodeJS.ReadableStream): Promise => { + for await (const chunk of stream) { + for (const line of (chunk as Buffer).toString().split("\n")) { + if (line.trim()) { + lines.push(line); + } + } + } + }; + + // Start both stream processors concurrently (don't await yet) + const stdoutDone = processStream(child.stdout).catch((e: unknown) => { + streamError = streamError ?? (e instanceof Error ? e : new Error(String(e))); + }); + const stderrDone = processStream(child.stderr).catch((e: unknown) => { + streamError = streamError ?? (e instanceof Error ? e : new Error(String(e))); + }); + + // Attach close/error handlers immediately to avoid missing events + // Note: `close` can report exitCode=null when the process is terminated by signal. + const exitPromise = new Promise((resolve, reject) => { + child.on("close", (exitCode) => resolve(exitCode ?? -1)); + child.on("error", reject); + }); + + // Yield lines as they arrive, polling until streams complete + while (!streamsDone) { + // Check for abort + if (aborted) { + throw new Error("Coder workspace creation aborted"); + } + + // Drain any available lines using index-based iteration (O(1) per line vs O(n) for shift) + while (readIndex < lines.length) { + yield lines[readIndex++]; + } + // Compact array periodically to avoid unbounded memory growth + if (readIndex > 500) { + lines.splice(0, readIndex); + readIndex = 0; + } + + // Check if streams are done (non-blocking race) + const bothDone = await Promise.race([ + Promise.all([stdoutDone, stderrDone]).then(() => true), + new Promise((r) => setTimeout(() => r(false), 50)), + ]); + if (bothDone) { + streamsDone = true; + } + } + + // Drain any remaining lines after streams close + while (readIndex < lines.length) { + yield lines[readIndex++]; + } + + if (aborted) { + throw new Error("Coder workspace creation aborted"); + } + + if (streamError !== null) { + const err: Error = streamError; + throw err; + } + + const exitCode = await exitPromise; + if (exitCode !== 0) { + throw new Error(`coder create failed with exit code ${String(exitCode)}`); + } + } finally { + if (sigkillTimer) clearTimeout(sigkillTimer); + abortSignal?.removeEventListener("abort", abortHandler); + } + } + + /** + * Delete a Coder workspace. + */ + async deleteWorkspace(name: string): Promise { + log.debug("Deleting Coder workspace", { name }); + using proc = execAsync(`coder delete ${shescape.quote(name)} --yes`); + await proc.result; + } + + /** + * Ensure SSH config is set up for Coder workspaces. + * Run before every Coder workspace connection (idempotent). + */ + async ensureSSHConfig(): Promise { + log.debug("Ensuring Coder SSH config"); + using proc = execAsync("coder config-ssh --yes"); + await proc.result; + } +} + +// Singleton instance +export const coderService = new CoderService(); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 6ca182b713..f5e055fcc0 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -42,6 +42,8 @@ import { SessionUsageService } from "@/node/services/sessionUsageService"; import { IdleCompactionService } from "@/node/services/idleCompactionService"; import { TaskService } from "@/node/services/taskService"; import { getSigningService, type SigningService } from "@/node/services/signingService"; +import { coderService, type CoderService } from "@/node/services/coderService"; +import { setGlobalCoderService } from "@/node/runtime/runtimeFactory"; /** * ServiceContainer - Central dependency container for all backend services. @@ -76,6 +78,7 @@ export class ServiceContainer { public readonly experimentsService: ExperimentsService; public readonly sessionUsageService: SessionUsageService; public readonly signingService: SigningService; + public readonly coderService: CoderService; private readonly initStateManager: InitStateManager; private readonly extensionMetadata: ExtensionMetadataService; private readonly ptyService: PTYService; @@ -163,6 +166,9 @@ export class ServiceContainer { this.featureFlagService = new FeatureFlagService(config, this.telemetryService); this.sessionTimingService = new SessionTimingService(config, this.telemetryService); this.signingService = getSigningService(); + this.coderService = coderService; + // Register globally so all createRuntime calls can create CoderSSHRuntime + setGlobalCoderService(this.coderService); // Backend timing stats (behind feature flag). this.aiService.on("stream-start", (data: StreamStartEvent) => diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 4fd820fd35..0bd8d3d32e 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -15,7 +15,7 @@ import { readAgentDefinition, discoverAgentDefinitions, } from "@/node/services/agentDefinitions/agentDefinitionsService"; -import { createRuntime } from "@/node/runtime/runtimeFactory"; +import { createRuntime, runBackgroundInit } from "@/node/runtime/runtimeFactory"; import type { InitLogger, WorkspaceCreationResult } from "@/node/runtime/Runtime"; import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation"; import { Ok, Err, type Result } from "@/common/types/result"; @@ -631,22 +631,20 @@ export class TaskService { // Emit metadata update so the UI sees the workspace immediately. await this.emitWorkspaceMetadata(taskId); - // Kick init hook (best-effort, async). + // Kick init (best-effort, async). const secrets = secretsToRecord(this.config.getProjectSecrets(parentMeta.projectPath)); - void runtime - .initWorkspace({ + runBackgroundInit( + runtime, + { projectPath: parentMeta.projectPath, branchName: workspaceName, trunkBranch, workspacePath, initLogger, env: secrets, - }) - .catch((error: unknown) => { - const errorMessage = error instanceof Error ? error.message : String(error); - initLogger.logStderr(`Initialization failed: ${errorMessage}`); - initLogger.logComplete(-1); - }); + }, + taskId + ); // Start immediately (counts towards parallel limit). const sendResult = await this.workspaceService.sendMessage(taskId, prompt, { @@ -1536,20 +1534,18 @@ export class TaskService { trunkBranch, }); const secrets = secretsToRecord(this.config.getProjectSecrets(taskEntry.projectPath)); - void runtime - .initWorkspace({ + runBackgroundInit( + runtime, + { projectPath: taskEntry.projectPath, branchName: workspaceName, trunkBranch, workspacePath, initLogger, env: secrets, - }) - .catch((error: unknown) => { - const errorMessage = error instanceof Error ? error.message : String(error); - initLogger.logStderr(`Initialization failed: ${errorMessage}`); - initLogger.logComplete(-1); - }); + }, + taskId + ); } const model = task.taskModelString ?? defaultModel; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 032a6d6eae..ba201603e2 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -17,7 +17,12 @@ import type { TelemetryService } from "@/node/services/telemetryService"; import type { ExperimentsService } from "@/node/services/experimentsService"; import { EXPERIMENT_IDS, EXPERIMENTS } from "@/common/constants/experiments"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; -import { createRuntime, IncompatibleRuntimeError } from "@/node/runtime/runtimeFactory"; +import { + createRuntime, + IncompatibleRuntimeError, + runBackgroundInit, +} from "@/node/runtime/runtimeFactory"; +import { prepareCoderConfigForCreate } from "@/node/runtime/CoderSSHRuntime"; import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation"; import { getPlanFilePath, getLegacyPlanFilePath } from "@/common/utils/planStorage"; import { shellQuote } from "@/node/runtime/backgroundCommands"; @@ -565,12 +570,20 @@ export class WorkspaceService extends EventEmitter { return Err("Trunk branch is required for worktree and SSH runtimes"); } + // For SSH+Coder, we can't reach the host until postCreateSetup runs (it may not exist yet). + // Skip srcBaseDir resolution; SSH runtime will expand ~ at execution time. + const isCoderSSH = isSSHRuntime(finalRuntimeConfig) && finalRuntimeConfig.coder; + + // NOTE: Coder config preparation (prepareCoderConfigForCreate) is deferred until after + // the collision loop so that it uses the final workspace name (which may have a suffix). + let runtime; try { runtime = createRuntime(finalRuntimeConfig, { projectPath }); - // Resolve srcBaseDir path if the config has one + + // Resolve srcBaseDir path if the config has one (skip for Coder - host doesn't exist yet) const srcBaseDir = getSrcBaseDir(finalRuntimeConfig); - if (srcBaseDir) { + if (srcBaseDir && !isCoderSSH) { const resolvedSrcBaseDir = await runtime.resolvePath(srcBaseDir); if (resolvedSrcBaseDir !== srcBaseDir && hasSrcBaseDir(finalRuntimeConfig)) { finalRuntimeConfig = { @@ -594,6 +607,25 @@ export class WorkspaceService extends EventEmitter { let finalBranchName = branchName; let createResult: { success: boolean; workspacePath?: string; error?: string }; + // For Coder workspaces, check config-level collisions upfront since CoderSSHRuntime.createWorkspace() + // can't detect collisions (the Coder host may not exist yet). This matches other runtimes' behavior + // where collision is detected by the runtime and triggers suffix retry. + if (isCoderSSH) { + const existingNames = new Set( + (this.config.loadConfigOrDefault().projects.get(projectPath)?.workspaces ?? []).map( + (w) => w.name + ) + ); + for ( + let i = 0; + i < MAX_WORKSPACE_NAME_COLLISION_RETRIES && existingNames.has(finalBranchName); + i++ + ) { + log.debug(`Coder workspace name collision for "${finalBranchName}", adding suffix`); + finalBranchName = appendCollisionSuffix(branchName); + } + } + for (let attempt = 0; attempt <= MAX_WORKSPACE_NAME_COLLISION_RETRIES; attempt++) { createResult = await runtime.createWorkspace({ projectPath, @@ -622,6 +654,23 @@ export class WorkspaceService extends EventEmitter { return Err(createResult!.error ?? "Failed to create workspace"); } + // Prepare Coder config AFTER collision handling so it uses the final workspace name. + // This ensures the Coder workspace name matches the mux workspace name (possibly with suffix). + if (isSSHRuntime(finalRuntimeConfig) && finalRuntimeConfig.coder) { + const coderResult = prepareCoderConfigForCreate(finalRuntimeConfig.coder, finalBranchName); + if (!coderResult.success) { + initLogger.logComplete(-1); + return Err(coderResult.error); + } + finalRuntimeConfig = { + ...finalRuntimeConfig, + host: coderResult.host, + coder: coderResult.coder, + }; + // Re-create runtime with updated config (needed for postCreateSetup to have correct host) + runtime = createRuntime(finalRuntimeConfig, { projectPath }); + } + const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; @@ -661,22 +710,22 @@ export class WorkspaceService extends EventEmitter { session.emitMetadata(completeMetadata); + // Background init: run postCreateSetup (if present) then initWorkspace const secrets = secretsToRecord(this.config.getProjectSecrets(projectPath)); - void runtime - .initWorkspace({ + // Background init: postCreateSetup (provisioning) + initWorkspace (sync/checkout/hook) + runBackgroundInit( + runtime, + { projectPath, branchName: finalBranchName, trunkBranch: normalizedTrunkBranch, workspacePath: createResult!.workspacePath, initLogger, env: secrets, - }) - .catch((error: unknown) => { - const errorMsg = error instanceof Error ? error.message : String(error); - log.error(`initWorkspace failed for ${workspaceId}:`, error); - initLogger.logStderr(`Initialization failed: ${errorMsg}`); - initLogger.logComplete(-1); - }); + }, + workspaceId, + log + ); return Ok({ metadata: completeMetadata }); } catch (error) { @@ -736,6 +785,8 @@ export class WorkspaceService extends EventEmitter { ); } + // Note: Coder workspace deletion is handled by CoderSSHRuntime.deleteWorkspace() + // If this workspace is a sub-agent/task, roll its accumulated usage into the parent BEFORE // deleting ~/.mux/sessions//session-usage.json. const parentWorkspaceId = metadata.parentWorkspaceId; @@ -1399,22 +1450,22 @@ export class WorkspaceService extends EventEmitter { return Err(forkResult.error ?? "Failed to fork workspace"); } - // Run init hook for forked workspace (fire-and-forget like create()) + // Run init for forked workspace (fire-and-forget like create()) // Use sourceBranch as trunk since fork is based on source workspace's branch - void runtime - .initWorkspace({ + const secrets = secretsToRecord(this.config.getProjectSecrets(foundProjectPath)); + runBackgroundInit( + runtime, + { projectPath: foundProjectPath, branchName: newName, trunkBranch: forkResult.sourceBranch ?? "main", workspacePath: forkResult.workspacePath!, initLogger, - }) - .catch((error: unknown) => { - const errorMsg = error instanceof Error ? error.message : String(error); - log.error(`initWorkspace failed for forked workspace ${newWorkspaceId}:`, error); - initLogger.logStderr(`Initialization failed: ${errorMsg}`); - initLogger.logComplete(-1); - }); + env: secrets, + }, + newWorkspaceId, + log + ); const sourceSessionDir = this.config.getSessionDir(sourceWorkspaceId); const newSessionDir = this.config.getSessionDir(newWorkspaceId); @@ -1475,6 +1526,14 @@ export class WorkspaceService extends EventEmitter { // Copy plan file if it exists (checks both new and legacy paths) await copyPlanFile(runtime, sourceMetadata.name, sourceWorkspaceId, newName, projectName); + // Apply runtime-provided config updates (e.g., Coder marks shared workspaces) + const forkedRuntimeConfig = forkResult.forkedRuntimeConfig ?? sourceRuntimeConfig; + if (forkResult.sourceRuntimeConfig) { + await this.config.updateWorkspaceMetadata(sourceWorkspaceId, { + runtimeConfig: forkResult.sourceRuntimeConfig, + }); + } + // Compute namedWorkspacePath for frontend metadata const namedWorkspacePath = runtime.getWorkspacePath(foundProjectPath, newName); @@ -1484,7 +1543,7 @@ export class WorkspaceService extends EventEmitter { projectName, projectPath: foundProjectPath, createdAt: new Date().toISOString(), - runtimeConfig: sourceRuntimeConfig, + runtimeConfig: forkedRuntimeConfig, namedWorkspacePath, }; diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index 2f0e094dfc..e6dd3d9b5a 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -104,6 +104,7 @@ export async function createTestEnvironment(): Promise { telemetryService: services.telemetryService, sessionUsageService: services.sessionUsageService, signingService: services.signingService, + coderService: services.coderService, }; const orpc = createOrpcTestClient(orpcContext); diff --git a/tests/runtime/runtime.test.ts b/tests/runtime/runtime.test.ts index c6b954786f..cd0adaa979 100644 --- a/tests/runtime/runtime.test.ts +++ b/tests/runtime/runtime.test.ts @@ -28,6 +28,7 @@ import { createTestRuntime, TestWorkspace, type RuntimeType } from "./test-fixtu import { execBuffered, readFileString, writeFileString } from "@/node/utils/runtime/helpers"; import type { Runtime } from "@/node/runtime/Runtime"; import { RuntimeError } from "@/node/runtime/Runtime"; +import { runFullInit } from "@/node/runtime/runtimeFactory"; // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -1086,9 +1087,10 @@ describeIntegration("Runtime integration tests", () => { ); expect(fileCheck.stdout.trim()).toBe("exists"); - // initWorkspace should be able to run on a forked repo without trying to re-sync. - // (The absence of a .mux/init hook means it will complete immediately.) - const initResult = await runtime.initWorkspace({ + // runFullInit (and thus initWorkspace) should be able to run on a forked repo + // without trying to re-sync. (The absence of a .mux/init hook means it will + // complete immediately.) + const initResult = await runFullInit(runtime, { projectPath, branchName: newWorkspaceName, trunkBranch: "feature", @@ -1222,8 +1224,8 @@ describeIntegration("Runtime integration tests", () => { expect(createResult.success).toBe(true); if (!createResult.success) return; - // createWorkspace only stores container name; initWorkspace actually creates it - const initResult = await runtime.initWorkspace({ + // createWorkspace only stores container name; runFullInit (postCreateSetup + initWorkspace) creates it + const initResult = await runFullInit(runtime, { projectPath, branchName: workspaceName, trunkBranch: "main", @@ -1310,8 +1312,8 @@ describeIntegration("Runtime integration tests", () => { `docker exec ${containerName} bash -c "cd /src && git init -b ${workspaceName} && git config user.email test@test.com && git config user.name Test && echo test > README.md && git add . && git commit -m init"` ); - // Call initWorkspace - should detect running container and skip setup - const initResult = await runtime.initWorkspace({ + // Call runFullInit - postCreateSetup should detect running container and skip setup + const initResult = await runFullInit(runtime, { projectPath, branchName: workspaceName, trunkBranch: "main", @@ -1373,9 +1375,9 @@ describeIntegration("Runtime integration tests", () => { `docker exec ${containerName} bash -c "cd /src && git add . && git commit -m init"` ); - // Call initWorkspace - init hook will fail but init should still succeed + // Call runFullInit - init hook will fail but init should still succeed // (hook failures are non-fatal per docs/hooks/init.mdx) - const initResult = await runtime.initWorkspace({ + const initResult = await runFullInit(runtime, { projectPath, branchName: workspaceName, trunkBranch: "main", @@ -1401,4 +1403,190 @@ describeIntegration("Runtime integration tests", () => { ); }); }); + + /** + * CoderSSHRuntime-specific tests + * + * Tests Coder-specific behavior like fork config updates. + * Uses the same SSH fixture since CoderSSHRuntime extends SSHRuntime. + */ + describe("CoderSSHRuntime workspace operations", () => { + const srcBaseDir = "/home/testuser/src"; + + // Create a CoderSSHRuntime with mock CoderService + const createCoderSSHRuntime = async () => { + const { CoderSSHRuntime } = await import("@/node/runtime/CoderSSHRuntime"); + const { CoderService } = await import("@/node/services/coderService"); + + // Mock CoderService - forkWorkspace doesn't use it, so minimal mock is fine + const mockCoderService = {} as InstanceType; + + return new CoderSSHRuntime( + { + host: `testuser@localhost`, + srcBaseDir, + identityFile: sshConfig!.privateKeyPath, + port: sshConfig!.port, + coder: { + workspaceName: "test-coder-ws", + template: "test-template", + existingWorkspace: false, + }, + }, + mockCoderService + ); + }; + + describe("forkWorkspace", () => { + test.concurrent( + "marks both source and fork with existingWorkspace=true", + async () => { + const runtime = await createCoderSSHRuntime(); + const projectName = `coder-fork-${Date.now()}-${Math.random().toString(36).substring(7)}`; + const projectPath = `/some/path/${projectName}`; + + const sourceWorkspaceName = "source"; + const newWorkspaceName = "forked"; + const sourceWorkspacePath = `${srcBaseDir}/${projectName}/${sourceWorkspaceName}`; + + // Create a source workspace repo + await execBuffered( + runtime, + [ + `mkdir -p "${sourceWorkspacePath}"`, + `cd "${sourceWorkspacePath}"`, + `git init`, + `git config user.email "test@example.com"`, + `git config user.name "Test"`, + `echo "root" > root.txt`, + `git add root.txt`, + `git commit -m "root"`, + ].join(" && "), + { cwd: "/home/testuser", timeout: 30 } + ); + + const initLogger = { + logStep(_message: string) {}, + logStdout(_line: string) {}, + logStderr(_line: string) {}, + logComplete(_exitCode: number) {}, + }; + + const forkResult = await runtime.forkWorkspace({ + projectPath, + sourceWorkspaceName, + newWorkspaceName, + initLogger, + }); + + expect(forkResult.success).toBe(true); + if (!forkResult.success) return; + + // Both configs should have existingWorkspace=true + expect(forkResult.forkedRuntimeConfig).toBeDefined(); + expect(forkResult.sourceRuntimeConfig).toBeDefined(); + + if ( + forkResult.forkedRuntimeConfig?.type === "ssh" && + forkResult.sourceRuntimeConfig?.type === "ssh" + ) { + expect(forkResult.forkedRuntimeConfig.coder?.existingWorkspace).toBe(true); + expect(forkResult.sourceRuntimeConfig.coder?.existingWorkspace).toBe(true); + } else { + throw new Error("Expected SSH runtime configs with coder field"); + } + }, + 60000 + ); + + test.concurrent( + "postCreateSetup after fork does not call coder create", + async () => { + const { CoderSSHRuntime } = await import("@/node/runtime/CoderSSHRuntime"); + const { CoderService } = await import("@/node/services/coderService"); + + // Track whether createWorkspace was called + let createWorkspaceCalled = false; + const mockCoderService = { + createWorkspace: async function* () { + createWorkspaceCalled = true; + yield "should not happen"; + }, + ensureSSHConfig: async () => { + // This SHOULD be called - it's safe and idempotent + }, + } as unknown as InstanceType; + + const runtime = new CoderSSHRuntime( + { + host: `testuser@localhost`, + srcBaseDir, + identityFile: sshConfig!.privateKeyPath, + port: sshConfig!.port, + coder: { + workspaceName: "test-coder-ws", + template: "test-template", + existingWorkspace: false, // Source was mux-created + }, + }, + mockCoderService + ); + + const projectName = `coder-fork-postcreate-${Date.now()}-${Math.random().toString(36).substring(7)}`; + const projectPath = `/some/path/${projectName}`; + const sourceWorkspaceName = "source"; + const newWorkspaceName = "forked"; + const sourceWorkspacePath = `${srcBaseDir}/${projectName}/${sourceWorkspaceName}`; + const forkedWorkspacePath = `${srcBaseDir}/${projectName}/${newWorkspaceName}`; + + // Create a source workspace repo + await execBuffered( + runtime, + [ + `mkdir -p "${sourceWorkspacePath}"`, + `cd "${sourceWorkspacePath}"`, + `git init`, + `git config user.email "test@example.com"`, + `git config user.name "Test"`, + `echo "root" > root.txt`, + `git add root.txt`, + `git commit -m "root"`, + ].join(" && "), + { cwd: "/home/testuser", timeout: 30 } + ); + + const initLogger = { + logStep(_message: string) {}, + logStdout(_line: string) {}, + logStderr(_line: string) {}, + logComplete(_exitCode: number) {}, + }; + + // Fork the workspace + const forkResult = await runtime.forkWorkspace({ + projectPath, + sourceWorkspaceName, + newWorkspaceName, + initLogger, + }); + expect(forkResult.success).toBe(true); + + // Now run postCreateSetup on the SAME runtime instance (simulating what + // workspaceService does after fork - it runs init on the forked workspace) + await runtime.postCreateSetup({ + projectPath, + branchName: newWorkspaceName, + trunkBranch: sourceWorkspaceName, + workspacePath: forkedWorkspacePath, + initLogger, + }); + + // The key assertion: createWorkspace should NOT have been called + // because forkWorkspace() should have set existingWorkspace=true + expect(createWorkspaceCalled).toBe(false); + }, + 60000 + ); + }); + }); }); From d2bca4a4b5cd4b35ffa6bb19bf1f76754b5de848 Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 13 Jan 2026 13:53:39 +1100 Subject: [PATCH 02/28] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20remove=20Coder?= =?UTF-8?q?-specific=20branching=20from=20WorkspaceService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce runtime hooks to customize workspace creation flow: - createFlags.deferredHost: skip srcBaseDir resolution for runtimes where the host does not exist yet (e.g., Coder) - createFlags.configLevelCollisionDetection: use config-based collision check when runtime cannot reach host - finalizeConfig(): derive names and compute host after collision handling - validateBeforePersist(): external validation before persisting metadata CoderSSHRuntime now owns the full name derivation and collision preflight logic, keeping policy local to the runtime. UI improvements: - Tri-state Coder CLI detection: loading (spinner), unavailable, available - Extract CoderCheckbox helper to reduce duplication Add unit tests for CoderSSHRuntime: - finalizeConfig: name derivation, normalization, validation, pass-through - deleteWorkspace: existingWorkspace handling, force flag, error combining --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high` • Cost: `$92.78`_ --- .../components/ChatInput/CoderControls.tsx | 92 +++--- src/browser/components/ChatInput/index.tsx | 4 +- src/node/runtime/CoderSSHRuntime.test.ts | 273 ++++++++++++++++++ src/node/runtime/CoderSSHRuntime.ts | 215 ++++++++------ src/node/runtime/Runtime.ts | 66 +++++ src/node/services/workspaceService.ts | 52 ++-- 6 files changed, 550 insertions(+), 152 deletions(-) create mode 100644 src/node/runtime/CoderSSHRuntime.test.ts diff --git a/src/browser/components/ChatInput/CoderControls.tsx b/src/browser/components/ChatInput/CoderControls.tsx index 1f62645801..bd162f4fc6 100644 --- a/src/browser/components/ChatInput/CoderControls.tsx +++ b/src/browser/components/ChatInput/CoderControls.tsx @@ -19,7 +19,7 @@ export interface CoderControlsProps { enabled: boolean; onEnabledChange: (enabled: boolean) => void; - /** Whether Coder CLI is available */ + /** Coder CLI availability info (null while checking) */ coderInfo: CoderInfo | null; /** Current Coder configuration */ @@ -49,6 +49,29 @@ type CoderMode = "new" | "existing"; * Coder workspace controls component. * Shows checkbox to enable Coder, then New/Existing toggle with appropriate dropdowns. */ +/** Checkbox row with optional status indicator */ +function CoderCheckbox(props: { + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + disabled: boolean; + status?: React.ReactNode; +}) { + return ( + + ); +} + export function CoderControls(props: CoderControlsProps) { const { enabled, @@ -66,29 +89,37 @@ export function CoderControls(props: CoderControlsProps) { hasError, } = props; - // Coder CLI not available - if (!coderInfo?.available) { - // If user previously enabled Coder but CLI is now unavailable, show checkbox so they can disable it - if (enabled) { - return ( -
- -
- ); - } - // Otherwise don't render anything - return null; + // Coder CLI status: loading (null), unavailable (available=false), or available (available=true) + if (coderInfo === null) { + return ( +
+ + + Checking… + + } + /> +
+ ); + } + + if (!coderInfo.available) { + if (!enabled) return null; + return ( +
+ (CLI unavailable)} + /> +
+ ); } const mode: CoderMode = coderConfig?.existingWorkspace ? "existing" : "new"; @@ -144,18 +175,7 @@ export function CoderControls(props: CoderControlsProps) { return (
- {/* Coder checkbox */} - + {/* Coder controls - only shown when enabled */} {enabled && ( diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 89f33cbf1d..1f4a54fb5b 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -611,9 +611,9 @@ const ChatInputInner: React.FC = (props) => { selectedSectionId, onSectionChange: setSelectedSectionId, runtimeFieldError, - // Pass coderProps when CLI is available OR when Coder is enabled (so user can disable) + // Pass coderProps when CLI is available, Coder is enabled, or still checking (so "Checking…" UI renders) coderProps: - coderState.coderInfo?.available || coderState.enabled + coderState.coderInfo?.available || coderState.enabled || coderState.coderInfo === null ? { enabled: coderState.enabled, onEnabledChange: coderState.setEnabled, diff --git a/src/node/runtime/CoderSSHRuntime.test.ts b/src/node/runtime/CoderSSHRuntime.test.ts new file mode 100644 index 0000000000..e12a9f8cba --- /dev/null +++ b/src/node/runtime/CoderSSHRuntime.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it, mock, beforeEach, afterEach, spyOn, type Mock } from "bun:test"; +import { CoderSSHRuntime, type CoderSSHRuntimeConfig } from "./CoderSSHRuntime"; +import { SSHRuntime } from "./SSHRuntime"; +import type { CoderService } from "@/node/services/coderService"; +import type { RuntimeConfig } from "@/common/types/runtime"; + +/** + * Create a minimal mock CoderService for testing. + * Only mocks methods used by the tested code paths. + */ +function createMockCoderService(overrides?: Partial): CoderService { + return { + deleteWorkspace: mock(() => Promise.resolve()), + listWorkspaces: mock(() => Promise.resolve([])), + ...overrides, + } as unknown as CoderService; +} + +/** + * Create a CoderSSHRuntime with minimal config for testing. + */ +function createRuntime( + coderConfig: { existingWorkspace?: boolean; workspaceName?: string; template?: string }, + coderService: CoderService +): CoderSSHRuntime { + const config: CoderSSHRuntimeConfig = { + host: "placeholder.coder", + srcBaseDir: "~/src", + coder: { + existingWorkspace: coderConfig.existingWorkspace ?? false, + workspaceName: coderConfig.workspaceName, + template: coderConfig.template ?? "default-template", + }, + }; + return new CoderSSHRuntime(config, coderService); +} + +/** + * Create an SSH+Coder RuntimeConfig for finalizeConfig tests. + */ +function createSSHCoderConfig(coder: { + existingWorkspace?: boolean; + workspaceName?: string; +}): RuntimeConfig { + return { + type: "ssh", + host: "placeholder.coder", + srcBaseDir: "~/src", + coder: { + existingWorkspace: coder.existingWorkspace ?? false, + workspaceName: coder.workspaceName, + template: "default-template", + }, + }; +} + +// ============================================================================= +// Test Suite 1: finalizeConfig (name/host derivation) +// ============================================================================= + +describe("CoderSSHRuntime.finalizeConfig", () => { + let coderService: CoderService; + let runtime: CoderSSHRuntime; + + beforeEach(() => { + coderService = createMockCoderService(); + runtime = createRuntime({}, coderService); + }); + + describe("new workspace mode", () => { + it("derives Coder name from branch name when not provided", async () => { + const config = createSSHCoderConfig({ existingWorkspace: false }); + const result = await runtime.finalizeConfig("my-feature", config); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("ssh"); + if (result.data.type === "ssh") { + expect(result.data.coder?.workspaceName).toBe("my-feature"); + expect(result.data.host).toBe("my-feature.coder"); + } + } + }); + + it("converts underscores to hyphens", async () => { + const config = createSSHCoderConfig({ existingWorkspace: false }); + const result = await runtime.finalizeConfig("my_feature_branch", config); + + expect(result.success).toBe(true); + if (result.success && result.data.type === "ssh") { + expect(result.data.coder?.workspaceName).toBe("my-feature-branch"); + expect(result.data.host).toBe("my-feature-branch.coder"); + } + }); + + it("collapses multiple hyphens and trims leading/trailing", async () => { + const config = createSSHCoderConfig({ existingWorkspace: false }); + const result = await runtime.finalizeConfig("--my--feature--", config); + + expect(result.success).toBe(true); + if (result.success && result.data.type === "ssh") { + expect(result.data.coder?.workspaceName).toBe("my-feature"); + } + }); + + it("rejects names that fail regex after conversion", async () => { + const config = createSSHCoderConfig({ existingWorkspace: false }); + // Name that becomes empty or invalid after conversion + const result = await runtime.finalizeConfig("---", config); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("cannot be converted to a valid Coder name"); + } + }); + + it("uses provided workspaceName over branch name", async () => { + const config = createSSHCoderConfig({ + existingWorkspace: false, + workspaceName: "custom-name", + }); + const result = await runtime.finalizeConfig("branch-name", config); + + expect(result.success).toBe(true); + if (result.success && result.data.type === "ssh") { + expect(result.data.coder?.workspaceName).toBe("custom-name"); + expect(result.data.host).toBe("custom-name.coder"); + } + }); + }); + + describe("existing workspace mode", () => { + it("requires workspaceName to be provided", async () => { + const config = createSSHCoderConfig({ existingWorkspace: true }); + const result = await runtime.finalizeConfig("branch-name", config); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("required for existing workspaces"); + } + }); + + it("keeps provided workspaceName and sets host", async () => { + const config = createSSHCoderConfig({ + existingWorkspace: true, + workspaceName: "existing-ws", + }); + const result = await runtime.finalizeConfig("branch-name", config); + + expect(result.success).toBe(true); + if (result.success && result.data.type === "ssh") { + expect(result.data.coder?.workspaceName).toBe("existing-ws"); + expect(result.data.host).toBe("existing-ws.coder"); + } + }); + }); + + it("passes through non-SSH configs unchanged", async () => { + const config: RuntimeConfig = { type: "local" }; + const result = await runtime.finalizeConfig("branch", config); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(config); + } + }); + + it("passes through SSH configs without coder unchanged", async () => { + const config: RuntimeConfig = { type: "ssh", host: "example.com", srcBaseDir: "/src" }; + const result = await runtime.finalizeConfig("branch", config); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(config); + } + }); +}); + +// ============================================================================= +// Test Suite 2: deleteWorkspace behavior +// ============================================================================= + +describe("CoderSSHRuntime.deleteWorkspace", () => { + /** + * For deleteWorkspace tests, we mock SSHRuntime.prototype.deleteWorkspace + * to control the parent class behavior. + */ + let sshDeleteSpy: Mock; + + beforeEach(() => { + sshDeleteSpy = spyOn(SSHRuntime.prototype, "deleteWorkspace").mockResolvedValue({ + success: true, + deletedPath: "/path", + }); + }); + + afterEach(() => { + sshDeleteSpy.mockRestore(); + }); + + it("never calls coderService.deleteWorkspace when existingWorkspace=true", async () => { + const deleteWorkspace = mock(() => Promise.resolve()); + const coderService = createMockCoderService({ deleteWorkspace }); + + const runtime = createRuntime( + { existingWorkspace: true, workspaceName: "existing-ws" }, + coderService + ); + + await runtime.deleteWorkspace("/project", "ws", false); + expect(deleteWorkspace).not.toHaveBeenCalled(); + }); + + it("skips Coder deletion when workspaceName is not set", async () => { + const deleteWorkspace = mock(() => Promise.resolve()); + const coderService = createMockCoderService({ deleteWorkspace }); + + // No workspaceName provided + const runtime = createRuntime({ existingWorkspace: false }, coderService); + + const result = await runtime.deleteWorkspace("/project", "ws", false); + expect(deleteWorkspace).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + }); + + it("skips Coder deletion when SSH delete fails and force=false", async () => { + sshDeleteSpy.mockResolvedValue({ success: false, error: "dirty workspace" }); + + const deleteWorkspace = mock(() => Promise.resolve()); + const coderService = createMockCoderService({ deleteWorkspace }); + + const runtime = createRuntime( + { existingWorkspace: false, workspaceName: "my-ws" }, + coderService + ); + + const result = await runtime.deleteWorkspace("/project", "ws", false); + expect(deleteWorkspace).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + }); + + it("calls Coder deletion when SSH delete fails but force=true", async () => { + sshDeleteSpy.mockResolvedValue({ success: false, error: "dirty workspace" }); + + const deleteWorkspace = mock(() => Promise.resolve()); + const coderService = createMockCoderService({ deleteWorkspace }); + + const runtime = createRuntime( + { existingWorkspace: false, workspaceName: "my-ws" }, + coderService + ); + + await runtime.deleteWorkspace("/project", "ws", true); + expect(deleteWorkspace).toHaveBeenCalledWith("my-ws"); + }); + + it("returns combined error when SSH succeeds but Coder delete throws", async () => { + const deleteWorkspace = mock(() => Promise.reject(new Error("Coder API error"))); + const coderService = createMockCoderService({ deleteWorkspace }); + + const runtime = createRuntime( + { existingWorkspace: false, workspaceName: "my-ws" }, + coderService + ); + + const result = await runtime.deleteWorkspace("/project", "ws", false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("SSH delete succeeded"); + expect(result.error).toContain("Coder API error"); + } + }); +}); diff --git a/src/node/runtime/CoderSSHRuntime.ts b/src/node/runtime/CoderSSHRuntime.ts index 746555a349..a63b1332af 100644 --- a/src/node/runtime/CoderSSHRuntime.ts +++ b/src/node/runtime/CoderSSHRuntime.ts @@ -10,6 +10,7 @@ */ import type { + RuntimeCreateFlags, WorkspaceCreationParams, WorkspaceCreationResult, WorkspaceForkParams, @@ -17,8 +18,11 @@ import type { WorkspaceInitParams, } from "./Runtime"; import { SSHRuntime, type SSHRuntimeConfig } from "./SSHRuntime"; -import type { CoderWorkspaceConfig } from "@/common/types/runtime"; +import type { CoderWorkspaceConfig, RuntimeConfig } from "@/common/types/runtime"; +import { isSSHRuntime } from "@/common/types/runtime"; import type { CoderService } from "@/node/services/coderService"; +import type { Result } from "@/common/types/result"; +import { Ok, Err } from "@/common/types/result"; import { log } from "@/node/services/log"; import { execBuffered } from "@/node/utils/runtime/helpers"; import { expandTildeForSSH } from "./tildeExpansion"; @@ -29,6 +33,27 @@ export interface CoderSSHRuntimeConfig extends SSHRuntimeConfig { coder: CoderWorkspaceConfig; } +/** + * Coder workspace name regex: ^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$ + * - Must start with alphanumeric + * - Can contain hyphens, but only between alphanumeric segments + * - No underscores (unlike mux workspace names) + */ +const CODER_NAME_REGEX = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/; + +/** + * Transform a mux workspace name to be Coder-compatible. + * - Replace underscores with hyphens + * - Remove leading/trailing hyphens + * - Collapse multiple consecutive hyphens + */ +function toCoderCompatibleName(name: string): string { + return name + .replace(/_/g, "-") // Replace underscores with hyphens + .replace(/^-+|-+$/g, "") // Remove leading/trailing hyphens + .replace(/-{2,}/g, "-"); // Collapse multiple hyphens +} + /** * SSH runtime that handles Coder workspace provisioning. * @@ -40,6 +65,16 @@ export class CoderSSHRuntime extends SSHRuntime { private coderConfig: CoderWorkspaceConfig; private readonly coderService: CoderService; + /** + * Flags for WorkspaceService to customize create flow: + * - deferredHost: skip srcBaseDir resolution (Coder host doesn't exist yet) + * - configLevelCollisionDetection: use config-based collision check (can't reach host) + */ + readonly createFlags: RuntimeCreateFlags = { + deferredHost: true, + configLevelCollisionDetection: true, + }; + constructor(config: CoderSSHRuntimeConfig, coderService: CoderService) { super({ host: config.host, @@ -52,6 +87,95 @@ export class CoderSSHRuntime extends SSHRuntime { this.coderService = coderService; } + /** + * Finalize runtime config after collision handling. + * Derives Coder workspace name from branch name and computes SSH host. + */ + finalizeConfig( + finalBranchName: string, + config: RuntimeConfig + ): Promise> { + if (!isSSHRuntime(config) || !config.coder) { + return Promise.resolve(Ok(config)); + } + + const coder = config.coder; + let workspaceName = coder.workspaceName?.trim() ?? ""; + + if (!coder.existingWorkspace) { + // New workspace: derive name from mux workspace name if not provided + if (!workspaceName) { + workspaceName = finalBranchName; + } + // Transform to Coder-compatible name (handles underscores, etc.) + workspaceName = toCoderCompatibleName(workspaceName); + + // Validate against Coder's regex + if (!CODER_NAME_REGEX.test(workspaceName)) { + return Promise.resolve( + Err( + `Workspace name "${finalBranchName}" cannot be converted to a valid Coder name. ` + + `Use only letters, numbers, and hyphens.` + ) + ); + } + } else { + // Existing workspace: name must be provided (selected from dropdown) + if (!workspaceName) { + return Promise.resolve(Err("Coder workspace name is required for existing workspaces")); + } + } + + // Final validation + if (!workspaceName) { + return Promise.resolve(Err("Coder workspace name is required")); + } + + return Promise.resolve( + Ok({ + ...config, + host: `${workspaceName}.coder`, + coder: { ...coder, workspaceName }, + }) + ); + } + + /** + * Validate before persisting workspace metadata. + * Checks if a Coder workspace with this name already exists. + */ + async validateBeforePersist( + _finalBranchName: string, + config: RuntimeConfig + ): Promise> { + if (!isSSHRuntime(config) || !config.coder) { + return Ok(undefined); + } + + // Skip for "existing" mode - user explicitly selected an existing workspace + if (config.coder.existingWorkspace) { + return Ok(undefined); + } + + const workspaceName = config.coder.workspaceName; + if (!workspaceName) { + return Ok(undefined); + } + + const existingWorkspaces = await this.coderService.listWorkspaces(false); + const collision = existingWorkspaces.find((w) => w.name === workspaceName); + + if (collision) { + return Err( + `A Coder workspace named "${workspaceName}" already exists. ` + + `Either switch to "Existing" mode to use it, delete/rename it in Coder, ` + + `or choose a different mux workspace name.` + ); + } + + return Ok(undefined); + } + /** * Create workspace (fast path only - no SSH needed). * The Coder workspace may not exist yet, so we can't reach the SSH host. @@ -95,7 +219,7 @@ export class CoderSSHRuntime extends SSHRuntime { return sshResult; } - // workspaceName should always be set after workspace creation (prepareCoderConfigForCreate sets it) + // workspaceName should always be set after workspace creation (finalizeConfig sets it) const coderWorkspaceName = this.coderConfig.workspaceName; if (!coderWorkspaceName) { log.warn("Coder workspace name not set, skipping Coder workspace deletion"); @@ -166,12 +290,10 @@ export class CoderSSHRuntime extends SSHRuntime { // Create Coder workspace if not connecting to an existing one if (!this.coderConfig.existingWorkspace) { - // Validate required fields (workspaceName is set by prepareCoderConfigForCreate before this runs) + // Validate required fields (workspaceName is set by finalizeConfig during workspace creation) const coderWorkspaceName = this.coderConfig.workspaceName; if (!coderWorkspaceName) { - throw new Error( - "Coder workspace name is required (should be set by prepareCoderConfigForCreate)" - ); + throw new Error("Coder workspace name is required (should be set by finalizeConfig)"); } if (!this.coderConfig.template) { throw new Error("Coder template is required for new workspaces"); @@ -225,84 +347,3 @@ export class CoderSSHRuntime extends SSHRuntime { } } } -// ============================================================================ -// Coder RuntimeConfig helpers (called by workspaceService before persistence) -// ============================================================================ - -/** - * Result of preparing a Coder SSH runtime config for workspace creation. - */ -export type PrepareCoderConfigResult = - | { success: true; host: string; coder: CoderWorkspaceConfig } - | { success: false; error: string }; - -/** - * Coder workspace name regex: ^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$ - * - Must start with alphanumeric - * - Can contain hyphens, but only between alphanumeric segments - * - No underscores (unlike mux workspace names) - */ -const CODER_NAME_REGEX = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/; - -/** - * Transform a mux workspace name to be Coder-compatible. - * - Replace underscores with hyphens - * - Remove leading/trailing hyphens - * - Collapse multiple consecutive hyphens - */ -function toCoderCompatibleName(name: string): string { - return name - .replace(/_/g, "-") // Replace underscores with hyphens - .replace(/^-+|-+$/g, "") // Remove leading/trailing hyphens - .replace(/-{2,}/g, "-"); // Collapse multiple hyphens -} - -/** - * Prepare Coder config for workspace creation. - * - * For new workspaces: derives workspaceName from mux workspace name if not set, - * transforming it to be Coder-compatible (no underscores, valid format). - * For existing workspaces: validates workspaceName is present. - * Always normalizes host to `.coder`. - * - * Call this before persisting RuntimeConfig to ensure correct values are stored. - */ -export function prepareCoderConfigForCreate( - coder: CoderWorkspaceConfig, - muxWorkspaceName: string -): PrepareCoderConfigResult { - let workspaceName = coder.workspaceName?.trim() ?? ""; - - if (!coder.existingWorkspace) { - // New workspace: derive name from mux workspace name if not provided - if (!workspaceName) { - workspaceName = muxWorkspaceName; - } - // Transform to Coder-compatible name (handles underscores, etc.) - workspaceName = toCoderCompatibleName(workspaceName); - - // Validate against Coder's regex - if (!CODER_NAME_REGEX.test(workspaceName)) { - return { - success: false, - error: `Workspace name "${muxWorkspaceName}" cannot be converted to a valid Coder name. Use only letters, numbers, and hyphens.`, - }; - } - } else { - // Existing workspace: name must be provided (selected from dropdown) - if (!workspaceName) { - return { success: false, error: "Coder workspace name is required for existing workspaces" }; - } - } - - // Final validation - if (!workspaceName) { - return { success: false, error: "Coder workspace name is required" }; - } - - return { - success: true, - host: `${workspaceName}.coder`, - coder: { ...coder, workspaceName }, - }; -} diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index 266ddfcee7..ad7308e209 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -1,4 +1,5 @@ import type { RuntimeConfig } from "@/common/types/runtime"; +import type { Result } from "@/common/types/result"; /** * Runtime abstraction for executing tools in different environments. @@ -245,6 +246,25 @@ export interface WorkspaceForkResult { sourceRuntimeConfig?: RuntimeConfig; } +/** + * Flags that control workspace creation behavior in WorkspaceService. + * Allows runtimes to customize the create flow without WorkspaceService + * needing runtime-specific conditionals. + */ +export interface RuntimeCreateFlags { + /** + * Skip srcBaseDir resolution before createWorkspace. + * Use when host doesn't exist until postCreateSetup (e.g., Coder). + */ + deferredHost?: boolean; + + /** + * Use config-level collision detection instead of runtime.createWorkspace. + * Use when createWorkspace can't detect existing workspaces (host doesn't exist). + */ + configLevelCollisionDetection?: boolean; +} + /** * Runtime interface - minimal, low-level abstraction for tool execution environments. * @@ -252,6 +272,11 @@ export interface WorkspaceForkResult { * Use helpers in utils/runtime/ for convenience wrappers (e.g., readFileString, execBuffered). */ export interface Runtime { + /** + * Flags that control workspace creation behavior. + * If not provided, defaults to standard behavior (no flags set). + */ + readonly createFlags?: RuntimeCreateFlags; /** * Execute a bash command with streaming I/O * @param command The bash script to execute @@ -359,6 +384,47 @@ export interface Runtime { */ createWorkspace(params: WorkspaceCreationParams): Promise; + /** + * Finalize runtime config after collision handling. + * Called with final branch name (may have collision suffix). + * + * Use cases: + * - Coder: derive workspace name from branch, compute SSH host + * + * @param finalBranchName Branch name after collision handling + * @param config Current runtime config + * @returns Updated runtime config, or error + */ + finalizeConfig?( + finalBranchName: string, + config: RuntimeConfig + ): Promise>; + + /** + * Validate before persisting workspace metadata. + * Called after finalizeConfig, before editConfig. + * May make network calls for external validation. + * + * Use cases: + * - Coder: check if workspace name already exists + * + * IMPORTANT: This hook runs AFTER createWorkspace(). Only implement this if: + * - createWorkspace() is side-effect-free for this runtime, OR + * - The runtime can tolerate/clean up side effects on validation failure + * + * If your runtime's createWorkspace() has side effects (e.g., creates directories) + * and validation failure would leave orphaned resources, consider whether those + * checks belong in createWorkspace() itself instead. + * + * @param finalBranchName Branch name after collision handling + * @param config Finalized runtime config + * @returns Success, or error message + */ + validateBeforePersist?( + finalBranchName: string, + config: RuntimeConfig + ): Promise>; + /** * Optional long-running setup that runs after mux persists workspace metadata. * Used for provisioning steps that must happen before initWorkspace but after diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index ba201603e2..8b54c2d953 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -22,7 +22,6 @@ import { IncompatibleRuntimeError, runBackgroundInit, } from "@/node/runtime/runtimeFactory"; -import { prepareCoderConfigForCreate } from "@/node/runtime/CoderSSHRuntime"; import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation"; import { getPlanFilePath, getLegacyPlanFilePath } from "@/common/utils/planStorage"; import { shellQuote } from "@/node/runtime/backgroundCommands"; @@ -570,20 +569,14 @@ export class WorkspaceService extends EventEmitter { return Err("Trunk branch is required for worktree and SSH runtimes"); } - // For SSH+Coder, we can't reach the host until postCreateSetup runs (it may not exist yet). - // Skip srcBaseDir resolution; SSH runtime will expand ~ at execution time. - const isCoderSSH = isSSHRuntime(finalRuntimeConfig) && finalRuntimeConfig.coder; - - // NOTE: Coder config preparation (prepareCoderConfigForCreate) is deferred until after - // the collision loop so that it uses the final workspace name (which may have a suffix). - let runtime; try { runtime = createRuntime(finalRuntimeConfig, { projectPath }); - // Resolve srcBaseDir path if the config has one (skip for Coder - host doesn't exist yet) + // Resolve srcBaseDir path if the config has one. + // Skip if runtime has deferredHost flag (host doesn't exist yet, e.g., Coder). const srcBaseDir = getSrcBaseDir(finalRuntimeConfig); - if (srcBaseDir && !isCoderSSH) { + if (srcBaseDir && !runtime.createFlags?.deferredHost) { const resolvedSrcBaseDir = await runtime.resolvePath(srcBaseDir); if (resolvedSrcBaseDir !== srcBaseDir && hasSrcBaseDir(finalRuntimeConfig)) { finalRuntimeConfig = { @@ -607,10 +600,9 @@ export class WorkspaceService extends EventEmitter { let finalBranchName = branchName; let createResult: { success: boolean; workspacePath?: string; error?: string }; - // For Coder workspaces, check config-level collisions upfront since CoderSSHRuntime.createWorkspace() - // can't detect collisions (the Coder host may not exist yet). This matches other runtimes' behavior - // where collision is detected by the runtime and triggers suffix retry. - if (isCoderSSH) { + // If runtime uses config-level collision detection (e.g., Coder - can't reach host), + // check against existing workspace names before createWorkspace. + if (runtime.createFlags?.configLevelCollisionDetection) { const existingNames = new Set( (this.config.loadConfigOrDefault().projects.get(projectPath)?.workspaces ?? []).map( (w) => w.name @@ -621,7 +613,7 @@ export class WorkspaceService extends EventEmitter { i < MAX_WORKSPACE_NAME_COLLISION_RETRIES && existingNames.has(finalBranchName); i++ ) { - log.debug(`Coder workspace name collision for "${finalBranchName}", adding suffix`); + log.debug(`Workspace name collision for "${finalBranchName}", adding suffix`); finalBranchName = appendCollisionSuffix(branchName); } } @@ -654,23 +646,29 @@ export class WorkspaceService extends EventEmitter { return Err(createResult!.error ?? "Failed to create workspace"); } - // Prepare Coder config AFTER collision handling so it uses the final workspace name. - // This ensures the Coder workspace name matches the mux workspace name (possibly with suffix). - if (isSSHRuntime(finalRuntimeConfig) && finalRuntimeConfig.coder) { - const coderResult = prepareCoderConfigForCreate(finalRuntimeConfig.coder, finalBranchName); - if (!coderResult.success) { + // Let runtime finalize config (e.g., derive names, compute host) after collision handling + if (runtime.finalizeConfig) { + const finalizeResult = await runtime.finalizeConfig(finalBranchName, finalRuntimeConfig); + if (!finalizeResult.success) { initLogger.logComplete(-1); - return Err(coderResult.error); + return Err(finalizeResult.error); } - finalRuntimeConfig = { - ...finalRuntimeConfig, - host: coderResult.host, - coder: coderResult.coder, - }; - // Re-create runtime with updated config (needed for postCreateSetup to have correct host) + finalRuntimeConfig = finalizeResult.data; runtime = createRuntime(finalRuntimeConfig, { projectPath }); } + // Let runtime validate before persisting (e.g., external collision checks) + if (runtime.validateBeforePersist) { + const validateResult = await runtime.validateBeforePersist( + finalBranchName, + finalRuntimeConfig + ); + if (!validateResult.success) { + initLogger.logComplete(-1); + return Err(validateResult.error); + } + } + const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; From 7de44ab21e7514d128057a225b68aa3b81a98f29 Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 13 Jan 2026 14:11:34 +1100 Subject: [PATCH 03/28] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stabilize=20Coder?= =?UTF-8?q?=20controls=20layout=20during=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add fixed h-7 height to template/preset rows so spinner does not cause vertical shift when dropdown appears - Always show preset dropdown (disabled with "No presets" when empty) instead of hiding it, preventing layout jump --- .../components/ChatInput/CoderControls.tsx | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/src/browser/components/ChatInput/CoderControls.tsx b/src/browser/components/ChatInput/CoderControls.tsx index bd162f4fc6..d91cc9f2df 100644 --- a/src/browser/components/ChatInput/CoderControls.tsx +++ b/src/browser/components/ChatInput/CoderControls.tsx @@ -170,9 +170,6 @@ export function CoderControls(props: CoderControlsProps) { const effectivePreset = presets.length === 0 ? undefined : presets.length === 1 ? presets[0].name : coderConfig?.preset; - // Show preset dropdown only when 2+ presets - const showPresetDropdown = presets.length >= 2; - return (
@@ -237,7 +234,7 @@ export function CoderControls(props: CoderControlsProps) { {/* New workspace controls - template/preset stacked vertically */} {mode === "new" && (
-
+
{loadingTemplates ? ( @@ -258,30 +255,29 @@ export function CoderControls(props: CoderControlsProps) { )}
- {showPresetDropdown && ( -
- - {loadingPresets ? ( - - ) : ( - - )} -
- )} +
+ + {loadingPresets ? ( + + ) : ( + + )} +
)} From 091eab803af5c2dba6139b17d61f9406f0b7ec7d Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 13 Jan 2026 14:15:23 +1100 Subject: [PATCH 04/28] =?UTF-8?q?=F0=9F=A4=96=20fix:=20parse=20Coder=20tem?= =?UTF-8?q?plates=20list=20JSON=20wrapper=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI returns [{Template: {...}}, ...] but we were expecting flat [{...}, ...]. Unwrap the Template field when parsing. --- src/node/services/coderService.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/node/services/coderService.ts b/src/node/services/coderService.ts index 4e7bcc42f4..2fb58a9e9f 100644 --- a/src/node/services/coderService.ts +++ b/src/node/services/coderService.ts @@ -112,16 +112,19 @@ export class CoderService { return []; } - const templates = JSON.parse(stdout) as Array<{ - name: string; - display_name?: string; - organization_name?: string; + // CLI returns [{Template: {...}}, ...] wrapper structure + const raw = JSON.parse(stdout) as Array<{ + Template: { + name: string; + display_name?: string; + organization_name?: string; + }; }>; - return templates.map((t) => ({ - name: t.name, - displayName: t.display_name ?? t.name, - organizationName: t.organization_name ?? "default", + return raw.map((entry) => ({ + name: entry.Template.name, + displayName: entry.Template.display_name ?? entry.Template.name, + organizationName: entry.Template.organization_name ?? "default", })); } catch (error) { // Common user state: Coder CLI installed but not configured/logged in. From c58b0200f2c0f2ad04e5cbc1a2a747837dca385c Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 13 Jan 2026 14:17:18 +1100 Subject: [PATCH 05/28] =?UTF-8?q?=F0=9F=A4=96=20fix:=20parse=20Coder=20pre?= =?UTF-8?q?sets=20list=20JSON=20wrapper=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI returns [{TemplatePreset: {ID, Name, ...}}, ...] with PascalCase fields. Unwrap and map to expected schema. --- src/node/services/coderService.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/node/services/coderService.ts b/src/node/services/coderService.ts index 2fb58a9e9f..7233f70353 100644 --- a/src/node/services/coderService.ts +++ b/src/node/services/coderService.ts @@ -149,18 +149,21 @@ export class CoderService { return []; } - const presets = JSON.parse(stdout) as Array<{ - id: string; - name: string; - description?: string; - is_default?: boolean; + // CLI returns [{TemplatePreset: {ID, Name, ...}}, ...] wrapper structure + const raw = JSON.parse(stdout) as Array<{ + TemplatePreset: { + ID: string; + Name: string; + Description?: string; + Default?: boolean; + }; }>; - return presets.map((p) => ({ - id: p.id, - name: p.name, - description: p.description, - isDefault: p.is_default ?? false, + return raw.map((entry) => ({ + id: entry.TemplatePreset.ID, + name: entry.TemplatePreset.Name, + description: entry.TemplatePreset.Description, + isDefault: entry.TemplatePreset.Default ?? false, })); } catch (error) { log.debug("Failed to list Coder presets (may not exist for template)", { From 9bcff60392e6d24f90becc891b7dfdd630d57558 Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 13 Jan 2026 20:05:46 +1100 Subject: [PATCH 06/28] =?UTF-8?q?=F0=9F=A4=96=20feat:=20runtime=20readines?= =?UTF-8?q?s=20events=20+=20Coder=20sidebar=20icon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add runtime-status events for ensureReady() progress: - New stream event type for runtime startup progress (checking/starting/waiting/ready/error) - CoderSSHRuntime emits status events during workspace start/wait - DockerRuntime returns typed error results (runtime_not_ready vs runtime_start_failed) - Frontend StreamingBarrier shows runtime-specific status text - New runtime_start_failed error type for transient failures (retryable) Add Coder sidebar icon: - CoderIcon placeholder SVG in RuntimeIcons.tsx - RuntimeBadge detects Coder workspaces (SSH + coder config) and shows CoderIcon - Uses existing SSH blue styling, tooltip shows "Coder: {workspaceName}" Other improvements: - workspaceExists() search-based collision check (avoids listing all workspaces) - Refactored coderService process management (graceful termination, runCoderCommand helper) - Updated test fixtures for Coder CLI JSON wrapper structures --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high` • Cost: `$152.30`_ --- .../components/ChatInput/CoderControls.tsx | 17 +- .../Messages/ChatBarrier/StreamingBarrier.tsx | 5 + src/browser/components/RuntimeBadge.tsx | 14 +- src/browser/components/icons/RuntimeIcons.tsx | 21 + src/browser/hooks/useCoderWorkspace.ts | 8 +- src/browser/stores/WorkspaceStore.ts | 5 +- .../messages/StreamingMessageAggregator.ts | 35 ++ ...pplyWorkspaceChatEventToAggregator.test.ts | 5 + .../applyWorkspaceChatEventToAggregator.ts | 10 + .../utils/messages/retryEligibility.ts | 3 +- src/common/orpc/schemas.ts | 1 + src/common/orpc/schemas/errors.ts | 4 +- src/common/orpc/schemas/stream.ts | 17 + src/common/orpc/types.ts | 5 + src/common/types/stream.ts | 10 +- src/common/utils/errors/formatSendError.ts | 5 + src/node/runtime/CoderSSHRuntime.ts | 322 +++++++++- src/node/runtime/DockerRuntime.ts | 24 +- src/node/runtime/LocalBaseRuntime.ts | 3 +- src/node/runtime/RemoteRuntime.ts | 4 +- src/node/runtime/Runtime.ts | 51 +- src/node/services/agentSession.ts | 1 + src/node/services/aiService.ts | 24 +- src/node/services/coderService.test.ts | 65 +- src/node/services/coderService.ts | 586 +++++++++++++++--- src/node/services/utils/sendMessageError.ts | 11 +- tests/runtime/runtime.test.ts | 4 +- 27 files changed, 1136 insertions(+), 124 deletions(-) diff --git a/src/browser/components/ChatInput/CoderControls.tsx b/src/browser/components/ChatInput/CoderControls.tsx index d91cc9f2df..d1f4dc41db 100644 --- a/src/browser/components/ChatInput/CoderControls.tsx +++ b/src/browser/components/ChatInput/CoderControls.tsx @@ -166,9 +166,15 @@ export function CoderControls(props: CoderControlsProps) { }); }; - // Auto-select default preset if 0 or 1 presets (per spec) + // Preset value: hook handles auto-selection, but keep a UI fallback to avoid a brief + // "Select preset" flash while async preset loading + config update races. + const defaultPresetName = presets.find((p) => p.isDefault)?.name; const effectivePreset = - presets.length === 0 ? undefined : presets.length === 1 ? presets[0].name : coderConfig?.preset; + presets.length === 0 + ? undefined + : presets.length === 1 + ? presets[0]?.name + : (coderConfig?.preset ?? defaultPresetName); return (
@@ -233,7 +239,7 @@ export function CoderControls(props: CoderControlsProps) { {/* Right column: Mode-specific controls */} {/* New workspace controls - template/preset stacked vertically */} {mode === "new" && ( -
+
{loadingTemplates ? ( @@ -272,7 +278,6 @@ export function CoderControls(props: CoderControlsProps) { {presets.map((p) => ( ))} @@ -281,9 +286,9 @@ export function CoderControls(props: CoderControlsProps) {
)} - {/* Existing workspace controls */} + {/* Existing workspace controls - min-h matches New mode (2×h-7 + gap-1 + p-2) */} {mode === "existing" && ( -
+
{loadingWorkspaces ? ( diff --git a/src/browser/components/Messages/ChatBarrier/StreamingBarrier.tsx b/src/browser/components/Messages/ChatBarrier/StreamingBarrier.tsx index 873c5aa608..38705f9e10 100644 --- a/src/browser/components/Messages/ChatBarrier/StreamingBarrier.tsx +++ b/src/browser/components/Messages/ChatBarrier/StreamingBarrier.tsx @@ -41,6 +41,7 @@ export const StreamingBarrier: React.FC = ({ workspaceId, currentModel, pendingStreamStartTime, pendingCompactionModel, + runtimeStatus, } = workspaceState; // Determine if we're in "starting" phase (message sent, waiting for stream-start) @@ -89,6 +90,10 @@ export const StreamingBarrier: React.FC = ({ workspaceId, const statusText = (() => { switch (phase) { case "starting": + // Show a runtime-specific message if the workspace is still booting (e.g., Coder/devcontainers). + if (runtimeStatus?.phase === "starting" || runtimeStatus?.phase === "waiting") { + return runtimeStatus.detail ?? "Starting workspace..."; + } return modelName ? `${modelName} starting...` : "starting..."; case "interrupting": return "interrupting..."; diff --git a/src/browser/components/RuntimeBadge.tsx b/src/browser/components/RuntimeBadge.tsx index 1a3dd917c7..2217c2c8e4 100644 --- a/src/browser/components/RuntimeBadge.tsx +++ b/src/browser/components/RuntimeBadge.tsx @@ -10,7 +10,7 @@ import { } from "@/common/types/runtime"; import { extractSshHostname } from "@/browser/utils/ui/runtimeBadge"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; -import { SSHIcon, WorktreeIcon, LocalIcon, DockerIcon } from "./icons/RuntimeIcons"; +import { SSHIcon, WorktreeIcon, LocalIcon, DockerIcon, CoderIcon } from "./icons/RuntimeIcons"; import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; interface RuntimeBadgeProps { @@ -36,6 +36,12 @@ const RUNTIME_STYLES = { working: "bg-[var(--color-runtime-ssh)]/20 text-[var(--color-runtime-ssh-text)] border-[var(--color-runtime-ssh)]/60 animate-pulse", }, + coder: { + // Coder uses SSH styling since it's an SSH-based runtime + idle: "bg-transparent text-muted border-[var(--color-runtime-ssh)]/50", + working: + "bg-[var(--color-runtime-ssh)]/20 text-[var(--color-runtime-ssh-text)] border-[var(--color-runtime-ssh)]/60 animate-pulse", + }, worktree: { idle: "bg-transparent text-muted border-[var(--color-runtime-worktree)]/50", working: @@ -97,6 +103,7 @@ type RuntimeType = keyof typeof RUNTIME_STYLES; const RUNTIME_ICONS: Record = { ssh: SSHIcon, + coder: CoderIcon, worktree: WorktreeIcon, local: LocalIcon, docker: DockerIcon, @@ -106,6 +113,11 @@ function getRuntimeInfo( runtimeConfig?: RuntimeConfig ): { type: RuntimeType; label: string } | null { if (isSSHRuntime(runtimeConfig)) { + // Coder-backed SSH runtime gets special treatment + if (runtimeConfig.coder) { + const coderWorkspaceName = runtimeConfig.coder.workspaceName; + return { type: "coder", label: `Coder: ${coderWorkspaceName ?? runtimeConfig.host}` }; + } const hostname = extractSshHostname(runtimeConfig); return { type: "ssh", label: `SSH: ${hostname ?? runtimeConfig.host}` }; } diff --git a/src/browser/components/icons/RuntimeIcons.tsx b/src/browser/components/icons/RuntimeIcons.tsx index 98ac6b25cd..c16e5c7f7c 100644 --- a/src/browser/components/icons/RuntimeIcons.tsx +++ b/src/browser/components/icons/RuntimeIcons.tsx @@ -74,6 +74,27 @@ export function LocalIcon({ size = 10, className }: IconProps) { ); } +/** Coder logo icon for Coder-backed SSH runtime (placeholder - replace with official logo) */ +export function CoderIcon({ size = 10, className }: IconProps) { + return ( + + {/* Placeholder: simple "C" shape - replace with Coder logo */} + + + ); +} + /** Container icon for Docker runtime */ export function DockerIcon({ size = 10, className }: IconProps) { return ( diff --git a/src/browser/hooks/useCoderWorkspace.ts b/src/browser/hooks/useCoderWorkspace.ts index 776b875084..66698f713d 100644 --- a/src/browser/hooks/useCoderWorkspace.ts +++ b/src/browser/hooks/useCoderWorkspace.ts @@ -212,7 +212,7 @@ export function useCoderWorkspace({ // Presets rules (per spec): // - 0 presets: no dropdown // - 1 preset: auto-select silently - // - 2+ presets: dropdown shown and selection is required (validated in ChatInput) + // - 2+ presets: dropdown shown, auto-select default if exists, otherwise user must pick // Use ref to get current config (avoids stale closure if user changed config during fetch) const currentConfig = coderConfigRef.current; if (currentConfig && !currentConfig.existingWorkspace) { @@ -221,6 +221,12 @@ export function useCoderWorkspace({ if (onlyPreset && currentConfig.preset !== onlyPreset.name) { onCoderConfigChange({ ...currentConfig, preset: onlyPreset.name }); } + } else if (result.length >= 2 && !currentConfig.preset) { + // Auto-select default preset if available (don't override user choice) + const defaultPreset = result.find((p) => p.isDefault); + if (defaultPreset) { + onCoderConfigChange({ ...currentConfig, preset: defaultPreset.name }); + } } else if (result.length === 0 && currentConfig.preset) { onCoderConfigChange({ ...currentConfig, preset: undefined }); } diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index dbf7c665fc..61b91326eb 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -21,7 +21,7 @@ import { isQueuedMessageChanged, isRestoreToInput, } from "@/common/orpc/types"; -import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream"; +import type { StreamEndEvent, StreamAbortEvent, RuntimeStatusEvent } from "@/common/types/stream"; import { MapStore } from "./MapStore"; import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { WorkspaceConsumerManager } from "./WorkspaceConsumerManager"; @@ -56,6 +56,8 @@ export interface WorkspaceState { pendingStreamStartTime: number | null; // Model override from pending compaction request (used during "starting" phase) pendingCompactionModel: string | null; + // Runtime status from ensureReady (for Coder workspace starting UX) + runtimeStatus: RuntimeStatusEvent | null; // Live streaming stats (updated on each stream-delta) streamingTokenCount: number | undefined; streamingTPS: number | undefined; @@ -866,6 +868,7 @@ export class WorkspaceStore { agentStatus: aggregator.getAgentStatus(), pendingStreamStartTime: aggregator.getPendingStreamStartTime(), pendingCompactionModel: aggregator.getPendingCompactionModel(), + runtimeStatus: aggregator.getRuntimeStatus(), streamingTokenCount, streamingTPS, }; diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 5a2f619903..e6a042f961 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -18,6 +18,7 @@ import type { ToolCallEndEvent, ReasoningDeltaEvent, ReasoningEndEvent, + RuntimeStatusEvent, } from "@/common/types/stream"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import type { TodoItem, StatusSetToolResult, NotifyToolResult } from "@/common/types/tools"; @@ -222,6 +223,10 @@ export class StreamingMessageAggregator { // (or the user retries) so retry UI/backoff logic doesn't misfire on send failures. private pendingStreamStartTime: number | null = null; + // Current runtime status (set during ensureReady for Coder workspaces) + // Used to show "Starting Coder workspace..." in StreamingBarrier + private runtimeStatus: RuntimeStatusEvent | null = null; + // Pending compaction request metadata for the next stream (set when user message arrives). // This is used for UI before we receive stream-start (e.g., show compaction model while "starting"). private pendingCompactionRequest: CompactionRequestData | null = null; @@ -661,6 +666,27 @@ export class StreamingMessageAggregator { return this.pendingStreamStartTime; } + /** + * Get the current runtime status (for Coder workspace starting UX). + * Returns null if no runtime status is active. + */ + getRuntimeStatus(): RuntimeStatusEvent | null { + return this.runtimeStatus; + } + + /** + * Handle runtime-status event (emitted during ensureReady for Coder workspaces). + * Used to show "Starting Coder workspace..." in StreamingBarrier. + */ + handleRuntimeStatus(status: RuntimeStatusEvent): void { + // Clear status when ready/error or set new status + if (status.phase === "ready" || status.phase === "error") { + this.runtimeStatus = null; + } else { + this.runtimeStatus = status; + } + } + /** * Get the model override for a pending compaction request (before stream-start). * @@ -981,6 +1007,9 @@ export class StreamingMessageAggregator { // Clear pending stream start timestamp - stream has started this.setPendingStreamStartTime(null); + // Clear runtime status - runtime is ready now that stream has started + this.runtimeStatus = null; + // NOTE: We do NOT clear agentStatus or currentTodos here. // They are cleared when a new user message arrives (see handleMessage), // ensuring consistent behavior whether loading from history or processing live events. @@ -1125,6 +1154,9 @@ export class StreamingMessageAggregator { } // Direct lookup by messageId + + // Clear runtime status (ensureReady is no longer relevant once stream aborts) + this.runtimeStatus = null; const activeStream = this.activeStreams.get(data.messageId); if (activeStream) { @@ -1154,6 +1186,9 @@ export class StreamingMessageAggregator { this.setPendingStreamStartTime(null); // Direct lookup by messageId + + // Clear runtime status - runtime start/ensureReady failed + this.runtimeStatus = null; const activeStream = this.activeStreams.get(data.messageId); if (activeStream) { diff --git a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts index 661541dfc9..9cb10d6998 100644 --- a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts +++ b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts @@ -4,6 +4,7 @@ import type { DeleteMessage, StreamErrorMessage, WorkspaceChatMessage } from "@/ import type { ReasoningDeltaEvent, ReasoningEndEvent, + RuntimeStatusEvent, StreamAbortEvent, StreamDeltaEvent, StreamEndEvent, @@ -74,6 +75,10 @@ class StubAggregator implements WorkspaceChatEventAggregator { this.calls.push(`handleMessage:${data.type}`); } + handleRuntimeStatus(data: RuntimeStatusEvent): void { + this.calls.push(`handleRuntimeStatus:${data.phase}:${data.runtimeType}`); + } + clearTokenState(messageId: string): void { this.calls.push(`clearTokenState:${messageId}`); } diff --git a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts index 74fb27fb6c..e945f3d575 100644 --- a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts +++ b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts @@ -12,6 +12,7 @@ import { isReasoningDelta, isReasoningEnd, isRestoreToInput, + isRuntimeStatus, isStreamAbort, isStreamDelta, isStreamEnd, @@ -33,6 +34,7 @@ import type { ToolCallEndEvent, ToolCallStartEvent, UsageDeltaEvent, + RuntimeStatusEvent, } from "@/common/types/stream"; export type WorkspaceChatEventUpdateHint = "immediate" | "throttled" | "ignored"; @@ -63,6 +65,8 @@ export interface WorkspaceChatEventAggregator { handleMessage(data: WorkspaceChatMessage): void; + handleRuntimeStatus(data: RuntimeStatusEvent): void; + clearTokenState(messageId: string): void; } @@ -147,6 +151,12 @@ export function applyWorkspaceChatEventToAggregator( return "immediate"; } + // runtime-status events are used for Coder workspace starting UX + if (isRuntimeStatus(event)) { + aggregator.handleRuntimeStatus(event); + return "immediate"; + } + // init-* and ChatMuxMessage are handled via the aggregator's unified handleMessage. if (isMuxMessage(event) || isInitStart(event) || isInitOutput(event) || isInitEnd(event)) { aggregator.handleMessage(event); diff --git a/src/browser/utils/messages/retryEligibility.ts b/src/browser/utils/messages/retryEligibility.ts index 0bb1339761..5b466cdd80 100644 --- a/src/browser/utils/messages/retryEligibility.ts +++ b/src/browser/utils/messages/retryEligibility.ts @@ -56,8 +56,9 @@ export function isNonRetryableSendError(error: SendMessageError): boolean { case "incompatible_workspace": // Workspace from newer mux version - user must upgrade case "runtime_not_ready": // Container doesn't exist - user must recreate workspace return true; + case "runtime_start_failed": // Runtime is starting - transient, worth retrying case "unknown": - return false; // Unknown errors might be transient + return false; // Transient errors might resolve on their own } } diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 1a0d1155a4..88a9d89060 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -113,6 +113,7 @@ export { ReasoningDeltaEventSchema, ReasoningEndEventSchema, RestoreToInputEventSchema, + RuntimeStatusEventSchema, SendMessageOptionsSchema, StreamAbortEventSchema, StreamDeltaEventSchema, diff --git a/src/common/orpc/schemas/errors.ts b/src/common/orpc/schemas/errors.ts index de846b5925..840cad0e71 100644 --- a/src/common/orpc/schemas/errors.ts +++ b/src/common/orpc/schemas/errors.ts @@ -11,6 +11,7 @@ export const SendMessageErrorSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("invalid_model_string"), message: z.string() }), z.object({ type: z.literal("incompatible_workspace"), message: z.string() }), z.object({ type: z.literal("runtime_not_ready"), message: z.string() }), + z.object({ type: z.literal("runtime_start_failed"), message: z.string() }), // Transient - retryable z.object({ type: z.literal("unknown"), raw: z.string() }), ]); @@ -29,6 +30,7 @@ export const StreamErrorTypeSchema = z.enum([ "context_exceeded", // Context length/token limit exceeded "quota", // Usage quota/billing limits "model_not_found", // Model does not exist - "runtime_not_ready", // Container/runtime doesn't exist or failed to start + "runtime_not_ready", // Container/runtime doesn't exist or failed to start (permanent) + "runtime_start_failed", // Runtime is starting or temporarily unavailable (retryable) "unknown", // Catch-all ]); diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index a4bc4c1950..2c85327e55 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -10,6 +10,7 @@ import { MuxToolPartSchema, } from "./message"; import { MuxProviderOptionsSchema } from "./providerOptions"; +import { RuntimeModeSchema } from "./runtime"; // Chat Events export const CaughtUpMessageSchema = z.object({ @@ -17,6 +18,20 @@ export const CaughtUpMessageSchema = z.object({ }); /** Sent when a workspace becomes eligible for idle compaction while connected */ + +/** + * Progress event for runtime readiness checks. + * Used by Coder workspaces to show "Starting Coder workspace..." while ensureReady() blocks. + * Not used by Docker (start is near-instant) or local runtimes. + */ +export const RuntimeStatusEventSchema = z.object({ + type: z.literal("runtime-status"), + workspaceId: z.string(), + phase: z.enum(["checking", "starting", "waiting", "ready", "error"]), + runtimeType: RuntimeModeSchema, + detail: z.string().optional(), // Human-readable status like "Starting Coder workspace..." +}); + export const IdleCompactionNeededEventSchema = z.object({ type: z.literal("idle-compaction-needed"), }); @@ -341,6 +356,8 @@ export const WorkspaceChatMessageSchema = z.discriminatedUnion("type", [ RestoreToInputEventSchema, // Idle compaction notification IdleCompactionNeededEventSchema, + // Runtime status events + RuntimeStatusEventSchema, // Init events ...WorkspaceInitEventSchema.def.options, // Chat messages with type discriminator diff --git a/src/common/orpc/types.ts b/src/common/orpc/types.ts index fc3f6230f3..7e1f51a6f0 100644 --- a/src/common/orpc/types.ts +++ b/src/common/orpc/types.ts @@ -13,6 +13,7 @@ import type { ReasoningDeltaEvent, ReasoningEndEvent, UsageDeltaEvent, + RuntimeStatusEvent, } from "@/common/types/stream"; export type BranchListResult = z.infer; @@ -131,3 +132,7 @@ export function isRestoreToInput( ): msg is Extract { return (msg as { type?: string }).type === "restore-to-input"; } + +export function isRuntimeStatus(msg: WorkspaceChatMessage): msg is RuntimeStatusEvent { + return (msg as { type?: string }).type === "runtime-status"; +} diff --git a/src/common/types/stream.ts b/src/common/types/stream.ts index ee35d8d32b..69833235a8 100644 --- a/src/common/types/stream.ts +++ b/src/common/types/stream.ts @@ -17,6 +17,7 @@ import type { ToolCallStartEventSchema, BashOutputEventSchema, UsageDeltaEventSchema, + RuntimeStatusEventSchema, } from "../orpc/schemas"; /** @@ -46,6 +47,12 @@ export type ReasoningEndEvent = z.infer; */ export type UsageDeltaEvent = z.infer; +/** + * Progress event for runtime readiness checks. + * Used by Coder workspaces to show "Starting Coder workspace..." while ensureReady() blocks. + */ +export type RuntimeStatusEvent = z.infer; + export type AIServiceEvent = | StreamStartEvent | StreamDeltaEvent @@ -58,4 +65,5 @@ export type AIServiceEvent = | BashOutputEvent | ReasoningDeltaEvent | ReasoningEndEvent - | UsageDeltaEvent; + | UsageDeltaEvent + | RuntimeStatusEvent; diff --git a/src/common/utils/errors/formatSendError.ts b/src/common/utils/errors/formatSendError.ts index 3b9e117393..4639b43085 100644 --- a/src/common/utils/errors/formatSendError.ts +++ b/src/common/utils/errors/formatSendError.ts @@ -42,6 +42,11 @@ export function formatSendMessageError(error: SendMessageError): FormattedError message: error.message, }; + case "runtime_start_failed": + return { + message: error.message, + }; + case "unknown": return { message: error.raw || "An unexpected error occurred", diff --git a/src/node/runtime/CoderSSHRuntime.ts b/src/node/runtime/CoderSSHRuntime.ts index a63b1332af..1df3b3545f 100644 --- a/src/node/runtime/CoderSSHRuntime.ts +++ b/src/node/runtime/CoderSSHRuntime.ts @@ -16,6 +16,9 @@ import type { WorkspaceForkParams, WorkspaceForkResult, WorkspaceInitParams, + EnsureReadyOptions, + EnsureReadyResult, + RuntimeStatusEvent, } from "./Runtime"; import { SSHRuntime, type SSHRuntimeConfig } from "./SSHRuntime"; import type { CoderWorkspaceConfig, RuntimeConfig } from "@/common/types/runtime"; @@ -65,6 +68,24 @@ export class CoderSSHRuntime extends SSHRuntime { private coderConfig: CoderWorkspaceConfig; private readonly coderService: CoderService; + /** + * Tracks whether the Coder workspace is ready for use. + * - For existing workspaces: true (already exists) + * - For new workspaces: false until postCreateSetup() succeeds + * Used by ensureReady() to return proper status after build failures. + */ + private coderWorkspaceReady: boolean; + + /** + * Timestamp of last time we (a) successfully used the runtime or (b) decided not + * to block the user (unknown Coder CLI error). + * Used to avoid running expensive status checks on every message while still + * catching auto-stopped workspaces after long inactivity. + */ + private lastActivityAtMs = 0; + + private static readonly INACTIVITY_THRESHOLD_MS = 5 * 60 * 1000; + /** * Flags for WorkspaceService to customize create flow: * - deferredHost: skip srcBaseDir resolution (Coder host doesn't exist yet) @@ -85,6 +106,298 @@ export class CoderSSHRuntime extends SSHRuntime { }); this.coderConfig = config.coder; this.coderService = coderService; + // Existing workspaces are already ready; new ones need postCreateSetup() to succeed + this.coderWorkspaceReady = config.coder.existingWorkspace ?? false; + } + + /** Overall timeout for ensureReady operations (start + polling) */ + private static readonly ENSURE_READY_TIMEOUT_MS = 120_000; + + /** Polling interval when waiting for workspace to stop/start */ + private static readonly STATUS_POLL_INTERVAL_MS = 2_000; + + /** In-flight ensureReady promise to avoid duplicate start/wait sequences */ + private ensureReadyPromise: Promise | null = null; + + /** + * Check if runtime is ready for use. + * + * Behavior: + * - If creation failed during postCreateSetup(), fail fast. + * - If workspace is running: return ready. + * - If workspace is stopped: auto-start and wait (blocking, ~120s timeout). + * - If workspace is stopping: poll until stopped, then start. + * - Emits runtime-status events via statusSink for UX feedback. + * + * Concurrency: shares an in-flight promise to avoid duplicate start sequences. + */ + override async ensureReady(options?: EnsureReadyOptions): Promise { + // Fast-fail: workspace never created successfully + if (!this.coderWorkspaceReady) { + return { + ready: false, + error: "Coder workspace does not exist. Check init logs for build errors.", + errorType: "runtime_not_ready", + }; + } + + const workspaceName = this.coderConfig.workspaceName; + if (!workspaceName) { + return { + ready: false, + error: "Coder workspace name not set", + errorType: "runtime_not_ready", + }; + } + + const now = Date.now(); + + // Fast path: recently active, skip expensive status check + if ( + this.lastActivityAtMs !== 0 && + now - this.lastActivityAtMs < CoderSSHRuntime.INACTIVITY_THRESHOLD_MS + ) { + return { ready: true }; + } + + // Avoid duplicate concurrent start/wait sequences + if (this.ensureReadyPromise) { + return this.ensureReadyPromise; + } + + this.ensureReadyPromise = this.doEnsureReady(workspaceName, options); + try { + return await this.ensureReadyPromise; + } finally { + this.ensureReadyPromise = null; + } + } + + /** + * Core ensureReady logic - called once (protected by ensureReadyPromise). + */ + private async doEnsureReady( + workspaceName: string, + options?: EnsureReadyOptions + ): Promise { + const statusSink = options?.statusSink; + const signal = options?.signal; + const startTime = Date.now(); + + const emitStatus = (phase: RuntimeStatusEvent["phase"], detail?: string) => { + statusSink?.({ phase, runtimeType: "ssh", detail }); + }; + + // Helper: check if we've exceeded overall timeout + const isTimedOut = () => Date.now() - startTime > CoderSSHRuntime.ENSURE_READY_TIMEOUT_MS; + const remainingMs = () => + Math.max(0, CoderSSHRuntime.ENSURE_READY_TIMEOUT_MS - (Date.now() - startTime)); + + // Step 1: Check current status + emitStatus("checking"); + + if (signal?.aborted) { + emitStatus("error"); + return { ready: false, error: "Aborted", errorType: "runtime_start_failed" }; + } + + const statusResult = await this.coderService.getWorkspaceStatus(workspaceName, { + timeoutMs: Math.min(remainingMs(), 10_000), + signal, + }); + + if (statusResult.status === "running") { + this.lastActivityAtMs = Date.now(); + emitStatus("ready"); + return { ready: true }; + } + + const isWorkspaceNotFoundError = (error: string | undefined) => + Boolean(error && /workspace not found/i.test(error)); + + if (statusResult.status === null) { + // Fail fast only when we're confident the workspace doesn't exist. + // For other errors (timeout, auth hiccup, Coder CLI issues), proceed optimistically + // and let SSH fail naturally to avoid blocking the happy path. + if (isWorkspaceNotFoundError(statusResult.error)) { + emitStatus("error"); + return { + ready: false, + error: `Coder workspace "${workspaceName}" not found`, + errorType: "runtime_not_ready", + }; + } + + log.debug("Coder workspace status unknown, proceeding optimistically", { + workspaceName, + error: statusResult.error, + }); + this.lastActivityAtMs = Date.now(); + return { ready: true }; + } + + // Step 2: Handle "stopping" status - wait for it to become "stopped" + let currentStatus: string | null = statusResult.status; + + if (currentStatus === "stopping") { + emitStatus("waiting", "Waiting for Coder workspace to stop..."); + + while (currentStatus === "stopping" && !isTimedOut()) { + if (signal?.aborted) { + emitStatus("error"); + return { ready: false, error: "Aborted", errorType: "runtime_start_failed" }; + } + + await this.sleep(CoderSSHRuntime.STATUS_POLL_INTERVAL_MS); + const pollResult = await this.coderService.getWorkspaceStatus(workspaceName, { + timeoutMs: Math.min(remainingMs(), 10_000), + signal, + }); + currentStatus = pollResult.status; + + if (currentStatus === "running") { + this.lastActivityAtMs = Date.now(); + emitStatus("ready"); + return { ready: true }; + } + + // If status became null, only fail fast if the workspace is definitively gone. + // Otherwise fall through to start attempt (status check might have been flaky). + if (currentStatus === null) { + if (isWorkspaceNotFoundError(pollResult.error)) { + emitStatus("error"); + return { + ready: false, + error: `Coder workspace "${workspaceName}" not found`, + errorType: "runtime_not_ready", + }; + } + break; + } + } + + if (isTimedOut()) { + emitStatus("error"); + return { + ready: false, + error: "Coder workspace is still stopping... Please retry shortly.", + errorType: "runtime_start_failed", + }; + } + } + + // Step 3: Start the workspace and wait for it to be ready + emitStatus("starting", "Starting Coder workspace..."); + log.debug("Starting Coder workspace", { workspaceName, currentStatus }); + + const startResult = await this.coderService.startWorkspaceAndWait( + workspaceName, + remainingMs(), + signal + ); + + if (startResult.success) { + this.lastActivityAtMs = Date.now(); + emitStatus("ready"); + return { ready: true }; + } + + if (isWorkspaceNotFoundError(startResult.error)) { + emitStatus("error"); + return { + ready: false, + error: `Coder workspace "${workspaceName}" not found`, + errorType: "runtime_not_ready", + }; + } + + // Handle "build already active" - poll until running or stopped + if (startResult.error === "build_in_progress") { + log.debug("Coder workspace build already active, polling for completion", { workspaceName }); + emitStatus("waiting", "Waiting for Coder workspace build..."); + + while (!isTimedOut()) { + if (signal?.aborted) { + emitStatus("error"); + return { ready: false, error: "Aborted", errorType: "runtime_start_failed" }; + } + + await this.sleep(CoderSSHRuntime.STATUS_POLL_INTERVAL_MS); + const pollResult = await this.coderService.getWorkspaceStatus(workspaceName, { + timeoutMs: Math.min(remainingMs(), 10_000), + signal, + }); + + if (pollResult.status === null && isWorkspaceNotFoundError(pollResult.error)) { + emitStatus("error"); + return { + ready: false, + error: `Coder workspace "${workspaceName}" not found`, + errorType: "runtime_not_ready", + }; + } + + if (pollResult.status === "running") { + this.lastActivityAtMs = Date.now(); + emitStatus("ready"); + return { ready: true }; + } + + if (pollResult.status === "stopped") { + // Build finished but workspace ended up stopped - retry start once + log.debug("Coder workspace stopped after build, retrying start", { workspaceName }); + emitStatus("starting", "Starting Coder workspace..."); + + const retryResult = await this.coderService.startWorkspaceAndWait( + workspaceName, + remainingMs(), + signal + ); + + if (retryResult.success) { + this.lastActivityAtMs = Date.now(); + emitStatus("ready"); + return { ready: true }; + } + + if (isWorkspaceNotFoundError(retryResult.error)) { + emitStatus("error"); + return { + ready: false, + error: `Coder workspace "${workspaceName}" not found`, + errorType: "runtime_not_ready", + }; + } + + emitStatus("error"); + return { + ready: false, + error: `Failed to start Coder workspace: ${retryResult.error ?? "unknown error"}`, + errorType: "runtime_start_failed", + }; + } + } + + emitStatus("error"); + return { + ready: false, + error: "Coder workspace is still starting... Please retry shortly.", + errorType: "runtime_start_failed", + }; + } + + // Other start failure + emitStatus("error"); + return { + ready: false, + error: `Failed to start Coder workspace: ${startResult.error ?? "unknown error"}`, + errorType: "runtime_start_failed", + }; + } + + /** Promise-based sleep helper */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); } /** @@ -162,10 +475,9 @@ export class CoderSSHRuntime extends SSHRuntime { return Ok(undefined); } - const existingWorkspaces = await this.coderService.listWorkspaces(false); - const collision = existingWorkspaces.find((w) => w.name === workspaceName); + const exists = await this.coderService.workspaceExists(workspaceName); - if (collision) { + if (exists) { return Err( `A Coder workspace named "${workspaceName}" already exists. ` + `Either switch to "Existing" mode to use it, delete/rename it in Coder, ` + @@ -345,5 +657,9 @@ export class CoderSSHRuntime extends SSHRuntime { initLogger.logStderr(`Failed to prepare workspace directory: ${errorMsg}`); throw new Error(`Failed to prepare workspace directory: ${errorMsg}`); } + + // Mark workspace as ready only after all setup succeeds + this.coderWorkspaceReady = true; + this.lastActivityAtMs = Date.now(); } } diff --git a/src/node/runtime/DockerRuntime.ts b/src/node/runtime/DockerRuntime.ts index fa30b69ac5..c46f83a83f 100644 --- a/src/node/runtime/DockerRuntime.ts +++ b/src/node/runtime/DockerRuntime.ts @@ -26,6 +26,7 @@ import type { WorkspaceForkParams, WorkspaceForkResult, InitLogger, + EnsureReadyResult, } from "./Runtime"; import { RuntimeError } from "./Runtime"; import { RemoteRuntime, type SpawnResult } from "./RemoteRuntime"; @@ -1126,15 +1127,32 @@ export class DockerRuntime extends RemoteRuntime { * Ensure the Docker container is running. * `docker start` is idempotent - succeeds if already running, starts if stopped, * and waits if container is in a transitional state (starting/restarting). + * + * Returns typed error for retry decisions: + * - runtime_not_ready: container missing or permanent failure + * - runtime_start_failed: transient failure (daemon issue, etc.) */ - override async ensureReady(): Promise<{ ready: boolean; error?: string }> { + override async ensureReady(): Promise { if (!this.containerName) { - return { ready: false, error: "Container name not set" }; + return { + ready: false, + error: "Container name not set", + errorType: "runtime_not_ready", + }; } const result = await runDockerCommand(`docker start ${this.containerName}`, 30000); if (result.exitCode !== 0) { - return { ready: false, error: result.stderr || "Failed to start container" }; + const stderr = result.stderr || "Failed to start container"; + + // Classify error type based on stderr content + const isContainerMissing = stderr.includes("No such container") || stderr.includes("not found"); + + return { + ready: false, + error: stderr, + errorType: isContainerMissing ? "runtime_not_ready" : "runtime_start_failed", + }; } // Detect container user info if not already set (e.g., runtime recreated for existing workspace) diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index cdaf4c8915..763b8613c3 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -16,6 +16,7 @@ import type { WorkspaceForkParams, WorkspaceForkResult, InitLogger, + EnsureReadyResult, } from "./Runtime"; import { RuntimeError as RuntimeErrorClass } from "./Runtime"; import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env"; @@ -383,7 +384,7 @@ export abstract class LocalBaseRuntime implements Runtime { /** * Local runtimes are always ready. */ - ensureReady(): Promise<{ ready: boolean; error?: string }> { + ensureReady(): Promise { return Promise.resolve({ ready: true }); } diff --git a/src/node/runtime/RemoteRuntime.ts b/src/node/runtime/RemoteRuntime.ts index 6a57672b39..a7708e09b6 100644 --- a/src/node/runtime/RemoteRuntime.ts +++ b/src/node/runtime/RemoteRuntime.ts @@ -27,6 +27,7 @@ import type { WorkspaceInitResult, WorkspaceForkParams, WorkspaceForkResult, + EnsureReadyResult, } from "./Runtime"; import { RuntimeError } from "./Runtime"; import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes"; @@ -479,8 +480,9 @@ export abstract class RemoteRuntime implements Runtime { /** * Remote runtimes are always ready (SSH connections are re-established as needed). + * Subclasses (CoderSSHRuntime, DockerRuntime) may override for provisioning checks. */ - ensureReady(): Promise<{ ready: boolean; error?: string }> { + ensureReady(): Promise { return Promise.resolve({ ready: true }); } diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index ad7308e209..d59f32cada 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -1,4 +1,5 @@ import type { RuntimeConfig } from "@/common/types/runtime"; +import type { RuntimeStatusEvent as StreamRuntimeStatusEvent } from "@/common/types/stream"; import type { Result } from "@/common/types/result"; /** @@ -265,6 +266,48 @@ export interface RuntimeCreateFlags { configLevelCollisionDetection?: boolean; } +/** + * Runtime status update payload for ensureReady progress. + * + * Derived from the stream schema type to keep phase/runtimeType/detail consistent + * across backend + frontend. + */ +export type RuntimeStatusEvent = Pick; + +/** + * Callback for runtime status updates during ensureReady(). + */ +export type RuntimeStatusSink = (status: RuntimeStatusEvent) => void; + +/** + * Options for ensureReady(). + */ +export interface EnsureReadyOptions { + /** + * Callback to emit runtime-status events for UX feedback. + * Coder uses this to show "Starting Coder workspace..." during boot. + */ + statusSink?: RuntimeStatusSink; + + /** + * Abort signal to cancel long-running operations. + */ + signal?: AbortSignal; +} + +/** + * Result of ensureReady(). + * Distinguishes between permanent failures (runtime_not_ready) and + * transient failures (runtime_start_failed) for retry logic. + */ +export type EnsureReadyResult = + | { ready: true } + | { + ready: false; + error: string; + errorType: "runtime_not_ready" | "runtime_start_failed"; + }; + /** * Runtime interface - minimal, low-level abstraction for tool execution environments. * @@ -498,10 +541,14 @@ export interface Runtime { * - LocalRuntime: Always returns ready (no-op) * - DockerRuntime: Starts container if stopped * - SSHRuntime: Could verify connection (future) + * - CoderSSHRuntime: Checks workspace status, starts if stopped, waits for ready + * + * Called automatically by AIService before streaming. * - * Called automatically by executeBash handler before first operation. + * @param options Optional config: statusSink for progress events, signal for cancellation + * @returns Result indicating ready or failure with error type for retry decisions */ - ensureReady(): Promise<{ ready: boolean; error?: string }>; + ensureReady(options?: EnsureReadyOptions): Promise; /** * Fork an existing workspace to create a new one diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 7b22ee634d..1ce4f2d513 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -793,6 +793,7 @@ export class AgentSession { forward("reasoning-end", (payload) => this.emitChatEvent(payload)); forward("usage-delta", (payload) => this.emitChatEvent(payload)); forward("stream-abort", (payload) => this.emitChatEvent(payload)); + forward("runtime-status", (payload) => this.emitChatEvent(payload)); forward("stream-end", async (payload) => { const handled = await this.compactionHandler.handleCompletion(payload as StreamEndEvent); diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index f90a3386d8..a11852e1e4 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1166,14 +1166,30 @@ export class AIService extends EventEmitter { // Verify runtime is actually reachable after init completes. // For Docker workspaces, this checks the container exists and starts it if stopped. + // For Coder workspaces, this may start a stopped workspace and wait for it. // If init failed during container creation, ensureReady() will return an error. - const readyResult = await runtime.ensureReady(); + const readyResult = await runtime.ensureReady({ + signal: abortSignal, + statusSink: (status) => { + // Emit runtime-status events for frontend UX (StreamingBarrier) + this.emit("runtime-status", { + type: "runtime-status", + workspaceId, + phase: status.phase, + runtimeType: status.runtimeType, + detail: status.detail, + }); + }, + }); if (!readyResult.ready) { // Generate message ID for the error event (frontend needs this for synthetic message) const errorMessageId = `assistant-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; const runtimeType = metadata.runtimeConfig?.type ?? "local"; const runtimeLabel = runtimeType === "docker" ? "Container" : "Runtime"; - const errorMessage = `${runtimeLabel} unavailable.`; + const errorMessage = readyResult.error || `${runtimeLabel} unavailable.`; + + // Use the errorType from ensureReady result (runtime_not_ready vs runtime_start_failed) + const errorType = readyResult.errorType; // Emit error event so frontend receives it via stream subscription. // This mirrors the context_exceeded pattern - the fire-and-forget sendMessage @@ -1184,11 +1200,11 @@ export class AIService extends EventEmitter { workspaceId, messageId: errorMessageId, error: errorMessage, - errorType: "runtime_not_ready", + errorType, }); return Err({ - type: "runtime_not_ready", + type: errorType, message: errorMessage, }); } diff --git a/src/node/services/coderService.test.ts b/src/node/services/coderService.test.ts index a2d2163f7c..c535906a12 100644 --- a/src/node/services/coderService.test.ts +++ b/src/node/services/coderService.test.ts @@ -111,8 +111,14 @@ describe("CoderService", () => { mockExecAsync.mockReturnValue({ result: Promise.resolve({ stdout: JSON.stringify([ - { name: "template-1", display_name: "Template One", organization_name: "org1" }, - { name: "template-2", display_name: "Template Two" }, + { + Template: { + name: "template-1", + display_name: "Template One", + organization_name: "org1", + }, + }, + { Template: { name: "template-2", display_name: "Template Two" } }, ]), }), [Symbol.dispose]: noop, @@ -129,7 +135,7 @@ describe("CoderService", () => { it("uses name as displayName when display_name not present", async () => { mockExecAsync.mockReturnValue({ result: Promise.resolve({ - stdout: JSON.stringify([{ name: "my-template" }]), + stdout: JSON.stringify([{ Template: { name: "my-template" } }]), }), [Symbol.dispose]: noop, }); @@ -169,8 +175,21 @@ describe("CoderService", () => { mockExecAsync.mockReturnValue({ result: Promise.resolve({ stdout: JSON.stringify([ - { id: "preset-1", name: "Small", description: "Small instance", is_default: true }, - { id: "preset-2", name: "Large", description: "Large instance" }, + { + TemplatePreset: { + ID: "preset-1", + Name: "Small", + Description: "Small instance", + Default: true, + }, + }, + { + TemplatePreset: { + ID: "preset-2", + Name: "Large", + Description: "Large instance", + }, + }, ]), }), [Symbol.dispose]: noop, @@ -259,6 +278,42 @@ describe("CoderService", () => { }); }); + describe("workspaceExists", () => { + it("returns true when exact match is found in search results", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ stdout: JSON.stringify([{ name: "ws-1" }, { name: "ws-10" }]) }), + [Symbol.dispose]: noop, + }); + + const exists = await service.workspaceExists("ws-1"); + + expect(exists).toBe(true); + expect(mockExecAsync).toHaveBeenCalledWith("coder list --search 'name:ws-1' --output=json"); + }); + + it("returns false when only prefix matches", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ stdout: JSON.stringify([{ name: "ws-10" }]) }), + [Symbol.dispose]: noop, + }); + + const exists = await service.workspaceExists("ws-1"); + + expect(exists).toBe(false); + }); + + it("returns false on CLI error", async () => { + mockExecAsync.mockReturnValue({ + result: Promise.reject(new Error("not logged in")), + [Symbol.dispose]: noop, + }); + + const exists = await service.workspaceExists("ws-1"); + + expect(exists).toBe(false); + }); + }); + describe("createWorkspace", () => { it("streams stdout/stderr lines and passes expected args", async () => { const stdout = Readable.from([Buffer.from("out-1\nout-2\n")]); diff --git a/src/node/services/coderService.ts b/src/node/services/coderService.ts index 7233f70353..c1818c0f90 100644 --- a/src/node/services/coderService.ts +++ b/src/node/services/coderService.ts @@ -5,7 +5,7 @@ import { shescape } from "@/node/runtime/streamUtils"; import { execAsync } from "@/node/utils/disposableExec"; import { log } from "@/node/services/log"; -import { spawn } from "child_process"; +import { spawn, type ChildProcess } from "child_process"; import { CoderWorkspaceStatusSchema, type CoderInfo, @@ -51,6 +51,81 @@ export function compareVersions(a: string, b: string): number { return 0; } +const SIGKILL_GRACE_PERIOD_MS = 5000; + +function createGracefulTerminator( + child: ChildProcess, + options?: { sigkillAfterMs?: number } +): { + terminate: () => void; + cleanup: () => void; +} { + const sigkillAfterMs = options?.sigkillAfterMs ?? SIGKILL_GRACE_PERIOD_MS; + let sigkillTimer: ReturnType | null = null; + + const scheduleSigkill = () => { + if (sigkillTimer) return; + sigkillTimer = setTimeout(() => { + sigkillTimer = null; + // Only attempt SIGKILL if the process still appears to be running. + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill("SIGKILL"); + } catch { + // ignore + } + } + }, sigkillAfterMs); + }; + + const terminate = () => { + try { + child.kill("SIGTERM"); + } catch { + // ignore + } + scheduleSigkill(); + }; + + const cleanup = () => { + if (sigkillTimer) { + clearTimeout(sigkillTimer); + sigkillTimer = null; + } + }; + + return { terminate, cleanup }; +} + +interface CoderCommandResult { + exitCode: number | null; + stdout: string; + stderr: string; + error?: "timeout" | "aborted"; +} + +type InterpretedCoderCommandResult = + | { ok: true; stdout: string; stderr: string } + | { ok: false; error: string; combined: string }; + +function interpretCoderResult(result: CoderCommandResult): InterpretedCoderCommandResult { + const combined = `${result.stderr}\n${result.stdout}`.trim(); + + if (result.error) { + return { ok: false, error: result.error, combined }; + } + + if (result.exitCode !== 0) { + return { + ok: false, + error: combined || `Exit code ${String(result.exitCode)}`, + combined, + }; + } + + return { ok: true, stdout: result.stdout, stderr: result.stderr }; +} + export class CoderService { private cachedInfo: CoderInfo | null = null; @@ -174,6 +249,33 @@ export class CoderService { } } + /** + * Check if a Coder workspace exists by name. + * + * Uses `coder list --search name:` so we don't have to fetch all workspaces. + * Note: Coder's `--search` is prefix-based server-side, so we must exact-match locally. + */ + async workspaceExists(workspaceName: string): Promise { + try { + using proc = execAsync( + `coder list --search ${shescape.quote(`name:${workspaceName}`)} --output=json` + ); + const { stdout } = await proc.result; + + if (!stdout.trim()) { + return false; + } + + const workspaces = JSON.parse(stdout) as Array<{ name: string }>; + return workspaces.some((w) => w.name === workspaceName); + } catch (error) { + // Best-effort: if Coder isn't configured/logged in, treat as "doesn't exist" so we + // don't block creation (later steps will fail with a more actionable error). + log.debug("Failed to check if Coder workspace exists", { workspaceName, error }); + return false; + } + } + /** * List Coder workspaces. Only returns "running" workspaces by default. */ @@ -219,6 +321,193 @@ export class CoderService { } } + /** + * Run a `coder` CLI command with timeout + optional cancellation. + * + * We use spawn (not execAsync) so ensureReady() can't hang forever on a stuck + * Coder CLI invocation. + */ + private runCoderCommand( + args: string[], + options: { timeoutMs: number; signal?: AbortSignal } + ): Promise { + return new Promise((resolve) => { + if (options.timeoutMs <= 0) { + resolve({ exitCode: null, stdout: "", stderr: "", error: "timeout" }); + return; + } + + if (options.signal?.aborted) { + resolve({ exitCode: null, stdout: "", stderr: "", error: "aborted" }); + return; + } + + const child = spawn("coder", args, { + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let resolved = false; + + let timeoutTimer: ReturnType | null = null; + + const terminator = createGracefulTerminator(child); + + const resolveOnce = (result: CoderCommandResult) => { + if (resolved) return; + resolved = true; + resolve(result); + }; + + const cleanup = (cleanupOptions?: { keepSigkillTimer?: boolean }) => { + if (timeoutTimer) { + clearTimeout(timeoutTimer); + timeoutTimer = null; + } + if (!cleanupOptions?.keepSigkillTimer) { + terminator.cleanup(); + } + child.removeListener("close", onClose); + child.removeListener("error", onError); + options.signal?.removeEventListener("abort", onAbort); + }; + + function onAbort() { + terminator.terminate(); + // Keep SIGKILL escalation alive if SIGTERM doesn't work. + cleanup({ keepSigkillTimer: true }); + resolveOnce({ exitCode: null, stdout, stderr, error: "aborted" }); + } + + function onError() { + cleanup(); + resolveOnce({ exitCode: null, stdout, stderr }); + } + + function onClose(code: number | null) { + cleanup(); + resolveOnce({ exitCode: code, stdout, stderr }); + } + + child.stdout?.on("data", (chunk) => { + stdout += String(chunk); + }); + + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + + child.on("error", onError); + child.on("close", onClose); + + timeoutTimer = setTimeout(() => { + terminator.terminate(); + + // Keep SIGKILL escalation alive if SIGTERM doesn't work. + // We still remove the abort listener to avoid leaking it beyond the call. + options.signal?.removeEventListener("abort", onAbort); + + resolveOnce({ exitCode: null, stdout, stderr, error: "timeout" }); + }, options.timeoutMs); + + options.signal?.addEventListener("abort", onAbort); + }); + } + + /** + * Get workspace status using control-plane query. + * + * Note: `coder list --search 'name:X'` is prefix-based on the server, + * so we must exact-match the workspace name client-side. + */ + async getWorkspaceStatus( + workspaceName: string, + options?: { timeoutMs?: number; signal?: AbortSignal } + ): Promise<{ status: CoderWorkspaceStatus | null; error?: string }> { + const timeoutMs = options?.timeoutMs ?? 10_000; + + try { + const result = await this.runCoderCommand( + ["list", "--search", `name:${workspaceName}`, "--output", "json"], + { timeoutMs, signal: options?.signal } + ); + + const interpreted = interpretCoderResult(result); + if (!interpreted.ok) { + return { status: null, error: interpreted.error }; + } + + if (!interpreted.stdout.trim()) { + return { status: null, error: "Workspace not found" }; + } + + const workspaces = JSON.parse(interpreted.stdout) as Array<{ + name: string; + latest_build: { status: string }; + }>; + + // Exact match required (search is prefix-based) + const match = workspaces.find((w) => w.name === workspaceName); + if (!match) { + return { status: null, error: "Workspace not found" }; + } + + // Validate status against known schema values + const status = match.latest_build.status; + const parsed = CoderWorkspaceStatusSchema.safeParse(status); + if (!parsed.success) { + log.warn("Unknown Coder workspace status", { workspaceName, status }); + return { status: null, error: `Unknown status: ${status}` }; + } + + return { status: parsed.data }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.debug("Failed to get Coder workspace status", { workspaceName, error: message }); + return { status: null, error: message }; + } + } + + /** + * Start a workspace and wait for it to be ready. + * Blocks until the workspace is running (or timeout). + * + * @param workspaceName Workspace name + * @param timeoutMs Maximum time to wait + * @param signal Optional abort signal to cancel + * @returns Object with success/error info + */ + async startWorkspaceAndWait( + workspaceName: string, + timeoutMs: number, + signal?: AbortSignal + ): Promise<{ success: boolean; error?: string }> { + const result = await this.runCoderCommand(["start", "-y", workspaceName], { + timeoutMs, + signal, + }); + + const interpreted = interpretCoderResult(result); + + if (interpreted.ok) { + return { success: true }; + } + + if (interpreted.error === "aborted" || interpreted.error === "timeout") { + return { success: false, error: interpreted.error }; + } + + if (interpreted.combined.includes("workspace build is already active")) { + return { success: false, error: "build_in_progress" }; + } + + return { + success: false, + error: interpreted.error, + }; + } + /** * Create a new Coder workspace. Yields build log lines as they arrive. * Streams stdout and stderr concurrently to avoid blocking on either stream. @@ -233,134 +522,251 @@ export class CoderService { preset?: string, abortSignal?: AbortSignal ): AsyncGenerator { + // TEMPORARY: Use workaround for parameter prompts. + // Delete the workaround section below when `coder create --yes` no longer prompts + // for parameters that have defaults. + yield* this._createWorkspaceWithParamWorkaround(name, template, preset, abortSignal); + } + + /** + * Delete a Coder workspace. + */ + async deleteWorkspace(name: string): Promise { + log.debug("Deleting Coder workspace", { name }); + using proc = execAsync(`coder delete ${shescape.quote(name)} --yes`); + await proc.result; + } + + /** + * Ensure SSH config is set up for Coder workspaces. + * Run before every Coder workspace connection (idempotent). + */ + async ensureSSHConfig(): Promise { + log.debug("Ensuring Coder SSH config"); + using proc = execAsync("coder config-ssh --yes"); + await proc.result; + } + + // ============================================================================ + // TEMPORARY WORKAROUND: Parameter prompt retry logic + // Delete this entire section when `coder create --yes` properly handles + // parameters with defaults. Then update createWorkspace to run `coder create` + // directly (without prompt-detection retries). + // ============================================================================ + + private async *_createWorkspaceWithParamWorkaround( + name: string, + template: string, + preset: string | undefined, + abortSignal: AbortSignal | undefined + ): AsyncGenerator { + const MAX_PARAM_RETRIES = 20; + const PROMPT_TIMEOUT_MS = 5000; + + const collectedParams: Array<{ name: string; value: string }> = []; + + for (let attempt = 0; attempt < MAX_PARAM_RETRIES; attempt++) { + log.debug("Creating Coder workspace (workaround attempt)", { + name, + template, + preset, + attempt, + collectedParams, + }); + + if (abortSignal?.aborted) { + throw new Error("Coder workspace creation aborted"); + } + + const result = await this._tryCreateDetectingPrompts( + name, + template, + preset, + collectedParams, + PROMPT_TIMEOUT_MS, + abortSignal + ); + + if (result.type === "success") { + for (const line of result.lines) { + yield line; + } + return; + } + + if (result.type === "prompt") { + log.debug("Detected parameter prompt, will retry", { param: result.param, attempt }); + // Yield collected lines so user sees progress + for (const line of result.lines) { + yield line; + } + yield `[Detected parameter prompt "${result.param.name}", retrying with default "${result.param.value}"...]`; + collectedParams.push(result.param); + continue; + } + + // Yield collected lines before throwing so user sees CLI output + for (const line of result.lines) { + yield line; + } + throw new Error(result.error); + } + + throw new Error( + `Too many parameter prompts (${MAX_PARAM_RETRIES}). ` + + `Collected: ${collectedParams.map((p) => p.name).join(", ")}` + ); + } + + private async _tryCreateDetectingPrompts( + name: string, + template: string, + preset: string | undefined, + extraParams: Array<{ name: string; value: string }>, + promptTimeoutMs: number, + abortSignal: AbortSignal | undefined + ): Promise< + | { type: "success"; lines: string[] } + | { type: "prompt"; param: { name: string; value: string }; lines: string[] } + | { type: "error"; error: string; lines: string[] } + > { const args = ["create", name, "-t", template, "--yes"]; if (preset) { args.push("--preset", preset); } - - log.debug("Creating Coder workspace", { name, template, preset, args }); - - // Check if already aborted before spawning - if (abortSignal?.aborted) { - throw new Error("Coder workspace creation aborted"); + for (const p of extraParams) { + args.push("--parameter", `${p.name}=${p.value}`); } const child = spawn("coder", args, { stdio: ["ignore", "pipe", "pipe"], }); + const terminator = createGracefulTerminator(child); - // Set up abort handler to kill the child process + const lines: string[] = []; + let rawOutput = ""; let aborted = false; - let sigkillTimer: ReturnType | null = null; + let promptDetected = false; + const abortHandler = () => { aborted = true; - child.kill("SIGTERM"); - // Force kill after 5 seconds if SIGTERM doesn't work - sigkillTimer = setTimeout(() => child.kill("SIGKILL"), 5000); + terminator.terminate(); }; abortSignal?.addEventListener("abort", abortHandler); - try { - // Collect lines from both streams into a shared queue - const lines: string[] = []; - let readIndex = 0; // Index-based iteration to avoid O(n²) shift() - let streamsDone = false; - let streamError: Error | null = null; - - const processStream = async (stream: NodeJS.ReadableStream): Promise => { - for await (const chunk of stream) { - for (const line of (chunk as Buffer).toString().split("\n")) { - if (line.trim()) { - lines.push(line); - } + const processStream = async (stream: NodeJS.ReadableStream): Promise => { + for await (const chunk of stream) { + const text = (chunk as Buffer).toString(); + rawOutput += text; + for (const line of text.split("\n")) { + if (line.trim()) { + lines.push(line); } } - }; + } + }; - // Start both stream processors concurrently (don't await yet) - const stdoutDone = processStream(child.stdout).catch((e: unknown) => { - streamError = streamError ?? (e instanceof Error ? e : new Error(String(e))); - }); - const stderrDone = processStream(child.stderr).catch((e: unknown) => { - streamError = streamError ?? (e instanceof Error ? e : new Error(String(e))); - }); + const stdoutDone = processStream(child.stdout).catch((error: unknown) => { + log.debug("Failed to read coder create stdout", { error }); + }); + const stderrDone = processStream(child.stderr).catch((error: unknown) => { + log.debug("Failed to read coder create stderr", { error }); + }); - // Attach close/error handlers immediately to avoid missing events - // Note: `close` can report exitCode=null when the process is terminated by signal. - const exitPromise = new Promise((resolve, reject) => { - child.on("close", (exitCode) => resolve(exitCode ?? -1)); - child.on("error", reject); - }); + const exitPromise = new Promise((resolve) => { + child.on("close", (code) => resolve(code)); + child.on("error", () => resolve(null)); + }); - // Yield lines as they arrive, polling until streams complete - while (!streamsDone) { - // Check for abort - if (aborted) { - throw new Error("Coder workspace creation aborted"); - } + const promptCheckInterval = setInterval(() => { + const parsed = this._parseParameterPrompt(rawOutput); + if (parsed) { + promptDetected = true; + terminator.terminate(); + clearInterval(promptCheckInterval); + } + }, 200); - // Drain any available lines using index-based iteration (O(1) per line vs O(n) for shift) - while (readIndex < lines.length) { - yield lines[readIndex++]; - } - // Compact array periodically to avoid unbounded memory growth - if (readIndex > 500) { - lines.splice(0, readIndex); - readIndex = 0; - } + const exitCode = await Promise.race([ + exitPromise, + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), promptTimeoutMs)), + ]); + + clearInterval(promptCheckInterval); - // Check if streams are done (non-blocking race) - const bothDone = await Promise.race([ - Promise.all([stdoutDone, stderrDone]).then(() => true), - new Promise((r) => setTimeout(() => r(false), 50)), + if (exitCode === "timeout") { + const parsed = this._parseParameterPrompt(rawOutput); + if (parsed) { + terminator.terminate(); + + const didExit = await Promise.race([ + exitPromise.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 1000)), ]); - if (bothDone) { - streamsDone = true; + if (didExit) { + terminator.cleanup(); } - } - // Drain any remaining lines after streams close - while (readIndex < lines.length) { - yield lines[readIndex++]; + abortSignal?.removeEventListener("abort", abortHandler); + return { type: "prompt", param: parsed, lines }; } + const finalExitCode = await exitPromise; + await Promise.all([stdoutDone, stderrDone]); + terminator.cleanup(); + abortSignal?.removeEventListener("abort", abortHandler); if (aborted) { - throw new Error("Coder workspace creation aborted"); + return { type: "error", error: "Coder workspace creation aborted", lines }; + } + if (finalExitCode !== 0) { + return { + type: "error", + error: `coder create failed with exit code ${String(finalExitCode)}`, + lines, + }; } + return { type: "success", lines }; + } + + await Promise.all([stdoutDone, stderrDone]); + terminator.cleanup(); + abortSignal?.removeEventListener("abort", abortHandler); + + if (aborted) { + return { type: "error", error: "Coder workspace creation aborted", lines }; + } - if (streamError !== null) { - const err: Error = streamError; - throw err; + if (promptDetected) { + const parsed = this._parseParameterPrompt(rawOutput); + if (parsed) { + return { type: "prompt", param: parsed, lines }; } + } - const exitCode = await exitPromise; - if (exitCode !== 0) { - throw new Error(`coder create failed with exit code ${String(exitCode)}`); + if (exitCode !== 0) { + const parsed = this._parseParameterPrompt(rawOutput); + if (parsed) { + return { type: "prompt", param: parsed, lines }; } - } finally { - if (sigkillTimer) clearTimeout(sigkillTimer); - abortSignal?.removeEventListener("abort", abortHandler); + return { + type: "error", + error: `coder create failed with exit code ${String(exitCode)}`, + lines, + }; } - } - /** - * Delete a Coder workspace. - */ - async deleteWorkspace(name: string): Promise { - log.debug("Deleting Coder workspace", { name }); - using proc = execAsync(`coder delete ${shescape.quote(name)} --yes`); - await proc.result; + return { type: "success", lines }; } - /** - * Ensure SSH config is set up for Coder workspaces. - * Run before every Coder workspace connection (idempotent). - */ - async ensureSSHConfig(): Promise { - log.debug("Ensuring Coder SSH config"); - using proc = execAsync("coder config-ssh --yes"); - await proc.result; + private _parseParameterPrompt(output: string): { name: string; value: string } | null { + const re = /^([^\n]+)\n {2}[^\n]+\n\n> Enter a value \(default: "([^"]*)"\):/m; + const match = re.exec(output); + return match ? { name: match[1].trim(), value: match[2] } : null; } + + // ============================================================================ + // END TEMPORARY WORKAROUND + // ============================================================================ } // Singleton instance diff --git a/src/node/services/utils/sendMessageError.ts b/src/node/services/utils/sendMessageError.ts index 521022af74..d179a33978 100644 --- a/src/node/services/utils/sendMessageError.ts +++ b/src/node/services/utils/sendMessageError.ts @@ -46,8 +46,15 @@ export const formatSendMessageError = ( }; case "runtime_not_ready": return { - message: `Workspace runtime unavailable: ${error.message}. The container may have failed to start or been removed.`, - errorType: "unknown", + message: + `Workspace runtime unavailable: ${error.message}. ` + + `The container/workspace may have been removed or does not exist.`, + errorType: "runtime_not_ready", + }; + case "runtime_start_failed": + return { + message: `Workspace is starting: ${error.message}`, + errorType: "runtime_start_failed", }; case "unknown": return { diff --git a/tests/runtime/runtime.test.ts b/tests/runtime/runtime.test.ts index cd0adaa979..b591e26467 100644 --- a/tests/runtime/runtime.test.ts +++ b/tests/runtime/runtime.test.ts @@ -315,7 +315,9 @@ describeIntegration("Runtime integration tests", () => { const result = await runtime.ensureReady(); expect(result.ready).toBe(false); - expect(result.error).toBeDefined(); + if (!result.ready) { + expect(result.error).toBeDefined(); + } }); }); From 4023e463c6231b9521c8a713a2b0d52c157cc9fb Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 13 Jan 2026 21:42:32 +1100 Subject: [PATCH 07/28] =?UTF-8?q?=F0=9F=A4=96=20tests:=20cover=20Coder=20r?= =?UTF-8?q?untime=20readiness=20+=20runtime-status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add coverage for Coder-backed SSH runtime readiness and runtime-status event plumbing. - CoderSSHRuntime: validateBeforePersist / postCreateSetup / ensureReady - CoderService: getWorkspaceStatus / startWorkspaceAndWait - Frontend: runtime-status routing + StreamingMessageAggregator lifecycle - Retry policy: runtime_not_ready (non-retryable) vs runtime_start_failed (retryable) Also drops brittle execAsync string snapshot tests for deleteWorkspace/ensureSSHConfig. --- _Generated with • Model: openai:gpt-5.2 • Thinking: high • Cost: 65.97_ --- ...ingMessageAggregator.runtimeStatus.test.ts | 113 +++++++ ...pplyWorkspaceChatEventToAggregator.test.ts | 16 + .../utils/messages/retryEligibility.test.ts | 56 ++++ src/node/runtime/CoderSSHRuntime.test.ts | 290 +++++++++++++++++- src/node/services/coderService.test.ts | 169 ++++++---- 5 files changed, 578 insertions(+), 66 deletions(-) create mode 100644 src/browser/utils/messages/StreamingMessageAggregator.runtimeStatus.test.ts diff --git a/src/browser/utils/messages/StreamingMessageAggregator.runtimeStatus.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.runtimeStatus.test.ts new file mode 100644 index 0000000000..4136a7f072 --- /dev/null +++ b/src/browser/utils/messages/StreamingMessageAggregator.runtimeStatus.test.ts @@ -0,0 +1,113 @@ +import { describe, test, expect } from "bun:test"; + +import { StreamingMessageAggregator } from "./StreamingMessageAggregator"; + +const TEST_CREATED_AT = "2024-01-01T00:00:00.000Z"; + +describe("StreamingMessageAggregator runtime-status", () => { + test("handleRuntimeStatus sets status for non-terminal phases and clears on ready/error", () => { + const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); + + expect(aggregator.getRuntimeStatus()).toBeNull(); + + aggregator.handleRuntimeStatus({ + type: "runtime-status", + workspaceId: "ws-1", + phase: "starting", + runtimeType: "ssh", + detail: "Starting workspace...", + }); + + expect(aggregator.getRuntimeStatus()?.phase).toBe("starting"); + + aggregator.handleRuntimeStatus({ + type: "runtime-status", + workspaceId: "ws-1", + phase: "ready", + runtimeType: "ssh", + }); + + expect(aggregator.getRuntimeStatus()).toBeNull(); + + aggregator.handleRuntimeStatus({ + type: "runtime-status", + workspaceId: "ws-1", + phase: "waiting", + runtimeType: "ssh", + }); + + expect(aggregator.getRuntimeStatus()?.phase).toBe("waiting"); + + aggregator.handleRuntimeStatus({ + type: "runtime-status", + workspaceId: "ws-1", + phase: "error", + runtimeType: "ssh", + detail: "boom", + }); + + expect(aggregator.getRuntimeStatus()).toBeNull(); + }); + + test("stream-start clears runtimeStatus", () => { + const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); + + aggregator.handleRuntimeStatus({ + type: "runtime-status", + workspaceId: "ws-1", + phase: "starting", + runtimeType: "ssh", + }); + + aggregator.handleStreamStart({ + type: "stream-start", + workspaceId: "ws-1", + messageId: "msg-1", + historySequence: 1, + model: "test-model", + startTime: 0, + }); + + expect(aggregator.getRuntimeStatus()).toBeNull(); + }); + + test("stream-abort clears runtimeStatus", () => { + const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); + + aggregator.handleRuntimeStatus({ + type: "runtime-status", + workspaceId: "ws-1", + phase: "starting", + runtimeType: "ssh", + }); + + aggregator.handleStreamAbort({ + type: "stream-abort", + workspaceId: "ws-1", + messageId: "msg-1", + metadata: {}, + }); + + expect(aggregator.getRuntimeStatus()).toBeNull(); + }); + + test("stream-error clears runtimeStatus", () => { + const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); + + aggregator.handleRuntimeStatus({ + type: "runtime-status", + workspaceId: "ws-1", + phase: "starting", + runtimeType: "ssh", + }); + + aggregator.handleStreamError({ + type: "stream-error", + messageId: "msg-1", + error: "boom", + errorType: "runtime_start_failed", + }); + + expect(aggregator.getRuntimeStatus()).toBeNull(); + }); +}); diff --git a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts index 9cb10d6998..5904eb8695 100644 --- a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts +++ b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts @@ -138,6 +138,22 @@ describe("applyWorkspaceChatEventToAggregator", () => { expect(aggregator.calls).toEqual(["handleStreamEnd:msg-1", "clearTokenState:msg-1"]); }); + test("runtime-status routes to handleRuntimeStatus", () => { + const aggregator = new StubAggregator(); + + const event: WorkspaceChatMessage = { + type: "runtime-status", + workspaceId: "ws-1", + phase: "starting", + runtimeType: "ssh", + detail: "Starting Coder workspace...", + }; + + const hint = applyWorkspaceChatEventToAggregator(aggregator, event); + + expect(hint).toBe("immediate"); + expect(aggregator.calls).toEqual(["handleRuntimeStatus:starting:ssh"]); + }); test("stream-abort clears token state before calling handleStreamAbort", () => { const aggregator = new StubAggregator(); diff --git a/src/browser/utils/messages/retryEligibility.test.ts b/src/browser/utils/messages/retryEligibility.test.ts index 4619016f80..4c4da2bd30 100644 --- a/src/browser/utils/messages/retryEligibility.test.ts +++ b/src/browser/utils/messages/retryEligibility.test.ts @@ -452,6 +452,26 @@ describe("isEligibleForAutoRetry", () => { ]; expect(isEligibleForAutoRetry(messages)).toBe(false); }); + it("returns false for runtime_not_ready errors (workspace needs attention)", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Coder workspace does not exist", + errorType: "runtime_not_ready", + historySequence: 2, + }, + ]; + expect(isEligibleForAutoRetry(messages)).toBe(false); + }); }); describe("retryable error types", () => { @@ -517,6 +537,27 @@ describe("isEligibleForAutoRetry", () => { ]; expect(isEligibleForAutoRetry(messages)).toBe(true); }); + + it("returns true for runtime_start_failed errors (transient runtime start failures)", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Failed to start runtime", + errorType: "runtime_start_failed", + historySequence: 2, + }, + ]; + expect(isEligibleForAutoRetry(messages)).toBe(true); + }); }); describe("partial messages and user messages", () => { @@ -628,6 +669,21 @@ describe("isNonRetryableSendError", () => { expect(isNonRetryableSendError(error)).toBe(false); }); + it("returns true for runtime_not_ready error", () => { + const error: SendMessageError = { + type: "runtime_not_ready", + message: "Coder workspace does not exist", + }; + expect(isNonRetryableSendError(error)).toBe(true); + }); + + it("returns false for runtime_start_failed error", () => { + const error: SendMessageError = { + type: "runtime_start_failed", + message: "Failed to start runtime", + }; + expect(isNonRetryableSendError(error)).toBe(false); + }); it("returns true for incompatible_workspace error", () => { const error: SendMessageError = { type: "incompatible_workspace", diff --git a/src/node/runtime/CoderSSHRuntime.test.ts b/src/node/runtime/CoderSSHRuntime.test.ts index e12a9f8cba..9de41c5b2c 100644 --- a/src/node/runtime/CoderSSHRuntime.test.ts +++ b/src/node/runtime/CoderSSHRuntime.test.ts @@ -1,17 +1,43 @@ import { describe, expect, it, mock, beforeEach, afterEach, spyOn, type Mock } from "bun:test"; -import { CoderSSHRuntime, type CoderSSHRuntimeConfig } from "./CoderSSHRuntime"; -import { SSHRuntime } from "./SSHRuntime"; import type { CoderService } from "@/node/services/coderService"; import type { RuntimeConfig } from "@/common/types/runtime"; +// eslint-disable-next-line @typescript-eslint/no-empty-function +const noop = () => {}; +import type { RuntimeStatusEvent } from "./Runtime"; + +const execBufferedMock = mock(() => + Promise.resolve({ stdout: "", stderr: "", exitCode: 0, duration: 0 }) +); + +void mock.module("@/node/utils/runtime/helpers", () => ({ + execBuffered: execBufferedMock, +})); + +import { CoderSSHRuntime, type CoderSSHRuntimeConfig } from "./CoderSSHRuntime"; +import { SSHRuntime } from "./SSHRuntime"; + /** * Create a minimal mock CoderService for testing. * Only mocks methods used by the tested code paths. */ function createMockCoderService(overrides?: Partial): CoderService { return { + createWorkspace: mock(() => + (async function* (): AsyncGenerator { + await Promise.resolve(); + // default: no output + for (const line of [] as string[]) { + yield line; + } + })() + ), deleteWorkspace: mock(() => Promise.resolve()), + ensureSSHConfig: mock(() => Promise.resolve()), + getWorkspaceStatus: mock(() => Promise.resolve({ status: "running" as const })), listWorkspaces: mock(() => Promise.resolve([])), + startWorkspaceAndWait: mock(() => Promise.resolve({ success: true })), + workspaceExists: mock(() => Promise.resolve(false)), ...overrides, } as unknown as CoderService; } @@ -23,13 +49,15 @@ function createRuntime( coderConfig: { existingWorkspace?: boolean; workspaceName?: string; template?: string }, coderService: CoderService ): CoderSSHRuntime { + const template = "template" in coderConfig ? coderConfig.template : "default-template"; + const config: CoderSSHRuntimeConfig = { host: "placeholder.coder", srcBaseDir: "~/src", coder: { existingWorkspace: coderConfig.existingWorkspace ?? false, workspaceName: coderConfig.workspaceName, - template: coderConfig.template ?? "default-template", + template, }, }; return new CoderSSHRuntime(config, coderService); @@ -271,3 +299,259 @@ describe("CoderSSHRuntime.deleteWorkspace", () => { } }); }); + +// ============================================================================= +// Test Suite 3: validateBeforePersist (collision detection) +// ============================================================================= + +describe("CoderSSHRuntime.validateBeforePersist", () => { + it("returns error when Coder workspace already exists", async () => { + const workspaceExists = mock(() => Promise.resolve(true)); + const coderService = createMockCoderService({ workspaceExists }); + const runtime = createRuntime({}, coderService); + + const config = createSSHCoderConfig({ + existingWorkspace: false, + workspaceName: "my-ws", + }); + + const result = await runtime.validateBeforePersist("branch", config); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("already exists"); + } + expect(workspaceExists).toHaveBeenCalledWith("my-ws"); + }); + + it("skips collision check for existingWorkspace=true", async () => { + const workspaceExists = mock(() => Promise.resolve(true)); + const coderService = createMockCoderService({ workspaceExists }); + const runtime = createRuntime({}, coderService); + + const config = createSSHCoderConfig({ + existingWorkspace: true, + workspaceName: "existing-ws", + }); + + const result = await runtime.validateBeforePersist("branch", config); + expect(result.success).toBe(true); + expect(workspaceExists).not.toHaveBeenCalled(); + }); +}); + +// ============================================================================= +// Test Suite 4: postCreateSetup (provisioning) +// ============================================================================= + +describe("CoderSSHRuntime.postCreateSetup", () => { + beforeEach(() => { + execBufferedMock.mockClear(); + }); + + it("creates a new Coder workspace and prepares the directory", async () => { + const createWorkspace = mock(() => + (async function* (): AsyncGenerator { + await Promise.resolve(); + yield "build line 1"; + yield "build line 2"; + })() + ); + const ensureSSHConfig = mock(() => Promise.resolve()); + + const coderService = createMockCoderService({ createWorkspace, ensureSSHConfig }); + const runtime = createRuntime( + { existingWorkspace: false, workspaceName: "my-ws", template: "my-template" }, + coderService + ); + + // Before postCreateSetup, ensureReady should fail fast (workspace not created yet) + const beforeReady = await runtime.ensureReady(); + expect(beforeReady.ready).toBe(false); + + const steps: string[] = []; + const stdout: string[] = []; + const stderr: string[] = []; + const initLogger = { + logStep: (s: string) => { + steps.push(s); + }, + logStdout: (s: string) => { + stdout.push(s); + }, + logStderr: (s: string) => { + stderr.push(s); + }, + logComplete: noop, + }; + + await runtime.postCreateSetup({ + initLogger, + projectPath: "/project", + branchName: "branch", + trunkBranch: "main", + workspacePath: "/home/user/src/my-project/my-ws", + }); + + expect(createWorkspace).toHaveBeenCalledWith("my-ws", "my-template", undefined, undefined); + expect(ensureSSHConfig).toHaveBeenCalled(); + expect(execBufferedMock).toHaveBeenCalled(); + + // After postCreateSetup, ensureReady should no longer fast-fail + const afterReady = await runtime.ensureReady(); + expect(afterReady.ready).toBe(true); + + expect(stdout).toEqual(["build line 1", "build line 2"]); + expect(stderr).toEqual([]); + expect(steps.join("\n")).toContain("Creating Coder workspace"); + expect(steps.join("\n")).toContain("Configuring SSH"); + expect(steps.join("\n")).toContain("Preparing workspace directory"); + }); + + it("skips workspace creation when existingWorkspace=true", async () => { + const createWorkspace = mock(() => + (async function* (): AsyncGenerator { + await Promise.resolve(); + yield "should not happen"; + })() + ); + const ensureSSHConfig = mock(() => Promise.resolve()); + + const coderService = createMockCoderService({ createWorkspace, ensureSSHConfig }); + const runtime = createRuntime( + { existingWorkspace: true, workspaceName: "existing-ws" }, + coderService + ); + + await runtime.postCreateSetup({ + initLogger: { + logStep: noop, + logStdout: noop, + logStderr: noop, + logComplete: noop, + }, + projectPath: "/project", + branchName: "branch", + trunkBranch: "main", + workspacePath: "/home/user/src/my-project/existing-ws", + }); + + expect(createWorkspace).not.toHaveBeenCalled(); + expect(ensureSSHConfig).toHaveBeenCalled(); + expect(execBufferedMock).toHaveBeenCalled(); + }); + + it("throws when workspaceName is missing", () => { + const coderService = createMockCoderService(); + const runtime = createRuntime({ existingWorkspace: false, template: "tmpl" }, coderService); + + return expect( + runtime.postCreateSetup({ + initLogger: { + logStep: noop, + logStdout: noop, + logStderr: noop, + logComplete: noop, + }, + projectPath: "/project", + branchName: "branch", + trunkBranch: "main", + workspacePath: "/home/user/src/my-project/ws", + }) + ).rejects.toThrow("Coder workspace name is required"); + }); + + it("throws when template is missing for new workspaces", () => { + const coderService = createMockCoderService(); + const runtime = createRuntime( + { existingWorkspace: false, workspaceName: "my-ws", template: undefined }, + coderService + ); + + return expect( + runtime.postCreateSetup({ + initLogger: { + logStep: noop, + logStdout: noop, + logStderr: noop, + logComplete: noop, + }, + projectPath: "/project", + branchName: "branch", + trunkBranch: "main", + workspacePath: "/home/user/src/my-project/ws", + }) + ).rejects.toThrow("Coder template is required"); + }); +}); + +// ============================================================================= +// Test Suite 5: ensureReady (runtime readiness + status events) +// ============================================================================= + +describe("CoderSSHRuntime.ensureReady", () => { + it("returns ready when workspace is already running", async () => { + const getWorkspaceStatus = mock(() => Promise.resolve({ status: "running" as const })); + const startWorkspaceAndWait = mock(() => Promise.resolve({ success: true })); + const coderService = createMockCoderService({ getWorkspaceStatus, startWorkspaceAndWait }); + + const runtime = createRuntime( + { existingWorkspace: true, workspaceName: "my-ws" }, + coderService + ); + + const events: RuntimeStatusEvent[] = []; + const result = await runtime.ensureReady({ + statusSink: (e) => events.push(e), + }); + + expect(result).toEqual({ ready: true }); + expect(getWorkspaceStatus).toHaveBeenCalled(); + expect(startWorkspaceAndWait).not.toHaveBeenCalled(); + expect(events.map((e) => e.phase)).toEqual(["checking", "ready"]); + expect(events[0]?.runtimeType).toBe("ssh"); + }); + + it("starts the workspace when status is stopped", async () => { + const getWorkspaceStatus = mock(() => Promise.resolve({ status: "stopped" as const })); + const startWorkspaceAndWait = mock(() => Promise.resolve({ success: true })); + const coderService = createMockCoderService({ getWorkspaceStatus, startWorkspaceAndWait }); + + const runtime = createRuntime( + { existingWorkspace: true, workspaceName: "my-ws" }, + coderService + ); + + const events: RuntimeStatusEvent[] = []; + const result = await runtime.ensureReady({ + statusSink: (e) => events.push(e), + }); + + expect(result).toEqual({ ready: true }); + expect(startWorkspaceAndWait).toHaveBeenCalled(); + expect(events.map((e) => e.phase)).toEqual(["checking", "starting", "ready"]); + }); + + it("returns runtime_start_failed when start fails", async () => { + const getWorkspaceStatus = mock(() => Promise.resolve({ status: "stopped" as const })); + const startWorkspaceAndWait = mock(() => Promise.resolve({ success: false, error: "boom" })); + const coderService = createMockCoderService({ getWorkspaceStatus, startWorkspaceAndWait }); + + const runtime = createRuntime( + { existingWorkspace: true, workspaceName: "my-ws" }, + coderService + ); + + const events: RuntimeStatusEvent[] = []; + const result = await runtime.ensureReady({ + statusSink: (e) => events.push(e), + }); + + expect(result.ready).toBe(false); + if (!result.ready) { + expect(result.errorType).toBe("runtime_start_failed"); + expect(result.error).toContain("Failed to start"); + } + + expect(events.at(-1)?.phase).toBe("error"); + }); +}); diff --git a/src/node/services/coderService.test.ts b/src/node/services/coderService.test.ts index c535906a12..54177533d1 100644 --- a/src/node/services/coderService.test.ts +++ b/src/node/services/coderService.test.ts @@ -3,6 +3,42 @@ import { Readable } from "stream"; import { describe, it, expect, vi, beforeEach, afterEach } from "bun:test"; import { CoderService, compareVersions } from "./coderService"; +function mockExecOk(stdout: string, stderr = ""): void { + mockExecAsync.mockReturnValue({ + result: Promise.resolve({ stdout, stderr }), + [Symbol.dispose]: noop, + }); +} + +function mockExecError(error: Error): void { + mockExecAsync.mockReturnValue({ + result: Promise.reject(error), + [Symbol.dispose]: noop, + }); +} + +function mockCoderCommandResult(options: { + stdout?: string; + stderr?: string; + exitCode: number; +}): void { + const stdout = Readable.from(options.stdout ? [Buffer.from(options.stdout)] : []); + const stderr = Readable.from(options.stderr ? [Buffer.from(options.stderr)] : []); + const events = new EventEmitter(); + + mockSpawn.mockReturnValue({ + stdout, + stderr, + exitCode: null, + signalCode: null, + kill: vi.fn(), + on: events.on.bind(events), + removeListener: events.removeListener.bind(events), + } as never); + + // Emit close after handlers are attached. + setTimeout(() => events.emit("close", options.exitCode), 0); +} // eslint-disable-next-line @typescript-eslint/no-empty-function const noop = () => {}; @@ -39,10 +75,7 @@ describe("CoderService", () => { describe("getCoderInfo", () => { it("returns available: true with valid version", async () => { - mockExecAsync.mockReturnValue({ - result: Promise.resolve({ stdout: JSON.stringify({ version: "2.28.2" }) }), - [Symbol.dispose]: noop, - }); + mockExecOk(JSON.stringify({ version: "2.28.2" })); const info = await service.getCoderInfo(); @@ -50,10 +83,7 @@ describe("CoderService", () => { }); it("returns available: true for exact minimum version", async () => { - mockExecAsync.mockReturnValue({ - result: Promise.resolve({ stdout: JSON.stringify({ version: "2.25.0" }) }), - [Symbol.dispose]: noop, - }); + mockExecOk(JSON.stringify({ version: "2.25.0" })); const info = await service.getCoderInfo(); @@ -61,10 +91,7 @@ describe("CoderService", () => { }); it("returns available: false for version below minimum", async () => { - mockExecAsync.mockReturnValue({ - result: Promise.resolve({ stdout: JSON.stringify({ version: "2.24.9" }) }), - [Symbol.dispose]: noop, - }); + mockExecOk(JSON.stringify({ version: "2.24.9" })); const info = await service.getCoderInfo(); @@ -72,10 +99,7 @@ describe("CoderService", () => { }); it("handles version with dev suffix", async () => { - mockExecAsync.mockReturnValue({ - result: Promise.resolve({ stdout: JSON.stringify({ version: "2.28.2-devel+903c045b9" }) }), - [Symbol.dispose]: noop, - }); + mockExecOk(JSON.stringify({ version: "2.28.2-devel+903c045b9" })); const info = await service.getCoderInfo(); @@ -83,10 +107,7 @@ describe("CoderService", () => { }); it("returns available: false when CLI not installed", async () => { - mockExecAsync.mockReturnValue({ - result: Promise.reject(new Error("command not found: coder")), - [Symbol.dispose]: noop, - }); + mockExecError(new Error("command not found: coder")); const info = await service.getCoderInfo(); @@ -94,10 +115,7 @@ describe("CoderService", () => { }); it("caches the result", async () => { - mockExecAsync.mockReturnValue({ - result: Promise.resolve({ stdout: JSON.stringify({ version: "2.28.2" }) }), - [Symbol.dispose]: noop, - }); + mockExecOk(JSON.stringify({ version: "2.28.2" })); await service.getCoderInfo(); await service.getCoderInfo(); @@ -280,22 +298,15 @@ describe("CoderService", () => { describe("workspaceExists", () => { it("returns true when exact match is found in search results", async () => { - mockExecAsync.mockReturnValue({ - result: Promise.resolve({ stdout: JSON.stringify([{ name: "ws-1" }, { name: "ws-10" }]) }), - [Symbol.dispose]: noop, - }); + mockExecOk(JSON.stringify([{ name: "ws-1" }, { name: "ws-10" }])); const exists = await service.workspaceExists("ws-1"); expect(exists).toBe(true); - expect(mockExecAsync).toHaveBeenCalledWith("coder list --search 'name:ws-1' --output=json"); }); it("returns false when only prefix matches", async () => { - mockExecAsync.mockReturnValue({ - result: Promise.resolve({ stdout: JSON.stringify([{ name: "ws-10" }]) }), - [Symbol.dispose]: noop, - }); + mockExecOk(JSON.stringify([{ name: "ws-10" }])); const exists = await service.workspaceExists("ws-1"); @@ -303,10 +314,7 @@ describe("CoderService", () => { }); it("returns false on CLI error", async () => { - mockExecAsync.mockReturnValue({ - result: Promise.reject(new Error("not logged in")), - [Symbol.dispose]: noop, - }); + mockExecError(new Error("not logged in")); const exists = await service.workspaceExists("ws-1"); @@ -314,6 +322,67 @@ describe("CoderService", () => { }); }); + describe("getWorkspaceStatus", () => { + it("returns status for exact match (search is prefix-based)", async () => { + mockCoderCommandResult({ + exitCode: 0, + stdout: JSON.stringify([ + { name: "ws-1", latest_build: { status: "running" } }, + { name: "ws-10", latest_build: { status: "stopped" } }, + ]), + }); + + const result = await service.getWorkspaceStatus("ws-1"); + + expect(result.status).toBe("running"); + expect(result.error).toBeUndefined(); + }); + + it("returns workspace not found when only prefix matches", async () => { + mockCoderCommandResult({ + exitCode: 0, + stdout: JSON.stringify([{ name: "ws-10", latest_build: { status: "running" } }]), + }); + + const result = await service.getWorkspaceStatus("ws-1"); + + expect(result.status).toBeNull(); + expect(result.error).toContain("not found"); + }); + + it("returns null status for unknown workspace status", async () => { + mockCoderCommandResult({ + exitCode: 0, + stdout: JSON.stringify([{ name: "ws-1", latest_build: { status: "weird" } }]), + }); + + const result = await service.getWorkspaceStatus("ws-1"); + + expect(result.status).toBeNull(); + expect(result.error).toContain("Unknown status"); + }); + }); + + describe("startWorkspaceAndWait", () => { + it("returns success true on exit code 0", async () => { + mockCoderCommandResult({ exitCode: 0 }); + + const result = await service.startWorkspaceAndWait("ws-1", 1000); + + expect(result).toEqual({ success: true }); + }); + + it("maps build already active errors to build_in_progress", async () => { + mockCoderCommandResult({ + exitCode: 1, + stderr: "workspace build is already active", + }); + + const result = await service.startWorkspaceAndWait("ws-1", 1000); + + expect(result).toEqual({ success: false, error: "build_in_progress" }); + }); + }); describe("createWorkspace", () => { it("streams stdout/stderr lines and passes expected args", async () => { const stdout = Readable.from([Buffer.from("out-1\nout-2\n")]); @@ -442,32 +511,6 @@ describe("CoderService", () => { expect(kill).toHaveBeenCalled(); }); }); - - describe("deleteWorkspace", () => { - it("calls coder delete with --yes flag", async () => { - mockExecAsync.mockReturnValue({ - result: Promise.resolve({ stdout: "", stderr: "" }), - [Symbol.dispose]: noop, - }); - - await service.deleteWorkspace("my-workspace"); - - expect(mockExecAsync).toHaveBeenCalledWith("coder delete 'my-workspace' --yes"); - }); - }); - - describe("ensureSSHConfig", () => { - it("calls coder config-ssh with --yes flag", async () => { - mockExecAsync.mockReturnValue({ - result: Promise.resolve({ stdout: "", stderr: "" }), - [Symbol.dispose]: noop, - }); - - await service.ensureSSHConfig(); - - expect(mockExecAsync).toHaveBeenCalledWith("coder config-ssh --yes"); - }); - }); }); describe("compareVersions", () => { From 50f234e39ca34c4a1537440a525193cd1c60d3e6 Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 14 Jan 2026 01:57:05 +1100 Subject: [PATCH 08/28] fix: remove coderWorkspaceReady flag that bricked workspaces after restart The in-memory flag was set during postCreateSetup() but never persisted. After app restart, workspaces created in 'New' mode would fail ensureReady() without ever checking the Coder server. The flag was redundant - getWorkspaceStatus() already returns 'workspace not found' for missing workspaces, which triggers the same runtime_not_ready error. Existing throttling (lastActivityAtMs with 5-minute threshold) prevents constant network calls - only one status check per session or after inactivity. --- src/node/runtime/CoderSSHRuntime.test.ts | 26 +++++++++++++++++++++--- src/node/runtime/CoderSSHRuntime.ts | 21 ------------------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/node/runtime/CoderSSHRuntime.test.ts b/src/node/runtime/CoderSSHRuntime.test.ts index 9de41c5b2c..e75aa086f6 100644 --- a/src/node/runtime/CoderSSHRuntime.test.ts +++ b/src/node/runtime/CoderSSHRuntime.test.ts @@ -358,15 +358,35 @@ describe("CoderSSHRuntime.postCreateSetup", () => { ); const ensureSSHConfig = mock(() => Promise.resolve()); - const coderService = createMockCoderService({ createWorkspace, ensureSSHConfig }); + // Start with workspace not found, then return running after creation + let workspaceCreated = false; + const getWorkspaceStatus = mock(() => + Promise.resolve( + workspaceCreated + ? { status: "running" as const } + : { status: null, error: "Workspace not found" } + ) + ); + + const coderService = createMockCoderService({ + createWorkspace, + ensureSSHConfig, + getWorkspaceStatus, + }); const runtime = createRuntime( { existingWorkspace: false, workspaceName: "my-ws", template: "my-template" }, coderService ); - // Before postCreateSetup, ensureReady should fail fast (workspace not created yet) + // Before postCreateSetup, ensureReady should fail (workspace doesn't exist on server) const beforeReady = await runtime.ensureReady(); expect(beforeReady.ready).toBe(false); + if (!beforeReady.ready) { + expect(beforeReady.errorType).toBe("runtime_not_ready"); + } + + // Simulate workspace being created by postCreateSetup + workspaceCreated = true; const steps: string[] = []; const stdout: string[] = []; @@ -396,7 +416,7 @@ describe("CoderSSHRuntime.postCreateSetup", () => { expect(ensureSSHConfig).toHaveBeenCalled(); expect(execBufferedMock).toHaveBeenCalled(); - // After postCreateSetup, ensureReady should no longer fast-fail + // After postCreateSetup, ensureReady should succeed (workspace exists on server) const afterReady = await runtime.ensureReady(); expect(afterReady.ready).toBe(true); diff --git a/src/node/runtime/CoderSSHRuntime.ts b/src/node/runtime/CoderSSHRuntime.ts index 1df3b3545f..2838bf5a96 100644 --- a/src/node/runtime/CoderSSHRuntime.ts +++ b/src/node/runtime/CoderSSHRuntime.ts @@ -68,14 +68,6 @@ export class CoderSSHRuntime extends SSHRuntime { private coderConfig: CoderWorkspaceConfig; private readonly coderService: CoderService; - /** - * Tracks whether the Coder workspace is ready for use. - * - For existing workspaces: true (already exists) - * - For new workspaces: false until postCreateSetup() succeeds - * Used by ensureReady() to return proper status after build failures. - */ - private coderWorkspaceReady: boolean; - /** * Timestamp of last time we (a) successfully used the runtime or (b) decided not * to block the user (unknown Coder CLI error). @@ -106,8 +98,6 @@ export class CoderSSHRuntime extends SSHRuntime { }); this.coderConfig = config.coder; this.coderService = coderService; - // Existing workspaces are already ready; new ones need postCreateSetup() to succeed - this.coderWorkspaceReady = config.coder.existingWorkspace ?? false; } /** Overall timeout for ensureReady operations (start + polling) */ @@ -132,15 +122,6 @@ export class CoderSSHRuntime extends SSHRuntime { * Concurrency: shares an in-flight promise to avoid duplicate start sequences. */ override async ensureReady(options?: EnsureReadyOptions): Promise { - // Fast-fail: workspace never created successfully - if (!this.coderWorkspaceReady) { - return { - ready: false, - error: "Coder workspace does not exist. Check init logs for build errors.", - errorType: "runtime_not_ready", - }; - } - const workspaceName = this.coderConfig.workspaceName; if (!workspaceName) { return { @@ -658,8 +639,6 @@ export class CoderSSHRuntime extends SSHRuntime { throw new Error(`Failed to prepare workspace directory: ${errorMsg}`); } - // Mark workspace as ready only after all setup succeeds - this.coderWorkspaceReady = true; this.lastActivityAtMs = Date.now(); } } From 03c9c3745d17feac4ec56f69b9b5ddd6c9eb9343 Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 14 Jan 2026 02:19:11 +1100 Subject: [PATCH 09/28] fix: replace mock.module with spyOn for test isolation mock.module affects all tests globally in Bun's test runner, causing failures in BackgroundProcessManager, hooks, and other tests that depend on the real execBuffered function. Using spyOn with mock.restore() in afterEach provides proper per-test isolation without affecting other test files. --- src/node/runtime/CoderSSHRuntime.test.ts | 26 ++++++++++++++---------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/node/runtime/CoderSSHRuntime.test.ts b/src/node/runtime/CoderSSHRuntime.test.ts index e75aa086f6..d130aa0037 100644 --- a/src/node/runtime/CoderSSHRuntime.test.ts +++ b/src/node/runtime/CoderSSHRuntime.test.ts @@ -1,19 +1,12 @@ import { describe, expect, it, mock, beforeEach, afterEach, spyOn, type Mock } from "bun:test"; import type { CoderService } from "@/node/services/coderService"; import type { RuntimeConfig } from "@/common/types/runtime"; +import * as runtimeHelpers from "@/node/utils/runtime/helpers"; // eslint-disable-next-line @typescript-eslint/no-empty-function const noop = () => {}; import type { RuntimeStatusEvent } from "./Runtime"; -const execBufferedMock = mock(() => - Promise.resolve({ stdout: "", stderr: "", exitCode: 0, duration: 0 }) -); - -void mock.module("@/node/utils/runtime/helpers", () => ({ - execBuffered: execBufferedMock, -})); - import { CoderSSHRuntime, type CoderSSHRuntimeConfig } from "./CoderSSHRuntime"; import { SSHRuntime } from "./SSHRuntime"; @@ -344,8 +337,19 @@ describe("CoderSSHRuntime.validateBeforePersist", () => { // ============================================================================= describe("CoderSSHRuntime.postCreateSetup", () => { + let execBufferedSpy: ReturnType; + beforeEach(() => { - execBufferedMock.mockClear(); + execBufferedSpy = spyOn(runtimeHelpers, "execBuffered").mockResolvedValue({ + stdout: "", + stderr: "", + exitCode: 0, + duration: 0, + }); + }); + + afterEach(() => { + mock.restore(); }); it("creates a new Coder workspace and prepares the directory", async () => { @@ -414,7 +418,7 @@ describe("CoderSSHRuntime.postCreateSetup", () => { expect(createWorkspace).toHaveBeenCalledWith("my-ws", "my-template", undefined, undefined); expect(ensureSSHConfig).toHaveBeenCalled(); - expect(execBufferedMock).toHaveBeenCalled(); + expect(execBufferedSpy).toHaveBeenCalled(); // After postCreateSetup, ensureReady should succeed (workspace exists on server) const afterReady = await runtime.ensureReady(); @@ -457,7 +461,7 @@ describe("CoderSSHRuntime.postCreateSetup", () => { expect(createWorkspace).not.toHaveBeenCalled(); expect(ensureSSHConfig).toHaveBeenCalled(); - expect(execBufferedMock).toHaveBeenCalled(); + expect(execBufferedSpy).toHaveBeenCalled(); }); it("throws when workspaceName is missing", () => { From ad03e97385abbecda38cd0836d0df23981c01d8f Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 14 Jan 2026 02:23:53 +1100 Subject: [PATCH 10/28] fix: add stale response guard to preset fetching Prevents race condition where changing templates quickly could apply presets from the previous template to the newly selected one. --- src/browser/hooks/useCoderWorkspace.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/browser/hooks/useCoderWorkspace.ts b/src/browser/hooks/useCoderWorkspace.ts index 66698f713d..38fa3e7255 100644 --- a/src/browser/hooks/useCoderWorkspace.ts +++ b/src/browser/hooks/useCoderWorkspace.ts @@ -200,13 +200,21 @@ export function useCoderWorkspace({ let mounted = true; setLoadingPresets(true); + // Capture template at request time to detect stale responses + const templateAtRequest = coderConfig.template; + api.coder - .listPresets({ template: coderConfig.template }) + .listPresets({ template: templateAtRequest }) .then((result) => { if (!mounted) { return; } + // Stale response guard: if user changed template while request was in-flight, ignore this response + if (coderConfigRef.current?.template !== templateAtRequest) { + return; + } + setPresets(result); // Presets rules (per spec): From e6b4aa72f91370b013d70102deb66fea9cb5aa60 Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 14 Jan 2026 02:25:39 +1100 Subject: [PATCH 11/28] refactor: remove redundant running-only workspace filter Backend CoderService.listWorkspaces() already defaults to filterRunning=true. --- src/browser/hooks/useCoderWorkspace.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/hooks/useCoderWorkspace.ts b/src/browser/hooks/useCoderWorkspace.ts index 38fa3e7255..e87d8317b6 100644 --- a/src/browser/hooks/useCoderWorkspace.ts +++ b/src/browser/hooks/useCoderWorkspace.ts @@ -169,8 +169,8 @@ export function useCoderWorkspace({ .listWorkspaces() .then((result) => { if (mounted) { - // Only show running workspaces (per spec) - setExistingWorkspaces(result.filter((w) => w.status === "running")); + // Backend already filters to running workspaces by default + setExistingWorkspaces(result); } }) .catch(() => { From 67d6d609293725574bb85bdb74c1c09297a0251c Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 14 Jan 2026 17:32:25 +1100 Subject: [PATCH 12/28] =?UTF-8?q?=F0=9F=A4=96=20fix:=20non-interactive=20c?= =?UTF-8?q?oder=20create=20+=20robust=20deletion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace creation: - Pre-fetch template rich parameters via API before spawning coder create - Pass defaults via --parameter flags to avoid interactive prompts - CSV-encode parameter values containing quotes/commas for CLI parsing - Validate required params have values (from preset or defaults) - Stream build logs in real-time via async queue Workspace status: - Change getWorkspaceStatus() return to discriminated union (ok/not_found/error) - Prevents treating transient errors as "workspace gone" Workspace deletion: - Check Coder workspace status before SSH cleanup - Skip SSH when workspace is not_found/deleted/deleting (avoids hang) - Proceed with SSH on API errors (let it fail naturally) Other: - Prefix new Coder workspace names with mux- - Replace placeholder Coder icon with official shorthand logo --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high` • Cost: `$205.71`_ --- src/browser/components/icons/RuntimeIcons.tsx | 15 +- src/node/runtime/CoderSSHRuntime.test.ts | 94 ++- src/node/runtime/CoderSSHRuntime.ts | 116 ++-- src/node/services/coderService.test.ts | 396 ++++++++++-- src/node/services/coderService.ts | 584 +++++++++++------- 5 files changed, 887 insertions(+), 318 deletions(-) diff --git a/src/browser/components/icons/RuntimeIcons.tsx b/src/browser/components/icons/RuntimeIcons.tsx index c16e5c7f7c..7c236b0a0e 100644 --- a/src/browser/components/icons/RuntimeIcons.tsx +++ b/src/browser/components/icons/RuntimeIcons.tsx @@ -74,23 +74,20 @@ export function LocalIcon({ size = 10, className }: IconProps) { ); } -/** Coder logo icon for Coder-backed SSH runtime (placeholder - replace with official logo) */ +/** Coder logo icon for Coder-backed SSH runtime */ export function CoderIcon({ size = 10, className }: IconProps) { return ( - {/* Placeholder: simple "C" shape - replace with Coder logo */} - + {/* Coder shorthand logo: stylized "C" with cursor block */} + + ); } diff --git a/src/node/runtime/CoderSSHRuntime.test.ts b/src/node/runtime/CoderSSHRuntime.test.ts index d130aa0037..7588258751 100644 --- a/src/node/runtime/CoderSSHRuntime.test.ts +++ b/src/node/runtime/CoderSSHRuntime.test.ts @@ -27,7 +27,9 @@ function createMockCoderService(overrides?: Partial): CoderService ), deleteWorkspace: mock(() => Promise.resolve()), ensureSSHConfig: mock(() => Promise.resolve()), - getWorkspaceStatus: mock(() => Promise.resolve({ status: "running" as const })), + getWorkspaceStatus: mock(() => + Promise.resolve({ kind: "ok" as const, status: "running" as const }) + ), listWorkspaces: mock(() => Promise.resolve([])), startWorkspaceAndWait: mock(() => Promise.resolve({ success: true })), workspaceExists: mock(() => Promise.resolve(false)), @@ -97,8 +99,8 @@ describe("CoderSSHRuntime.finalizeConfig", () => { if (result.success) { expect(result.data.type).toBe("ssh"); if (result.data.type === "ssh") { - expect(result.data.coder?.workspaceName).toBe("my-feature"); - expect(result.data.host).toBe("my-feature.coder"); + expect(result.data.coder?.workspaceName).toBe("mux-my-feature"); + expect(result.data.host).toBe("mux-my-feature.coder"); } } }); @@ -109,8 +111,8 @@ describe("CoderSSHRuntime.finalizeConfig", () => { expect(result.success).toBe(true); if (result.success && result.data.type === "ssh") { - expect(result.data.coder?.workspaceName).toBe("my-feature-branch"); - expect(result.data.host).toBe("my-feature-branch.coder"); + expect(result.data.coder?.workspaceName).toBe("mux-my-feature-branch"); + expect(result.data.host).toBe("mux-my-feature-branch.coder"); } }); @@ -120,14 +122,14 @@ describe("CoderSSHRuntime.finalizeConfig", () => { expect(result.success).toBe(true); if (result.success && result.data.type === "ssh") { - expect(result.data.coder?.workspaceName).toBe("my-feature"); + expect(result.data.coder?.workspaceName).toBe("mux-my-feature"); } }); it("rejects names that fail regex after conversion", async () => { const config = createSSHCoderConfig({ existingWorkspace: false }); - // Name that becomes empty or invalid after conversion - const result = await runtime.finalizeConfig("---", config); + // Name with special chars that can't form a valid Coder name (only hyphens/underscores become invalid) + const result = await runtime.finalizeConfig("@#$%", config); expect(result.success).toBe(false); if (!result.success) { @@ -291,6 +293,66 @@ describe("CoderSSHRuntime.deleteWorkspace", () => { expect(result.error).toContain("Coder API error"); } }); + + it("succeeds immediately when Coder workspace is already deleted", async () => { + // getWorkspaceStatus returns { kind: "not_found" } when workspace doesn't exist + const getWorkspaceStatus = mock(() => Promise.resolve({ kind: "not_found" as const })); + const deleteWorkspace = mock(() => Promise.resolve()); + const coderService = createMockCoderService({ getWorkspaceStatus, deleteWorkspace }); + + const runtime = createRuntime( + { existingWorkspace: false, workspaceName: "my-ws" }, + coderService + ); + + const result = await runtime.deleteWorkspace("/project", "ws", false); + + // Should succeed without calling SSH delete or Coder delete + expect(result.success).toBe(true); + expect(sshDeleteSpy).not.toHaveBeenCalled(); + expect(deleteWorkspace).not.toHaveBeenCalled(); + }); + + it("proceeds with SSH cleanup when status check fails with API error", async () => { + // API error (auth, network) - should NOT treat as "already deleted" + const getWorkspaceStatus = mock(() => + Promise.resolve({ kind: "error" as const, error: "coder timed out" }) + ); + const deleteWorkspace = mock(() => Promise.resolve()); + const coderService = createMockCoderService({ getWorkspaceStatus, deleteWorkspace }); + + const runtime = createRuntime( + { existingWorkspace: false, workspaceName: "my-ws" }, + coderService + ); + + const result = await runtime.deleteWorkspace("/project", "ws", false); + + // Should proceed with SSH cleanup (which succeeds), then Coder delete + expect(sshDeleteSpy).toHaveBeenCalled(); + expect(deleteWorkspace).toHaveBeenCalled(); + expect(result.success).toBe(true); + }); + + it("succeeds immediately when Coder workspace status is 'deleting'", async () => { + const getWorkspaceStatus = mock(() => + Promise.resolve({ kind: "ok" as const, status: "deleting" as const }) + ); + const deleteWorkspace = mock(() => Promise.resolve()); + const coderService = createMockCoderService({ getWorkspaceStatus, deleteWorkspace }); + + const runtime = createRuntime( + { existingWorkspace: false, workspaceName: "my-ws" }, + coderService + ); + + const result = await runtime.deleteWorkspace("/project", "ws", false); + + // Should succeed without calling SSH delete or Coder delete (workspace already dying) + expect(result.success).toBe(true); + expect(sshDeleteSpy).not.toHaveBeenCalled(); + expect(deleteWorkspace).not.toHaveBeenCalled(); + }); }); // ============================================================================= @@ -367,8 +429,8 @@ describe("CoderSSHRuntime.postCreateSetup", () => { const getWorkspaceStatus = mock(() => Promise.resolve( workspaceCreated - ? { status: "running" as const } - : { status: null, error: "Workspace not found" } + ? { kind: "ok" as const, status: "running" as const } + : { kind: "not_found" as const } ) ); @@ -514,7 +576,9 @@ describe("CoderSSHRuntime.postCreateSetup", () => { describe("CoderSSHRuntime.ensureReady", () => { it("returns ready when workspace is already running", async () => { - const getWorkspaceStatus = mock(() => Promise.resolve({ status: "running" as const })); + const getWorkspaceStatus = mock(() => + Promise.resolve({ kind: "ok" as const, status: "running" as const }) + ); const startWorkspaceAndWait = mock(() => Promise.resolve({ success: true })); const coderService = createMockCoderService({ getWorkspaceStatus, startWorkspaceAndWait }); @@ -536,7 +600,9 @@ describe("CoderSSHRuntime.ensureReady", () => { }); it("starts the workspace when status is stopped", async () => { - const getWorkspaceStatus = mock(() => Promise.resolve({ status: "stopped" as const })); + const getWorkspaceStatus = mock(() => + Promise.resolve({ kind: "ok" as const, status: "stopped" as const }) + ); const startWorkspaceAndWait = mock(() => Promise.resolve({ success: true })); const coderService = createMockCoderService({ getWorkspaceStatus, startWorkspaceAndWait }); @@ -556,7 +622,9 @@ describe("CoderSSHRuntime.ensureReady", () => { }); it("returns runtime_start_failed when start fails", async () => { - const getWorkspaceStatus = mock(() => Promise.resolve({ status: "stopped" as const })); + const getWorkspaceStatus = mock(() => + Promise.resolve({ kind: "ok" as const, status: "stopped" as const }) + ); const startWorkspaceAndWait = mock(() => Promise.resolve({ success: false, error: "boom" })); const coderService = createMockCoderService({ getWorkspaceStatus, startWorkspaceAndWait }); diff --git a/src/node/runtime/CoderSSHRuntime.ts b/src/node/runtime/CoderSSHRuntime.ts index 2838bf5a96..81ce861378 100644 --- a/src/node/runtime/CoderSSHRuntime.ts +++ b/src/node/runtime/CoderSSHRuntime.ts @@ -23,7 +23,7 @@ import type { import { SSHRuntime, type SSHRuntimeConfig } from "./SSHRuntime"; import type { CoderWorkspaceConfig, RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime } from "@/common/types/runtime"; -import type { CoderService } from "@/node/services/coderService"; +import type { CoderService, WorkspaceStatusResult } from "@/node/services/coderService"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import { log } from "@/node/services/log"; @@ -182,33 +182,37 @@ export class CoderSSHRuntime extends SSHRuntime { return { ready: false, error: "Aborted", errorType: "runtime_start_failed" }; } + // Helper to check if an error string indicates workspace not found (for startWorkspaceAndWait errors) + const isWorkspaceNotFoundError = (error: string | undefined): boolean => + Boolean(error && /workspace not found/i.test(error)); + const statusResult = await this.coderService.getWorkspaceStatus(workspaceName, { timeoutMs: Math.min(remainingMs(), 10_000), signal, }); - if (statusResult.status === "running") { + // Helper to extract status from result, or null if not available + const getStatus = (r: WorkspaceStatusResult): string | null => + r.kind === "ok" ? r.status : null; + + if (statusResult.kind === "ok" && statusResult.status === "running") { this.lastActivityAtMs = Date.now(); emitStatus("ready"); return { ready: true }; } - const isWorkspaceNotFoundError = (error: string | undefined) => - Boolean(error && /workspace not found/i.test(error)); + if (statusResult.kind === "not_found") { + emitStatus("error"); + return { + ready: false, + error: `Coder workspace "${workspaceName}" not found`, + errorType: "runtime_not_ready", + }; + } - if (statusResult.status === null) { - // Fail fast only when we're confident the workspace doesn't exist. - // For other errors (timeout, auth hiccup, Coder CLI issues), proceed optimistically + if (statusResult.kind === "error") { + // For errors (timeout, auth hiccup, Coder CLI issues), proceed optimistically // and let SSH fail naturally to avoid blocking the happy path. - if (isWorkspaceNotFoundError(statusResult.error)) { - emitStatus("error"); - return { - ready: false, - error: `Coder workspace "${workspaceName}" not found`, - errorType: "runtime_not_ready", - }; - } - log.debug("Coder workspace status unknown, proceeding optimistically", { workspaceName, error: statusResult.error, @@ -218,7 +222,7 @@ export class CoderSSHRuntime extends SSHRuntime { } // Step 2: Handle "stopping" status - wait for it to become "stopped" - let currentStatus: string | null = statusResult.status; + let currentStatus: string | null = getStatus(statusResult); if (currentStatus === "stopping") { emitStatus("waiting", "Waiting for Coder workspace to stop..."); @@ -234,7 +238,7 @@ export class CoderSSHRuntime extends SSHRuntime { timeoutMs: Math.min(remainingMs(), 10_000), signal, }); - currentStatus = pollResult.status; + currentStatus = getStatus(pollResult); if (currentStatus === "running") { this.lastActivityAtMs = Date.now(); @@ -242,17 +246,17 @@ export class CoderSSHRuntime extends SSHRuntime { return { ready: true }; } - // If status became null, only fail fast if the workspace is definitively gone. + // If status unavailable, only fail fast if the workspace is definitively gone. // Otherwise fall through to start attempt (status check might have been flaky). - if (currentStatus === null) { - if (isWorkspaceNotFoundError(pollResult.error)) { - emitStatus("error"); - return { - ready: false, - error: `Coder workspace "${workspaceName}" not found`, - errorType: "runtime_not_ready", - }; - } + if (pollResult.kind === "not_found") { + emitStatus("error"); + return { + ready: false, + error: `Coder workspace "${workspaceName}" not found`, + errorType: "runtime_not_ready", + }; + } + if (pollResult.kind === "error") { break; } } @@ -309,7 +313,7 @@ export class CoderSSHRuntime extends SSHRuntime { signal, }); - if (pollResult.status === null && isWorkspaceNotFoundError(pollResult.error)) { + if (pollResult.kind === "not_found") { emitStatus("error"); return { ready: false, @@ -318,13 +322,15 @@ export class CoderSSHRuntime extends SSHRuntime { }; } - if (pollResult.status === "running") { + const pollStatus = getStatus(pollResult); + + if (pollStatus === "running") { this.lastActivityAtMs = Date.now(); emitStatus("ready"); return { ready: true }; } - if (pollResult.status === "stopped") { + if (pollStatus === "stopped") { // Build finished but workspace ended up stopped - retry start once log.debug("Coder workspace stopped after build, retrying start", { workspaceName }); emitStatus("starting", "Starting Coder workspace..."); @@ -399,7 +405,7 @@ export class CoderSSHRuntime extends SSHRuntime { if (!coder.existingWorkspace) { // New workspace: derive name from mux workspace name if not provided if (!workspaceName) { - workspaceName = finalBranchName; + workspaceName = `mux-${finalBranchName}`; } // Transform to Coder-compatible name (handles underscores, etc.) workspaceName = toCoderCompatibleName(workspaceName); @@ -498,13 +504,44 @@ export class CoderSSHRuntime extends SSHRuntime { force: boolean, abortSignal?: AbortSignal ): Promise<{ success: true; deletedPath: string } | { success: false; error: string }> { - const sshResult = await super.deleteWorkspace(projectPath, workspaceName, force, abortSignal); - - // If this workspace is an existing Coder workspace that mux didn't create, never delete it. + // If this workspace is an existing Coder workspace that mux didn't create, just do SSH cleanup. if (this.coderConfig.existingWorkspace) { - return sshResult; + return super.deleteWorkspace(projectPath, workspaceName, force, abortSignal); + } + + const coderWorkspaceName = this.coderConfig.workspaceName; + if (!coderWorkspaceName) { + log.warn("Coder workspace name not set, falling back to SSH-only deletion"); + return super.deleteWorkspace(projectPath, workspaceName, force, abortSignal); + } + + // Check if Coder workspace still exists before attempting SSH operations. + // If it's already gone, skip SSH cleanup (would hang trying to connect to non-existent host). + const statusResult = await this.coderService.getWorkspaceStatus(coderWorkspaceName); + if (statusResult.kind === "not_found") { + log.debug("Coder workspace already deleted, skipping SSH cleanup", { coderWorkspaceName }); + return { success: true, deletedPath: this.getWorkspacePath(projectPath, workspaceName) }; + } + if (statusResult.kind === "error") { + // API errors (auth, network): fall through to SSH cleanup, let it fail naturally + log.warn("Could not check Coder workspace status, proceeding with SSH cleanup", { + coderWorkspaceName, + error: statusResult.error, + }); + } + if (statusResult.kind === "ok") { + // Workspace is being deleted or already deleted - skip SSH (would hang connecting to dying host) + if (statusResult.status === "deleted" || statusResult.status === "deleting") { + log.debug("Coder workspace is deleted/deleting, skipping SSH cleanup", { + coderWorkspaceName, + status: statusResult.status, + }); + return { success: true, deletedPath: this.getWorkspacePath(projectPath, workspaceName) }; + } } + const sshResult = await super.deleteWorkspace(projectPath, workspaceName, force, abortSignal); + // In the normal (force=false) delete path, only delete the Coder workspace if the SSH delete // succeeded. If SSH delete failed (e.g., dirty workspace), WorkspaceService.remove() keeps the // workspace metadata and the user can retry. @@ -512,13 +549,6 @@ export class CoderSSHRuntime extends SSHRuntime { return sshResult; } - // workspaceName should always be set after workspace creation (finalizeConfig sets it) - const coderWorkspaceName = this.coderConfig.workspaceName; - if (!coderWorkspaceName) { - log.warn("Coder workspace name not set, skipping Coder workspace deletion"); - return sshResult; - } - try { log.debug(`Deleting Coder workspace "${coderWorkspaceName}"`); await this.coderService.deleteWorkspace(coderWorkspaceName); diff --git a/src/node/services/coderService.test.ts b/src/node/services/coderService.test.ts index 54177533d1..3298bcf3ec 100644 --- a/src/node/services/coderService.test.ts +++ b/src/node/services/coderService.test.ts @@ -334,11 +334,13 @@ describe("CoderService", () => { const result = await service.getWorkspaceStatus("ws-1"); - expect(result.status).toBe("running"); - expect(result.error).toBeUndefined(); + expect(result.kind).toBe("ok"); + if (result.kind === "ok") { + expect(result.status).toBe("running"); + } }); - it("returns workspace not found when only prefix matches", async () => { + it("returns not_found when only prefix matches", async () => { mockCoderCommandResult({ exitCode: 0, stdout: JSON.stringify([{ name: "ws-10", latest_build: { status: "running" } }]), @@ -346,11 +348,10 @@ describe("CoderService", () => { const result = await service.getWorkspaceStatus("ws-1"); - expect(result.status).toBeNull(); - expect(result.error).toContain("not found"); + expect(result.kind).toBe("not_found"); }); - it("returns null status for unknown workspace status", async () => { + it("returns error for unknown workspace status", async () => { mockCoderCommandResult({ exitCode: 0, stdout: JSON.stringify([{ name: "ws-1", latest_build: { status: "weird" } }]), @@ -358,8 +359,10 @@ describe("CoderService", () => { const result = await service.getWorkspaceStatus("ws-1"); - expect(result.status).toBeNull(); - expect(result.error).toContain("Unknown status"); + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.error).toContain("Unknown status"); + } }); }); @@ -384,7 +387,98 @@ describe("CoderService", () => { }); }); describe("createWorkspace", () => { + // Capture original fetch once per describe block to avoid nested mock issues + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + // Helper to mock the pre-fetch calls that happen before spawn + function mockPrefetchCalls(options?: { presetParamNames?: string[] }) { + // Mock getDeploymentUrl (coder whoami) + // Mock getActiveTemplateVersionId (coder templates list) + // Mock getPresetParamNames (coder templates presets list) + // Mock getTemplateRichParameters (coder tokens create + fetch) + mockExecAsync.mockImplementation((cmd: string) => { + if (cmd === "coder whoami --output json") { + return { + result: Promise.resolve({ + stdout: JSON.stringify([{ url: "https://coder.example.com" }]), + }), + [Symbol.dispose]: noop, + }; + } + if (cmd === "coder templates list --output=json") { + return { + result: Promise.resolve({ + stdout: JSON.stringify([ + { Template: { name: "my-template", active_version_id: "version-123" } }, + { Template: { name: "tmpl", active_version_id: "version-456" } }, + ]), + }), + [Symbol.dispose]: noop, + }; + } + if (cmd.startsWith("coder templates presets list")) { + const paramNames = options?.presetParamNames ?? []; + return { + result: Promise.resolve({ + stdout: JSON.stringify([ + { + TemplatePreset: { + Name: "preset", + Parameters: paramNames.map((name) => ({ Name: name })), + }, + }, + ]), + }), + [Symbol.dispose]: noop, + }; + } + if (cmd.startsWith("coder tokens create --lifetime 5m --name")) { + return { + result: Promise.resolve({ stdout: "fake-token-123" }), + [Symbol.dispose]: noop, + }; + } + if (cmd.startsWith("coder tokens delete")) { + return { + result: Promise.resolve({ stdout: "" }), + [Symbol.dispose]: noop, + }; + } + // Fallback for any other command + return { + result: Promise.reject(new Error(`Unexpected command: ${cmd}`)), + [Symbol.dispose]: noop, + }; + }); + } + + // Helper to mock fetch for rich parameters API + function mockFetchRichParams( + params: Array<{ + name: string; + default_value: string; + ephemeral?: boolean; + required?: boolean; + }> + ) { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(params), + }) as unknown as typeof fetch; + } + it("streams stdout/stderr lines and passes expected args", async () => { + mockPrefetchCalls(); + mockFetchRichParams([]); + const stdout = Readable.from([Buffer.from("out-1\nout-2\n")]); const stderr = Readable.from([Buffer.from("err-1\n")]); const events = new EventEmitter(); @@ -410,10 +504,15 @@ describe("CoderService", () => { { stdio: ["ignore", "pipe", "pipe"] } ); - expect(lines.sort()).toEqual(["err-1", "out-1", "out-2"]); + // First line is the command, rest are stdout/stderr + expect(lines[0]).toBe("$ coder create my-workspace -t my-template --yes"); + expect(lines.slice(1).sort()).toEqual(["err-1", "out-1", "out-2"]); }); it("includes --preset when provided", async () => { + mockPrefetchCalls({ presetParamNames: ["covered-param"] }); + mockFetchRichParams([{ name: "covered-param", default_value: "val" }]); + const stdout = Readable.from([]); const stderr = Readable.from([]); const events = new EventEmitter(); @@ -438,7 +537,52 @@ describe("CoderService", () => { ); }); + it("includes --parameter flags for uncovered non-ephemeral params", async () => { + mockPrefetchCalls({ presetParamNames: ["covered-param"] }); + mockFetchRichParams([ + { name: "covered-param", default_value: "val1" }, + { name: "uncovered-param", default_value: "val2" }, + { name: "ephemeral-param", default_value: "val3", ephemeral: true }, + ]); + + const stdout = Readable.from([]); + const stderr = Readable.from([]); + const events = new EventEmitter(); + + mockSpawn.mockReturnValue({ + stdout, + stderr, + kill: vi.fn(), + on: events.on.bind(events), + } as never); + + setTimeout(() => events.emit("close", 0), 0); + + for await (const _line of service.createWorkspace("ws", "tmpl", "preset")) { + // drain + } + + expect(mockSpawn).toHaveBeenCalledWith( + "coder", + [ + "create", + "ws", + "-t", + "tmpl", + "--yes", + "--preset", + "preset", + "--parameter", + "uncovered-param=val2", + ], + { stdio: ["ignore", "pipe", "pipe"] } + ); + }); + it("throws when exit code is non-zero", async () => { + mockPrefetchCalls(); + mockFetchRichParams([]); + const stdout = Readable.from([]); const stderr = Readable.from([]); const events = new EventEmitter(); @@ -465,51 +609,219 @@ describe("CoderService", () => { expect(thrown instanceof Error ? thrown.message : String(thrown)).toContain("exit code 42"); }); - it("aborts by killing the child process", async () => { - const stdout = new Readable({ - read() { - // Keep stream open until aborted. - return; - }, - }); - const stderr = new Readable({ - read() { - // Keep stream open until aborted. - return; - }, - }); - const events = new EventEmitter(); - - const kill = vi.fn(() => { - stdout.destroy(); - stderr.destroy(); - events.emit("close", null); - }); - - mockSpawn.mockReturnValue({ - stdout, - stderr, - kill, - on: events.on.bind(events), - } as never); - + it("aborts before spawn when already aborted", async () => { const abortController = new AbortController(); - const iterator = service.createWorkspace("ws", "tmpl", undefined, abortController.signal); - - const pending = iterator.next(); abortController.abort(); let thrown: unknown; try { - await pending; + for await (const _line of service.createWorkspace( + "ws", + "tmpl", + undefined, + abortController.signal + )) { + // drain + } } catch (error) { thrown = error; } expect(thrown).toBeTruthy(); expect(thrown instanceof Error ? thrown.message : String(thrown)).toContain("aborted"); - expect(kill).toHaveBeenCalled(); }); + + it("throws when required param has no default and is not covered by preset", async () => { + mockPrefetchCalls({ presetParamNames: [] }); + mockFetchRichParams([{ name: "required-param", default_value: "", required: true }]); + + let thrown: unknown; + try { + for await (const _line of service.createWorkspace("ws", "tmpl")) { + // drain + } + } catch (error) { + thrown = error; + } + + expect(thrown).toBeTruthy(); + expect(thrown instanceof Error ? thrown.message : String(thrown)).toContain("required-param"); + }); + }); +}); + +describe("computeExtraParams", () => { + let service: CoderService; + + beforeEach(() => { + service = new CoderService(); + }); + + it("returns empty array when all params are covered by preset", () => { + const params = [ + { name: "param1", defaultValue: "val1", type: "string", ephemeral: false, required: false }, + { name: "param2", defaultValue: "val2", type: "string", ephemeral: false, required: false }, + ]; + const covered = new Set(["param1", "param2"]); + + expect(service.computeExtraParams(params, covered)).toEqual([]); + }); + + it("returns uncovered non-ephemeral params with defaults", () => { + const params = [ + { name: "covered", defaultValue: "val1", type: "string", ephemeral: false, required: false }, + { + name: "uncovered", + defaultValue: "val2", + type: "string", + ephemeral: false, + required: false, + }, + ]; + const covered = new Set(["covered"]); + + expect(service.computeExtraParams(params, covered)).toEqual([ + { name: "uncovered", encoded: "uncovered=val2" }, + ]); + }); + + it("excludes ephemeral params", () => { + const params = [ + { name: "normal", defaultValue: "val1", type: "string", ephemeral: false, required: false }, + { name: "ephemeral", defaultValue: "val2", type: "string", ephemeral: true, required: false }, + ]; + const covered = new Set(); + + expect(service.computeExtraParams(params, covered)).toEqual([ + { name: "normal", encoded: "normal=val1" }, + ]); + }); + + it("includes params with empty default values", () => { + const params = [ + { + name: "empty-default", + defaultValue: "", + type: "string", + ephemeral: false, + required: false, + }, + ]; + const covered = new Set(); + + expect(service.computeExtraParams(params, covered)).toEqual([ + { name: "empty-default", encoded: "empty-default=" }, + ]); + }); + + it("CSV-encodes list(string) values containing quotes", () => { + const params = [ + { + name: "Select IDEs", + defaultValue: '["vscode","code-server","cursor"]', + type: "list(string)", + ephemeral: false, + required: false, + }, + ]; + const covered = new Set(); + + // CLI uses CSV parsing, so quotes need escaping: " -> "" + expect(service.computeExtraParams(params, covered)).toEqual([ + { name: "Select IDEs", encoded: '"Select IDEs=[""vscode"",""code-server"",""cursor""]"' }, + ]); + }); + + it("passes empty list(string) array without CSV encoding", () => { + const params = [ + { + name: "empty-list", + defaultValue: "[]", + type: "list(string)", + ephemeral: false, + required: false, + }, + ]; + const covered = new Set(); + + // No quotes or commas, so no encoding needed + expect(service.computeExtraParams(params, covered)).toEqual([ + { name: "empty-list", encoded: "empty-list=[]" }, + ]); + }); +}); + +describe("validateRequiredParams", () => { + let service: CoderService; + + beforeEach(() => { + service = new CoderService(); + }); + + it("does not throw when all required params have defaults", () => { + const params = [ + { + name: "required-with-default", + defaultValue: "val", + type: "string", + ephemeral: false, + required: true, + }, + ]; + const covered = new Set(); + + expect(() => service.validateRequiredParams(params, covered)).not.toThrow(); + }); + + it("does not throw when required params are covered by preset", () => { + const params = [ + { + name: "required-no-default", + defaultValue: "", + type: "string", + ephemeral: false, + required: true, + }, + ]; + const covered = new Set(["required-no-default"]); + + expect(() => service.validateRequiredParams(params, covered)).not.toThrow(); + }); + + it("throws when required param has no default and is not covered", () => { + const params = [ + { name: "missing-param", defaultValue: "", type: "string", ephemeral: false, required: true }, + ]; + const covered = new Set(); + + expect(() => service.validateRequiredParams(params, covered)).toThrow("missing-param"); + }); + + it("ignores ephemeral required params", () => { + const params = [ + { + name: "ephemeral-required", + defaultValue: "", + type: "string", + ephemeral: true, + required: true, + }, + ]; + const covered = new Set(); + + expect(() => service.validateRequiredParams(params, covered)).not.toThrow(); + }); + + it("lists all missing required params in error", () => { + const params = [ + { name: "missing1", defaultValue: "", type: "string", ephemeral: false, required: true }, + { name: "missing2", defaultValue: "", type: "string", ephemeral: false, required: true }, + ]; + const covered = new Set(); + + expect(() => service.validateRequiredParams(params, covered)).toThrow( + /missing1.*missing2|missing2.*missing1/ + ); }); }); diff --git a/src/node/services/coderService.ts b/src/node/services/coderService.ts index c1818c0f90..a843f9a290 100644 --- a/src/node/services/coderService.ts +++ b/src/node/services/coderService.ts @@ -18,6 +18,12 @@ import { // Re-export types for consumers that import from this module export type { CoderInfo, CoderTemplate, CoderPreset, CoderWorkspace, CoderWorkspaceStatus }; +/** Discriminated union for workspace status check results */ +export type WorkspaceStatusResult = + | { kind: "ok"; status: CoderWorkspaceStatus } + | { kind: "not_found" } + | { kind: "error"; error: string }; + // Minimum supported Coder CLI version const MIN_CODER_VERSION = "2.25.0"; @@ -174,6 +180,246 @@ export class CoderService { this.cachedInfo = null; } + /** + * Get the Coder deployment URL via `coder whoami`. + * Throws if Coder CLI is not configured/logged in. + */ + private async getDeploymentUrl(): Promise { + using proc = execAsync("coder whoami --output json"); + const { stdout } = await proc.result; + + const data = JSON.parse(stdout) as Array<{ url: string }>; + if (!data[0]?.url) { + throw new Error("Could not determine Coder deployment URL from `coder whoami`"); + } + return data[0].url; + } + + /** + * Get the active template version ID for a template. + * Throws if template not found. + */ + private async getActiveTemplateVersionId(templateName: string): Promise { + using proc = execAsync("coder templates list --output=json"); + const { stdout } = await proc.result; + + if (!stdout.trim()) { + throw new Error(`Template "${templateName}" not found (no templates exist)`); + } + + const raw = JSON.parse(stdout) as Array<{ + Template: { + name: string; + active_version_id: string; + }; + }>; + + const template = raw.find((t) => t.Template.name === templateName); + if (!template) { + throw new Error(`Template "${templateName}" not found`); + } + + return template.Template.active_version_id; + } + + /** + * Get parameter names covered by a preset. + * Returns empty set if preset not found (allows creation to proceed without preset params). + */ + private async getPresetParamNames( + templateName: string, + presetName: string + ): Promise> { + try { + using proc = execAsync( + `coder templates presets list ${shescape.quote(templateName)} --output=json` + ); + const { stdout } = await proc.result; + + if (!stdout.trim()) { + return new Set(); + } + + const raw = JSON.parse(stdout) as Array<{ + TemplatePreset: { + Name: string; + Parameters?: Array<{ Name: string }>; + }; + }>; + + const preset = raw.find((p) => p.TemplatePreset.Name === presetName); + if (!preset?.TemplatePreset.Parameters) { + return new Set(); + } + + return new Set(preset.TemplatePreset.Parameters.map((p) => p.Name)); + } catch (error) { + log.debug("Failed to get preset param names", { templateName, presetName, error }); + return new Set(); + } + } + + /** + * Parse rich parameter data from the Coder API. + * Filters out entries with missing/invalid names to avoid generating invalid --parameter flags. + */ + private parseRichParameters(data: unknown): Array<{ + name: string; + defaultValue: string; + type: string; + ephemeral: boolean; + required: boolean; + }> { + if (!Array.isArray(data)) { + throw new Error("Expected array of rich parameters"); + } + return data + .filter((p): p is Record => { + if (p === null || typeof p !== "object") return false; + const obj = p as Record; + return typeof obj.name === "string" && obj.name !== ""; + }) + .map((p) => ({ + name: p.name as string, + defaultValue: typeof p.default_value === "string" ? p.default_value : "", + type: typeof p.type === "string" ? p.type : "string", + ephemeral: Boolean(p.ephemeral), + required: Boolean(p.required), + })); + } + + /** + * Fetch template rich parameters from Coder API. + * Creates a short-lived token, fetches params, then cleans up the token. + */ + private async getTemplateRichParameters( + deploymentUrl: string, + versionId: string, + workspaceName: string + ): Promise< + Array<{ + name: string; + defaultValue: string; + type: string; + ephemeral: boolean; + required: boolean; + }> + > { + // Create short-lived token named after workspace (avoids keychain read issues) + const tokenName = `mux-${workspaceName}`; + using tokenProc = execAsync( + `coder tokens create --lifetime 5m --name ${shescape.quote(tokenName)}` + ); + const { stdout: token } = await tokenProc.result; + + try { + const url = new URL( + `/api/v2/templateversions/${versionId}/rich-parameters`, + deploymentUrl + ).toString(); + + const response = await fetch(url, { + headers: { + "Coder-Session-Token": token.trim(), + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch rich parameters: ${response.status} ${response.statusText}` + ); + } + + const data: unknown = await response.json(); + return this.parseRichParameters(data); + } finally { + // Clean up the token by name + try { + using deleteProc = execAsync(`coder tokens delete ${shescape.quote(tokenName)} --yes`); + await deleteProc.result; + } catch { + // Best-effort cleanup; token will expire in 5 minutes anyway + log.debug("Failed to delete temporary token", { tokenName }); + } + } + } + + /** + * Encode a parameter string for the Coder CLI's --parameter flag. + * The CLI uses CSV parsing, so values containing quotes or commas need escaping: + * - Wrap the entire string in double quotes + * - Escape internal double quotes as "" + */ + private encodeParameterValue(nameValue: string): string { + if (!nameValue.includes('"') && !nameValue.includes(",")) { + return nameValue; + } + // CSV quoting: wrap in quotes, escape internal quotes as "" + return `"${nameValue.replace(/"/g, '""')}"`; + } + + /** + * Compute extra --parameter flags needed for workspace creation. + * Filters to non-ephemeral params not covered by preset, using their defaults. + * Values are passed through as-is (list(string) types expect JSON-encoded arrays). + */ + computeExtraParams( + allParams: Array<{ + name: string; + defaultValue: string; + type: string; + ephemeral: boolean; + required: boolean; + }>, + coveredByPreset: Set + ): Array<{ name: string; encoded: string }> { + const extra: Array<{ name: string; encoded: string }> = []; + + for (const p of allParams) { + // Skip ephemeral params + if (p.ephemeral) continue; + // Skip params covered by preset + if (coveredByPreset.has(p.name)) continue; + + // Encode for CLI's CSV parser (escape quotes/commas) + const encoded = this.encodeParameterValue(`${p.name}=${p.defaultValue}`); + extra.push({ name: p.name, encoded }); + } + + return extra; + } + + /** + * Validate that all required params have values (either from preset or defaults). + * Throws if any required param is missing a value. + */ + validateRequiredParams( + allParams: Array<{ + name: string; + defaultValue: string; + type: string; + ephemeral: boolean; + required: boolean; + }>, + coveredByPreset: Set + ): void { + const missing: string[] = []; + + for (const p of allParams) { + if (p.ephemeral) continue; + if (p.required && !p.defaultValue && !coveredByPreset.has(p.name)) { + missing.push(p.name); + } + } + + if (missing.length > 0) { + throw new Error( + `Required template parameters missing values: ${missing.join(", ")}. ` + + `Select a preset that provides these values or contact your template admin.` + ); + } + } + /** * List available Coder templates. */ @@ -424,7 +670,7 @@ export class CoderService { async getWorkspaceStatus( workspaceName: string, options?: { timeoutMs?: number; signal?: AbortSignal } - ): Promise<{ status: CoderWorkspaceStatus | null; error?: string }> { + ): Promise { const timeoutMs = options?.timeoutMs ?? 10_000; try { @@ -435,11 +681,11 @@ export class CoderService { const interpreted = interpretCoderResult(result); if (!interpreted.ok) { - return { status: null, error: interpreted.error }; + return { kind: "error", error: interpreted.error }; } if (!interpreted.stdout.trim()) { - return { status: null, error: "Workspace not found" }; + return { kind: "not_found" }; } const workspaces = JSON.parse(interpreted.stdout) as Array<{ @@ -450,7 +696,7 @@ export class CoderService { // Exact match required (search is prefix-based) const match = workspaces.find((w) => w.name === workspaceName); if (!match) { - return { status: null, error: "Workspace not found" }; + return { kind: "not_found" }; } // Validate status against known schema values @@ -458,14 +704,14 @@ export class CoderService { const parsed = CoderWorkspaceStatusSchema.safeParse(status); if (!parsed.success) { log.warn("Unknown Coder workspace status", { workspaceName, status }); - return { status: null, error: `Unknown status: ${status}` }; + return { kind: "error", error: `Unknown status: ${status}` }; } - return { status: parsed.data }; + return { kind: "ok", status: parsed.data }; } catch (error) { const message = error instanceof Error ? error.message : String(error); log.debug("Failed to get Coder workspace status", { workspaceName, error: message }); - return { status: null, error: message }; + return { kind: "error", error: message }; } } @@ -510,7 +756,10 @@ export class CoderService { /** * Create a new Coder workspace. Yields build log lines as they arrive. - * Streams stdout and stderr concurrently to avoid blocking on either stream. + * + * Pre-fetches template parameters and passes defaults via --parameter flags + * to avoid interactive prompts during creation. + * * @param name Workspace name * @param template Template name * @param preset Optional preset name @@ -522,251 +771,164 @@ export class CoderService { preset?: string, abortSignal?: AbortSignal ): AsyncGenerator { - // TEMPORARY: Use workaround for parameter prompts. - // Delete the workaround section below when `coder create --yes` no longer prompts - // for parameters that have defaults. - yield* this._createWorkspaceWithParamWorkaround(name, template, preset, abortSignal); - } + log.debug("Creating Coder workspace", { name, template, preset }); - /** - * Delete a Coder workspace. - */ - async deleteWorkspace(name: string): Promise { - log.debug("Deleting Coder workspace", { name }); - using proc = execAsync(`coder delete ${shescape.quote(name)} --yes`); - await proc.result; - } - - /** - * Ensure SSH config is set up for Coder workspaces. - * Run before every Coder workspace connection (idempotent). - */ - async ensureSSHConfig(): Promise { - log.debug("Ensuring Coder SSH config"); - using proc = execAsync("coder config-ssh --yes"); - await proc.result; - } + if (abortSignal?.aborted) { + throw new Error("Coder workspace creation aborted"); + } - // ============================================================================ - // TEMPORARY WORKAROUND: Parameter prompt retry logic - // Delete this entire section when `coder create --yes` properly handles - // parameters with defaults. Then update createWorkspace to run `coder create` - // directly (without prompt-detection retries). - // ============================================================================ + // 1. Get deployment URL + const deploymentUrl = await this.getDeploymentUrl(); - private async *_createWorkspaceWithParamWorkaround( - name: string, - template: string, - preset: string | undefined, - abortSignal: AbortSignal | undefined - ): AsyncGenerator { - const MAX_PARAM_RETRIES = 20; - const PROMPT_TIMEOUT_MS = 5000; - - const collectedParams: Array<{ name: string; value: string }> = []; - - for (let attempt = 0; attempt < MAX_PARAM_RETRIES; attempt++) { - log.debug("Creating Coder workspace (workaround attempt)", { - name, - template, - preset, - attempt, - collectedParams, - }); + // 2. Get active template version ID + const versionId = await this.getActiveTemplateVersionId(template); - if (abortSignal?.aborted) { - throw new Error("Coder workspace creation aborted"); - } + // 3. Get parameter names covered by preset (if any) + const coveredByPreset = preset + ? await this.getPresetParamNames(template, preset) + : new Set(); - const result = await this._tryCreateDetectingPrompts( - name, - template, - preset, - collectedParams, - PROMPT_TIMEOUT_MS, - abortSignal - ); + // 4. Fetch all template parameters from API + const allParams = await this.getTemplateRichParameters(deploymentUrl, versionId, name); - if (result.type === "success") { - for (const line of result.lines) { - yield line; - } - return; - } + // 5. Validate required params have values + this.validateRequiredParams(allParams, coveredByPreset); - if (result.type === "prompt") { - log.debug("Detected parameter prompt, will retry", { param: result.param, attempt }); - // Yield collected lines so user sees progress - for (const line of result.lines) { - yield line; - } - yield `[Detected parameter prompt "${result.param.name}", retrying with default "${result.param.value}"...]`; - collectedParams.push(result.param); - continue; - } + // 6. Compute extra --parameter flags for non-ephemeral params not in preset + const extraParams = this.computeExtraParams(allParams, coveredByPreset); - // Yield collected lines before throwing so user sees CLI output - for (const line of result.lines) { - yield line; - } - throw new Error(result.error); - } - - throw new Error( - `Too many parameter prompts (${MAX_PARAM_RETRIES}). ` + - `Collected: ${collectedParams.map((p) => p.name).join(", ")}` - ); - } + log.debug("Computed extra params for coder create", { + name, + template, + preset, + extraParamCount: extraParams.length, + extraParamNames: extraParams.map((p) => p.name), + }); - private async _tryCreateDetectingPrompts( - name: string, - template: string, - preset: string | undefined, - extraParams: Array<{ name: string; value: string }>, - promptTimeoutMs: number, - abortSignal: AbortSignal | undefined - ): Promise< - | { type: "success"; lines: string[] } - | { type: "prompt"; param: { name: string; value: string }; lines: string[] } - | { type: "error"; error: string; lines: string[] } - > { + // 7. Build and run single coder create command const args = ["create", name, "-t", template, "--yes"]; if (preset) { args.push("--preset", preset); } for (const p of extraParams) { - args.push("--parameter", `${p.name}=${p.value}`); + args.push("--parameter", p.encoded); } + // Yield the command we're about to run so it's visible in UI + yield `$ coder ${args.join(" ")}`; + const child = spawn("coder", args, { stdio: ["ignore", "pipe", "pipe"], }); - const terminator = createGracefulTerminator(child); - const lines: string[] = []; - let rawOutput = ""; - let aborted = false; - let promptDetected = false; + const terminator = createGracefulTerminator(child); const abortHandler = () => { - aborted = true; terminator.terminate(); }; abortSignal?.addEventListener("abort", abortHandler); - const processStream = async (stream: NodeJS.ReadableStream): Promise => { - for await (const chunk of stream) { - const text = (chunk as Buffer).toString(); - rawOutput += text; - for (const line of text.split("\n")) { - if (line.trim()) { - lines.push(line); - } + try { + // Use an async queue to stream lines as they arrive (not buffer until end) + const lineQueue: string[] = []; + let streamsDone = false; + let resolveNext: (() => void) | null = null; + + const pushLine = (line: string) => { + lineQueue.push(line); + if (resolveNext) { + resolveNext(); + resolveNext = null; } - } - }; - - const stdoutDone = processStream(child.stdout).catch((error: unknown) => { - log.debug("Failed to read coder create stdout", { error }); - }); - const stderrDone = processStream(child.stderr).catch((error: unknown) => { - log.debug("Failed to read coder create stderr", { error }); - }); - - const exitPromise = new Promise((resolve) => { - child.on("close", (code) => resolve(code)); - child.on("error", () => resolve(null)); - }); - - const promptCheckInterval = setInterval(() => { - const parsed = this._parseParameterPrompt(rawOutput); - if (parsed) { - promptDetected = true; - terminator.terminate(); - clearInterval(promptCheckInterval); - } - }, 200); - - const exitCode = await Promise.race([ - exitPromise, - new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), promptTimeoutMs)), - ]); - - clearInterval(promptCheckInterval); - - if (exitCode === "timeout") { - const parsed = this._parseParameterPrompt(rawOutput); - if (parsed) { - terminator.terminate(); + }; - const didExit = await Promise.race([ - exitPromise.then(() => true), - new Promise((resolve) => setTimeout(() => resolve(false), 1000)), - ]); - if (didExit) { - terminator.cleanup(); + // Set up stream processing + let pending = 2; + const markDone = () => { + pending--; + if (pending === 0) { + streamsDone = true; + if (resolveNext) { + resolveNext(); + resolveNext = null; + } } + }; - abortSignal?.removeEventListener("abort", abortHandler); - return { type: "prompt", param: parsed, lines }; - } - const finalExitCode = await exitPromise; - await Promise.all([stdoutDone, stderrDone]); - terminator.cleanup(); - abortSignal?.removeEventListener("abort", abortHandler); + const processStream = (stream: NodeJS.ReadableStream | null) => { + if (!stream) { + markDone(); + return; + } + let buffer = ""; + stream.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + const parts = buffer.split("\n"); + buffer = parts.pop() ?? ""; + for (const line of parts) { + const trimmed = line.trim(); + if (trimmed) pushLine(trimmed); + } + }); + stream.on("end", () => { + if (buffer.trim()) pushLine(buffer.trim()); + markDone(); + }); + stream.on("error", markDone); + }; - if (aborted) { - return { type: "error", error: "Coder workspace creation aborted", lines }; - } - if (finalExitCode !== 0) { - return { - type: "error", - error: `coder create failed with exit code ${String(finalExitCode)}`, - lines, - }; + processStream(child.stdout); + processStream(child.stderr); + + // Yield lines as they arrive + while (!streamsDone || lineQueue.length > 0) { + if (lineQueue.length > 0) { + yield lineQueue.shift()!; + } else if (!streamsDone) { + // Wait for more data + await new Promise((resolve) => { + resolveNext = resolve; + }); + } } - return { type: "success", lines }; - } - - await Promise.all([stdoutDone, stderrDone]); - terminator.cleanup(); - abortSignal?.removeEventListener("abort", abortHandler); - if (aborted) { - return { type: "error", error: "Coder workspace creation aborted", lines }; - } + // Wait for process to exit + const exitCode = await new Promise((resolve) => { + child.on("close", resolve); + child.on("error", () => resolve(null)); + }); - if (promptDetected) { - const parsed = this._parseParameterPrompt(rawOutput); - if (parsed) { - return { type: "prompt", param: parsed, lines }; + if (abortSignal?.aborted) { + throw new Error("Coder workspace creation aborted"); } - } - if (exitCode !== 0) { - const parsed = this._parseParameterPrompt(rawOutput); - if (parsed) { - return { type: "prompt", param: parsed, lines }; + if (exitCode !== 0) { + throw new Error(`coder create failed with exit code ${String(exitCode)}`); } - return { - type: "error", - error: `coder create failed with exit code ${String(exitCode)}`, - lines, - }; + } finally { + terminator.cleanup(); + abortSignal?.removeEventListener("abort", abortHandler); } - - return { type: "success", lines }; } - private _parseParameterPrompt(output: string): { name: string; value: string } | null { - const re = /^([^\n]+)\n {2}[^\n]+\n\n> Enter a value \(default: "([^"]*)"\):/m; - const match = re.exec(output); - return match ? { name: match[1].trim(), value: match[2] } : null; + /** + * Delete a Coder workspace. + */ + async deleteWorkspace(name: string): Promise { + log.debug("Deleting Coder workspace", { name }); + using proc = execAsync(`coder delete ${shescape.quote(name)} --yes`); + await proc.result; } - // ============================================================================ - // END TEMPORARY WORKAROUND - // ============================================================================ + /** + * Ensure SSH config is set up for Coder workspaces. + * Run before every Coder workspace connection (idempotent). + */ + async ensureSSHConfig(): Promise { + log.debug("Ensuring Coder SSH config"); + using proc = execAsync("coder config-ssh --yes"); + await proc.result; + } } // Singleton instance From 781ffc91b8e3c4217ef77512f4ee7a63ddb9cc5c Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 14 Jan 2026 23:57:54 +1100 Subject: [PATCH 13/28] =?UTF-8?q?=F0=9F=A4=96=20fix:=20respect=20abort=20s?= =?UTF-8?q?ignal=20in=20ensureReady=20status=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When getWorkspaceStatus returns an error due to abort, properly return a failed EnsureReadyResult instead of optimistically proceeding. --- src/node/runtime/CoderSSHRuntime.ts | 7 ++++++- src/node/runtime/DockerRuntime.ts | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/node/runtime/CoderSSHRuntime.ts b/src/node/runtime/CoderSSHRuntime.ts index 81ce861378..9a4c8c4229 100644 --- a/src/node/runtime/CoderSSHRuntime.ts +++ b/src/node/runtime/CoderSSHRuntime.ts @@ -211,7 +211,12 @@ export class CoderSSHRuntime extends SSHRuntime { } if (statusResult.kind === "error") { - // For errors (timeout, auth hiccup, Coder CLI issues), proceed optimistically + // Check if this was an abort - don't proceed optimistically if user cancelled + if (signal?.aborted) { + emitStatus("error"); + return { ready: false, error: "Aborted", errorType: "runtime_start_failed" }; + } + // For other errors (timeout, auth hiccup, Coder CLI issues), proceed optimistically // and let SSH fail naturally to avoid blocking the happy path. log.debug("Coder workspace status unknown, proceeding optimistically", { workspaceName, diff --git a/src/node/runtime/DockerRuntime.ts b/src/node/runtime/DockerRuntime.ts index c46f83a83f..7766796362 100644 --- a/src/node/runtime/DockerRuntime.ts +++ b/src/node/runtime/DockerRuntime.ts @@ -1146,7 +1146,8 @@ export class DockerRuntime extends RemoteRuntime { const stderr = result.stderr || "Failed to start container"; // Classify error type based on stderr content - const isContainerMissing = stderr.includes("No such container") || stderr.includes("not found"); + const isContainerMissing = + stderr.includes("No such container") || stderr.includes("not found"); return { ready: false, From 782aad962d51328d9b0667688fec8c13ddee7506 Mon Sep 17 00:00:00 2001 From: ethan Date: Thu, 15 Jan 2026 00:51:21 +1100 Subject: [PATCH 14/28] fix: show all Coder workspaces in picker regardless of status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove filterRunning parameter from listWorkspaces() - return all statuses - Show status in dropdown options (e.g., 'my-workspace (template) • stopped') - Change empty state text from 'No running workspaces' to 'No workspaces found' - Make coder controls container w-fit instead of full width ensureReady() already handles starting stopped workspaces, so users should be able to see and select them in the existing workspace picker. Addresses Codex review comment on PR #1617. --- .../components/ChatInput/CoderControls.tsx | 8 +-- src/node/services/coderService.test.ts | 69 ++++++++----------- src/node/services/coderService.ts | 13 ++-- 3 files changed, 34 insertions(+), 56 deletions(-) diff --git a/src/browser/components/ChatInput/CoderControls.tsx b/src/browser/components/ChatInput/CoderControls.tsx index d1f4dc41db..535c64b951 100644 --- a/src/browser/components/ChatInput/CoderControls.tsx +++ b/src/browser/components/ChatInput/CoderControls.tsx @@ -184,7 +184,7 @@ export function CoderControls(props: CoderControlsProps) { {enabled && (
- {existingWorkspaces.length === 0 && ( - - )} + {existingWorkspaces.length === 0 && } {existingWorkspaces.length > 0 && } {existingWorkspaces.map((w) => ( ))} diff --git a/src/node/services/coderService.test.ts b/src/node/services/coderService.test.ts index 3298bcf3ec..c675b359d3 100644 --- a/src/node/services/coderService.test.ts +++ b/src/node/services/coderService.test.ts @@ -1,7 +1,11 @@ import { EventEmitter } from "events"; import { Readable } from "stream"; -import { describe, it, expect, vi, beforeEach, afterEach } from "bun:test"; +import { describe, it, expect, vi, beforeEach, afterEach, spyOn } from "bun:test"; import { CoderService, compareVersions } from "./coderService"; +import * as childProcess from "child_process"; + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const noop = () => {}; function mockExecOk(stdout: string, stderr = ""): void { mockExecAsync.mockReturnValue({ @@ -17,6 +21,12 @@ function mockExecError(error: Error): void { }); } +/** + * Mock spawn for streaming createWorkspace() tests. + * Uses spyOn instead of vi.mock to avoid polluting other test files. + */ +let spawnSpy: ReturnType> | null = null; + function mockCoderCommandResult(options: { stdout?: string; stderr?: string; @@ -26,7 +36,7 @@ function mockCoderCommandResult(options: { const stderr = Readable.from(options.stderr ? [Buffer.from(options.stderr)] : []); const events = new EventEmitter(); - mockSpawn.mockReturnValue({ + spawnSpy?.mockReturnValue({ stdout, stderr, exitCode: null, @@ -39,19 +49,8 @@ function mockCoderCommandResult(options: { // Emit close after handlers are attached. setTimeout(() => events.emit("close", options.exitCode), 0); } -// eslint-disable-next-line @typescript-eslint/no-empty-function -const noop = () => {}; - -// Mock execAsync -// Mock spawn for streaming createWorkspace() -void vi.mock("child_process", () => ({ - spawn: vi.fn(), -})); - -import { spawn } from "child_process"; - -const mockSpawn = spawn as ReturnType; +// Mock execAsync (this is safe - it's our own module, not a Node.js built-in) void vi.mock("@/node/utils/disposableExec", () => ({ execAsync: vi.fn(), })); @@ -67,10 +66,14 @@ describe("CoderService", () => { beforeEach(() => { service = new CoderService(); vi.clearAllMocks(); + // Set up spawn spy for tests that use mockCoderCommandResult + spawnSpy = spyOn(childProcess, "spawn"); }); afterEach(() => { service.clearCache(); + spawnSpy?.mockRestore(); + spawnSpy = null; }); describe("getCoderInfo", () => { @@ -245,13 +248,13 @@ describe("CoderService", () => { }); describe("listWorkspaces", () => { - it("returns only running workspaces by default", async () => { + it("returns all workspaces regardless of status", async () => { mockExecAsync.mockReturnValue({ result: Promise.resolve({ stdout: JSON.stringify([ { name: "ws-1", template_name: "t1", latest_build: { status: "running" } }, { name: "ws-2", template_name: "t2", latest_build: { status: "stopped" } }, - { name: "ws-3", template_name: "t3", latest_build: { status: "running" } }, + { name: "ws-3", template_name: "t3", latest_build: { status: "starting" } }, ]), }), [Symbol.dispose]: noop, @@ -259,28 +262,10 @@ describe("CoderService", () => { const workspaces = await service.listWorkspaces(); - expect(workspaces).toEqual([ - { name: "ws-1", templateName: "t1", status: "running" }, - { name: "ws-3", templateName: "t3", status: "running" }, - ]); - }); - - it("returns all workspaces when filterRunning is false", async () => { - mockExecAsync.mockReturnValue({ - result: Promise.resolve({ - stdout: JSON.stringify([ - { name: "ws-1", template_name: "t1", latest_build: { status: "running" } }, - { name: "ws-2", template_name: "t2", latest_build: { status: "stopped" } }, - ]), - }), - [Symbol.dispose]: noop, - }); - - const workspaces = await service.listWorkspaces(false); - expect(workspaces).toEqual([ { name: "ws-1", templateName: "t1", status: "running" }, { name: "ws-2", templateName: "t2", status: "stopped" }, + { name: "ws-3", templateName: "t3", status: "starting" }, ]); }); @@ -483,7 +468,7 @@ describe("CoderService", () => { const stderr = Readable.from([Buffer.from("err-1\n")]); const events = new EventEmitter(); - mockSpawn.mockReturnValue({ + spawnSpy!.mockReturnValue({ stdout, stderr, kill: vi.fn(), @@ -498,7 +483,7 @@ describe("CoderService", () => { lines.push(line); } - expect(mockSpawn).toHaveBeenCalledWith( + expect(spawnSpy).toHaveBeenCalledWith( "coder", ["create", "my-workspace", "-t", "my-template", "--yes"], { stdio: ["ignore", "pipe", "pipe"] } @@ -517,7 +502,7 @@ describe("CoderService", () => { const stderr = Readable.from([]); const events = new EventEmitter(); - mockSpawn.mockReturnValue({ + spawnSpy!.mockReturnValue({ stdout, stderr, kill: vi.fn(), @@ -530,7 +515,7 @@ describe("CoderService", () => { // drain } - expect(mockSpawn).toHaveBeenCalledWith( + expect(spawnSpy).toHaveBeenCalledWith( "coder", ["create", "ws", "-t", "tmpl", "--yes", "--preset", "preset"], { stdio: ["ignore", "pipe", "pipe"] } @@ -549,7 +534,7 @@ describe("CoderService", () => { const stderr = Readable.from([]); const events = new EventEmitter(); - mockSpawn.mockReturnValue({ + spawnSpy!.mockReturnValue({ stdout, stderr, kill: vi.fn(), @@ -562,7 +547,7 @@ describe("CoderService", () => { // drain } - expect(mockSpawn).toHaveBeenCalledWith( + expect(spawnSpy).toHaveBeenCalledWith( "coder", [ "create", @@ -587,7 +572,7 @@ describe("CoderService", () => { const stderr = Readable.from([]); const events = new EventEmitter(); - mockSpawn.mockReturnValue({ + spawnSpy!.mockReturnValue({ stdout, stderr, kill: vi.fn(), diff --git a/src/node/services/coderService.ts b/src/node/services/coderService.ts index a843f9a290..0401e9cdf2 100644 --- a/src/node/services/coderService.ts +++ b/src/node/services/coderService.ts @@ -523,9 +523,9 @@ export class CoderService { } /** - * List Coder workspaces. Only returns "running" workspaces by default. + * List Coder workspaces (all statuses). */ - async listWorkspaces(filterRunning = true): Promise { + async listWorkspaces(): Promise { // Derive known statuses from schema to avoid duplication and prevent ORPC validation errors const KNOWN_STATUSES = new Set(CoderWorkspaceStatusSchema.options); @@ -546,19 +546,14 @@ export class CoderService { }; }>; - // Filter to known statuses first to avoid ORPC schema validation failures - const mapped = workspaces + // Filter to known statuses to avoid ORPC schema validation failures + return workspaces .filter((w) => KNOWN_STATUSES.has(w.latest_build.status)) .map((w) => ({ name: w.name, templateName: w.template_name, status: w.latest_build.status as CoderWorkspaceStatus, })); - - if (filterRunning) { - return mapped.filter((w) => w.status === "running"); - } - return mapped; } catch (error) { // Common user state: Coder CLI installed but not configured/logged in. // Don't spam error logs for UI list calls. From 4b79a3676c84cfeb54cbadd6c2d81479ccb7b525 Mon Sep 17 00:00:00 2001 From: ethan Date: Thu, 15 Jan 2026 02:04:17 +1100 Subject: [PATCH 15/28] fix: wire runtime-status events + add delete safeguard - Add runtime-status handler to bufferedEventHandlers in WorkspaceStore so Coder startup progress is displayed in StreamingBarrier - Add safety check in coderService.deleteWorkspace() to refuse deleting workspaces without 'mux-' prefix (defense in depth) - Add tests for delete safeguard Addresses Codex review comment on PR #1617. --- src/browser/stores/WorkspaceStore.ts | 4 ++++ src/node/services/coderService.test.ts | 28 ++++++++++++++++++++++++++ src/node/services/coderService.ts | 7 +++++++ 3 files changed, 39 insertions(+) diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 61b91326eb..fcb0143b4a 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -474,6 +474,10 @@ export class WorkspaceStore { applyWorkspaceChatEventToAggregator(aggregator, data); this.states.bump(workspaceId); }, + "runtime-status": (workspaceId, aggregator, data) => { + applyWorkspaceChatEventToAggregator(aggregator, data); + this.states.bump(workspaceId); + }, "session-usage-delta": (workspaceId, _aggregator, data) => { const usageDelta = data as Extract; diff --git a/src/node/services/coderService.test.ts b/src/node/services/coderService.test.ts index c675b359d3..579f2296a3 100644 --- a/src/node/services/coderService.test.ts +++ b/src/node/services/coderService.test.ts @@ -810,6 +810,34 @@ describe("validateRequiredParams", () => { }); }); +describe("deleteWorkspace", () => { + const service = new CoderService(); + const mockExec = execAsync as ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("refuses to delete workspace without mux- prefix", async () => { + await service.deleteWorkspace("my-workspace"); + + // Should not call execAsync at all + expect(mockExec).not.toHaveBeenCalled(); + }); + + it("deletes workspace with mux- prefix", async () => { + mockExec.mockReturnValue({ + result: Promise.resolve({ stdout: "", stderr: "" }), + [Symbol.dispose]: noop, + }); + + await service.deleteWorkspace("mux-my-workspace"); + + expect(mockExec).toHaveBeenCalledWith(expect.stringContaining("coder delete")); + expect(mockExec).toHaveBeenCalledWith(expect.stringContaining("mux-my-workspace")); + }); +}); + describe("compareVersions", () => { it("returns 0 for equal versions", () => { expect(compareVersions("2.28.6", "2.28.6")).toBe(0); diff --git a/src/node/services/coderService.ts b/src/node/services/coderService.ts index 0401e9cdf2..159f24e527 100644 --- a/src/node/services/coderService.ts +++ b/src/node/services/coderService.ts @@ -908,8 +908,15 @@ export class CoderService { /** * Delete a Coder workspace. + * + * Safety: Only deletes workspaces with "mux-" prefix to prevent accidentally + * deleting user workspaces that weren't created by mux. */ async deleteWorkspace(name: string): Promise { + if (!name.startsWith("mux-")) { + log.warn("Refusing to delete Coder workspace without mux- prefix", { name }); + return; + } log.debug("Deleting Coder workspace", { name }); using proc = execAsync(`coder delete ${shescape.quote(name)} --yes`); await proc.result; From 66675ca9052a6042773242e0765926c5d596435c Mon Sep 17 00:00:00 2001 From: ethan Date: Thu, 15 Jan 2026 11:26:07 +1100 Subject: [PATCH 16/28] fix: update storybook test for new empty state text --- src/browser/stories/App.coder.stories.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/stories/App.coder.stories.tsx b/src/browser/stories/App.coder.stories.tsx index 5e39f21207..f69b88eb89 100644 --- a/src/browser/stories/App.coder.stories.tsx +++ b/src/browser/stories/App.coder.stories.tsx @@ -365,7 +365,7 @@ export const CoderNoRunningWorkspaces: AppStory = { ); await userEvent.click(existingButton); - // Workspace dropdown should show "No running workspaces" + // Workspace dropdown should show "No workspaces found" const workspaceSelect = await canvas.findByTestId( "coder-workspace-select", {}, @@ -374,10 +374,10 @@ export const CoderNoRunningWorkspaces: AppStory = { await waitFor(() => { const options = workspaceSelect.querySelectorAll("option"); const hasNoWorkspacesOption = Array.from(options).some((opt) => - opt.textContent?.includes("No running workspaces") + opt.textContent?.includes("No workspaces found") ); if (!hasNoWorkspacesOption) { - throw new Error("Should show 'No running workspaces' option"); + throw new Error("Should show 'No workspaces found' option"); } }); }, From cefb2831a153abcd9e8e4818eccd015c6397dd50 Mon Sep 17 00:00:00 2001 From: ethan Date: Thu, 15 Jan 2026 17:16:34 +1100 Subject: [PATCH 17/28] =?UTF-8?q?=F0=9F=A4=96=20fix:=20Coder=20runtime=20i?= =?UTF-8?q?mprovements=20and=20agent=20discovery=20race=20condition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix race condition where agents.list/get tried SSH before workspace init completed by adding waitForInit() calls in RPC handlers - Replace native handleTemplateChange(e.target.value)} + onValueChange={handleTemplateChange} disabled={disabled || templates.length === 0} - className="bg-bg-dark text-foreground border-border-medium focus:border-accent h-7 w-[180px] rounded-md border px-2 text-sm focus:outline-none disabled:opacity-50" - data-testid="coder-template-select" > - {templates.length === 0 && } - {templates.map((t) => ( - - ))} - + + + + + {templates.map((t) => { + // Show org name only if there are duplicate template names + const hasDuplicate = templates.some( + (other) => + other.name === t.name && other.organizationName !== t.organizationName + ); + return ( + + {t.displayName || t.name} + {hasDuplicate && ( + ({t.organizationName}) + )} + + ); + })} + + )}
@@ -266,21 +282,25 @@ export function CoderControls(props: CoderControlsProps) { {loadingPresets ? ( ) : ( - + + + + + {presets.map((p) => ( + + {p.name} + + ))} + + )}
@@ -293,21 +313,31 @@ export function CoderControls(props: CoderControlsProps) { {loadingWorkspaces ? ( ) : ( - + + + + + {existingWorkspaces.map((w) => ( + + {w.name} ({w.templateName}) • {w.status} + + ))} + + )}
)} diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 8af07b0136..3a6bd706a5 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -2,6 +2,13 @@ import React, { useCallback, useEffect } from "react"; import { RUNTIME_MODE, type RuntimeMode, type ParsedRuntime } from "@/common/types/runtime"; import type { RuntimeAvailabilityMap } from "./useCreationWorkspace"; import { Select } from "../Select"; +import { + Select as RadixSelect, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; import { Loader2, Wand2 } from "lucide-react"; import { cn } from "@/common/lib/utils"; import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; @@ -144,26 +151,28 @@ function SectionPicker(props: SectionPickerProps) { opacity: selectedSection ? 1 : 0.4, }} /> - - + + + + + {sections.map((section) => ( + + {section.name} + + ))} + +
); } diff --git a/src/browser/components/RuntimeBadge.tsx b/src/browser/components/RuntimeBadge.tsx index 2217c2c8e4..09d214cfa5 100644 --- a/src/browser/components/RuntimeBadge.tsx +++ b/src/browser/components/RuntimeBadge.tsx @@ -116,7 +116,10 @@ function getRuntimeInfo( // Coder-backed SSH runtime gets special treatment if (runtimeConfig.coder) { const coderWorkspaceName = runtimeConfig.coder.workspaceName; - return { type: "coder", label: `Coder: ${coderWorkspaceName ?? runtimeConfig.host}` }; + return { + type: "coder", + label: `Coder Workspace: ${coderWorkspaceName ?? runtimeConfig.host}`, + }; } const hostname = extractSshHostname(runtimeConfig); return { type: "ssh", label: `SSH: ${hostname ?? runtimeConfig.host}` }; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 3ef652f9af..51be443098 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -449,6 +449,10 @@ export const router = (authToken?: string) => { .input(schemas.agents.list.input) .output(schemas.agents.list.output) .handler(async ({ context, input }) => { + // Wait for workspace init before agent discovery (SSH may not be ready yet) + if (input.workspaceId) { + await context.aiService.waitForInit(input.workspaceId); + } const { runtime, discoveryPath } = await resolveAgentDiscoveryContext(context, input); const descriptors = await discoverAgentDefinitions(runtime, discoveryPath); @@ -483,6 +487,10 @@ export const router = (authToken?: string) => { .input(schemas.agents.get.input) .output(schemas.agents.get.output) .handler(async ({ context, input }) => { + // Wait for workspace init before agent discovery (SSH may not be ready yet) + if (input.workspaceId) { + await context.aiService.waitForInit(input.workspaceId); + } const { runtime, discoveryPath } = await resolveAgentDiscoveryContext(context, input); return readAgentDefinition(runtime, discoveryPath, input.agentId); }), diff --git a/src/node/runtime/CoderSSHRuntime.test.ts b/src/node/runtime/CoderSSHRuntime.test.ts index 7588258751..21a18096db 100644 --- a/src/node/runtime/CoderSSHRuntime.test.ts +++ b/src/node/runtime/CoderSSHRuntime.test.ts @@ -31,7 +31,15 @@ function createMockCoderService(overrides?: Partial): CoderService Promise.resolve({ kind: "ok" as const, status: "running" as const }) ), listWorkspaces: mock(() => Promise.resolve([])), - startWorkspaceAndWait: mock(() => Promise.resolve({ success: true })), + waitForStartupScripts: mock(() => + (async function* (): AsyncGenerator { + await Promise.resolve(); + // default: no output (startup scripts completed) + for (const line of [] as string[]) { + yield line; + } + })() + ), workspaceExists: mock(() => Promise.resolve(false)), ...overrides, } as unknown as CoderService; @@ -493,16 +501,30 @@ describe("CoderSSHRuntime.postCreateSetup", () => { expect(steps.join("\n")).toContain("Preparing workspace directory"); }); - it("skips workspace creation when existingWorkspace=true", async () => { + it("skips workspace creation when existingWorkspace=true and workspace is running", async () => { const createWorkspace = mock(() => (async function* (): AsyncGenerator { await Promise.resolve(); yield "should not happen"; })() ); + const waitForStartupScripts = mock(() => + (async function* (): AsyncGenerator { + await Promise.resolve(); + yield "Already running"; + })() + ); const ensureSSHConfig = mock(() => Promise.resolve()); + const getWorkspaceStatus = mock(() => + Promise.resolve({ kind: "ok" as const, status: "running" as const }) + ); - const coderService = createMockCoderService({ createWorkspace, ensureSSHConfig }); + const coderService = createMockCoderService({ + createWorkspace, + waitForStartupScripts, + ensureSSHConfig, + getWorkspaceStatus, + }); const runtime = createRuntime( { existingWorkspace: true, workspaceName: "existing-ws" }, coderService @@ -522,10 +544,119 @@ describe("CoderSSHRuntime.postCreateSetup", () => { }); expect(createWorkspace).not.toHaveBeenCalled(); + // waitForStartupScripts is called (it handles running workspaces quickly) + expect(waitForStartupScripts).toHaveBeenCalled(); expect(ensureSSHConfig).toHaveBeenCalled(); expect(execBufferedSpy).toHaveBeenCalled(); }); + it("uses waitForStartupScripts for existing stopped workspace (auto-starts via coder ssh)", async () => { + const createWorkspace = mock(() => + (async function* (): AsyncGenerator { + await Promise.resolve(); + yield "should not happen"; + })() + ); + const waitForStartupScripts = mock(() => + (async function* (): AsyncGenerator { + await Promise.resolve(); + yield "Starting workspace..."; + yield "Build complete"; + yield "Startup scripts finished"; + })() + ); + const ensureSSHConfig = mock(() => Promise.resolve()); + const getWorkspaceStatus = mock(() => + Promise.resolve({ kind: "ok" as const, status: "stopped" as const }) + ); + + const coderService = createMockCoderService({ + createWorkspace, + waitForStartupScripts, + ensureSSHConfig, + getWorkspaceStatus, + }); + const runtime = createRuntime( + { existingWorkspace: true, workspaceName: "existing-ws" }, + coderService + ); + + const loggedStdout: string[] = []; + await runtime.postCreateSetup({ + initLogger: { + logStep: noop, + logStdout: (line) => loggedStdout.push(line), + logStderr: noop, + logComplete: noop, + }, + projectPath: "/project", + branchName: "branch", + trunkBranch: "main", + workspacePath: "/home/user/src/my-project/existing-ws", + }); + + expect(createWorkspace).not.toHaveBeenCalled(); + expect(waitForStartupScripts).toHaveBeenCalled(); + expect(loggedStdout).toContain("Starting workspace..."); + expect(loggedStdout).toContain("Startup scripts finished"); + expect(ensureSSHConfig).toHaveBeenCalled(); + }); + + it("polls until stopping workspace becomes stopped before connecting", async () => { + let pollCount = 0; + const getWorkspaceStatus = mock(() => { + pollCount++; + // First 2 calls return "stopping", then "stopped" + if (pollCount <= 2) { + return Promise.resolve({ kind: "ok" as const, status: "stopping" as const }); + } + return Promise.resolve({ kind: "ok" as const, status: "stopped" as const }); + }); + const waitForStartupScripts = mock(() => + (async function* (): AsyncGenerator { + await Promise.resolve(); + yield "Ready"; + })() + ); + const ensureSSHConfig = mock(() => Promise.resolve()); + + const coderService = createMockCoderService({ + getWorkspaceStatus, + waitForStartupScripts, + ensureSSHConfig, + }); + + const runtime = createRuntime( + { existingWorkspace: true, workspaceName: "stopping-ws" }, + coderService + ); + + // Avoid real sleeps in this polling test + interface RuntimeWithSleep { + sleep: (ms: number, abortSignal?: AbortSignal) => Promise; + } + spyOn(runtime as unknown as RuntimeWithSleep, "sleep").mockResolvedValue(undefined); + + const loggedSteps: string[] = []; + await runtime.postCreateSetup({ + initLogger: { + logStep: (step) => loggedSteps.push(step), + logStdout: noop, + logStderr: noop, + logComplete: noop, + }, + projectPath: "/project", + branchName: "branch", + trunkBranch: "main", + workspacePath: "/home/user/src/my-project/stopping-ws", + }); + + // Should have polled status multiple times + expect(pollCount).toBeGreaterThan(2); + expect(loggedSteps.some((s) => s.includes("Waiting for Coder workspace"))).toBe(true); + expect(waitForStartupScripts).toHaveBeenCalled(); + }); + it("throws when workspaceName is missing", () => { const coderService = createMockCoderService(); const runtime = createRuntime({ existingWorkspace: false, template: "tmpl" }, coderService); @@ -579,8 +710,13 @@ describe("CoderSSHRuntime.ensureReady", () => { const getWorkspaceStatus = mock(() => Promise.resolve({ kind: "ok" as const, status: "running" as const }) ); - const startWorkspaceAndWait = mock(() => Promise.resolve({ success: true })); - const coderService = createMockCoderService({ getWorkspaceStatus, startWorkspaceAndWait }); + const waitForStartupScripts = mock(() => + (async function* (): AsyncGenerator { + await Promise.resolve(); + yield "should not be called"; + })() + ); + const coderService = createMockCoderService({ getWorkspaceStatus, waitForStartupScripts }); const runtime = createRuntime( { existingWorkspace: true, workspaceName: "my-ws" }, @@ -594,17 +730,24 @@ describe("CoderSSHRuntime.ensureReady", () => { expect(result).toEqual({ ready: true }); expect(getWorkspaceStatus).toHaveBeenCalled(); - expect(startWorkspaceAndWait).not.toHaveBeenCalled(); + // Short-circuited because status is already "running" + expect(waitForStartupScripts).not.toHaveBeenCalled(); expect(events.map((e) => e.phase)).toEqual(["checking", "ready"]); expect(events[0]?.runtimeType).toBe("ssh"); }); - it("starts the workspace when status is stopped", async () => { + it("connects via waitForStartupScripts when status is stopped (auto-starts)", async () => { const getWorkspaceStatus = mock(() => Promise.resolve({ kind: "ok" as const, status: "stopped" as const }) ); - const startWorkspaceAndWait = mock(() => Promise.resolve({ success: true })); - const coderService = createMockCoderService({ getWorkspaceStatus, startWorkspaceAndWait }); + const waitForStartupScripts = mock(() => + (async function* (): AsyncGenerator { + await Promise.resolve(); + yield "Starting workspace..."; + yield "Workspace started"; + })() + ); + const coderService = createMockCoderService({ getWorkspaceStatus, waitForStartupScripts }); const runtime = createRuntime( { existingWorkspace: true, workspaceName: "my-ws" }, @@ -617,16 +760,25 @@ describe("CoderSSHRuntime.ensureReady", () => { }); expect(result).toEqual({ ready: true }); - expect(startWorkspaceAndWait).toHaveBeenCalled(); - expect(events.map((e) => e.phase)).toEqual(["checking", "starting", "ready"]); + expect(waitForStartupScripts).toHaveBeenCalled(); + // We should see checking, then starting, then ready + expect(events[0]?.phase).toBe("checking"); + expect(events.some((e) => e.phase === "starting")).toBe(true); + expect(events.at(-1)?.phase).toBe("ready"); }); - it("returns runtime_start_failed when start fails", async () => { + it("returns runtime_start_failed when waitForStartupScripts fails", async () => { const getWorkspaceStatus = mock(() => Promise.resolve({ kind: "ok" as const, status: "stopped" as const }) ); - const startWorkspaceAndWait = mock(() => Promise.resolve({ success: false, error: "boom" })); - const coderService = createMockCoderService({ getWorkspaceStatus, startWorkspaceAndWait }); + const waitForStartupScripts = mock(() => + (async function* (): AsyncGenerator { + await Promise.resolve(); + yield "Starting workspace..."; + throw new Error("connection failed"); + })() + ); + const coderService = createMockCoderService({ getWorkspaceStatus, waitForStartupScripts }); const runtime = createRuntime( { existingWorkspace: true, workspaceName: "my-ws" }, @@ -641,7 +793,7 @@ describe("CoderSSHRuntime.ensureReady", () => { expect(result.ready).toBe(false); if (!result.ready) { expect(result.errorType).toBe("runtime_start_failed"); - expect(result.error).toContain("Failed to start"); + expect(result.error).toContain("Failed to connect"); } expect(events.at(-1)?.phase).toBe("error"); diff --git a/src/node/runtime/CoderSSHRuntime.ts b/src/node/runtime/CoderSSHRuntime.ts index 9a4c8c4229..038d0835c5 100644 --- a/src/node/runtime/CoderSSHRuntime.ts +++ b/src/node/runtime/CoderSSHRuntime.ts @@ -23,7 +23,7 @@ import type { import { SSHRuntime, type SSHRuntimeConfig } from "./SSHRuntime"; import type { CoderWorkspaceConfig, RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime } from "@/common/types/runtime"; -import type { CoderService, WorkspaceStatusResult } from "@/node/services/coderService"; +import type { CoderService } from "@/node/services/coderService"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import { log } from "@/node/services/log"; @@ -156,6 +156,13 @@ export class CoderSSHRuntime extends SSHRuntime { /** * Core ensureReady logic - called once (protected by ensureReadyPromise). + * + * Flow: + * 1. Check status via `coder list` - short-circuit for "running" or "not_found" + * 2. If "stopping"/"canceling": poll until it clears (coder ssh can't autostart during these) + * 3. Run `coder ssh --wait=yes -- true` which handles everything else: + * - stopped: auto-starts, streams build logs, waits for startup scripts + * - starting/pending: waits for build completion + startup scripts */ private async doEnsureReady( workspaceName: string, @@ -174,7 +181,7 @@ export class CoderSSHRuntime extends SSHRuntime { const remainingMs = () => Math.max(0, CoderSSHRuntime.ENSURE_READY_TIMEOUT_MS - (Date.now() - startTime)); - // Step 1: Check current status + // Step 1: Check current status for short-circuits emitStatus("checking"); if (signal?.aborted) { @@ -182,25 +189,19 @@ export class CoderSSHRuntime extends SSHRuntime { return { ready: false, error: "Aborted", errorType: "runtime_start_failed" }; } - // Helper to check if an error string indicates workspace not found (for startWorkspaceAndWait errors) - const isWorkspaceNotFoundError = (error: string | undefined): boolean => - Boolean(error && /workspace not found/i.test(error)); - - const statusResult = await this.coderService.getWorkspaceStatus(workspaceName, { + let statusResult = await this.coderService.getWorkspaceStatus(workspaceName, { timeoutMs: Math.min(remainingMs(), 10_000), signal, }); - // Helper to extract status from result, or null if not available - const getStatus = (r: WorkspaceStatusResult): string | null => - r.kind === "ok" ? r.status : null; - + // Short-circuit: already running if (statusResult.kind === "ok" && statusResult.status === "running") { this.lastActivityAtMs = Date.now(); emitStatus("ready"); return { ready: true }; } + // Short-circuit: workspace doesn't exist if (statusResult.kind === "not_found") { emitStatus("error"); return { @@ -210,50 +211,49 @@ export class CoderSSHRuntime extends SSHRuntime { }; } + // For status check errors (timeout, auth issues), proceed optimistically + // and let SSH fail naturally to avoid blocking the happy path if (statusResult.kind === "error") { - // Check if this was an abort - don't proceed optimistically if user cancelled if (signal?.aborted) { emitStatus("error"); return { ready: false, error: "Aborted", errorType: "runtime_start_failed" }; } - // For other errors (timeout, auth hiccup, Coder CLI issues), proceed optimistically - // and let SSH fail naturally to avoid blocking the happy path. log.debug("Coder workspace status unknown, proceeding optimistically", { workspaceName, error: statusResult.error, }); - this.lastActivityAtMs = Date.now(); - return { ready: true }; } - // Step 2: Handle "stopping" status - wait for it to become "stopped" - let currentStatus: string | null = getStatus(statusResult); - - if (currentStatus === "stopping") { + // Step 2: Wait for "stopping"/"canceling" to clear (coder ssh can't autostart during these) + if ( + statusResult.kind === "ok" && + (statusResult.status === "stopping" || statusResult.status === "canceling") + ) { emitStatus("waiting", "Waiting for Coder workspace to stop..."); - while (currentStatus === "stopping" && !isTimedOut()) { + while ( + statusResult.kind === "ok" && + (statusResult.status === "stopping" || statusResult.status === "canceling") && + !isTimedOut() + ) { if (signal?.aborted) { emitStatus("error"); return { ready: false, error: "Aborted", errorType: "runtime_start_failed" }; } - await this.sleep(CoderSSHRuntime.STATUS_POLL_INTERVAL_MS); - const pollResult = await this.coderService.getWorkspaceStatus(workspaceName, { + await this.sleep(CoderSSHRuntime.STATUS_POLL_INTERVAL_MS, signal); + statusResult = await this.coderService.getWorkspaceStatus(workspaceName, { timeoutMs: Math.min(remainingMs(), 10_000), signal, }); - currentStatus = getStatus(pollResult); - if (currentStatus === "running") { + // Check for state changes during polling + if (statusResult.kind === "ok" && statusResult.status === "running") { this.lastActivityAtMs = Date.now(); emitStatus("ready"); return { ready: true }; } - - // If status unavailable, only fail fast if the workspace is definitively gone. - // Otherwise fall through to start attempt (status check might have been flaky). - if (pollResult.kind === "not_found") { + if (statusResult.kind === "not_found") { emitStatus("error"); return { ready: false, @@ -261,9 +261,6 @@ export class CoderSSHRuntime extends SSHRuntime { errorType: "runtime_not_ready", }; } - if (pollResult.kind === "error") { - break; - } } if (isTimedOut()) { @@ -276,120 +273,89 @@ export class CoderSSHRuntime extends SSHRuntime { } } - // Step 3: Start the workspace and wait for it to be ready - emitStatus("starting", "Starting Coder workspace..."); - log.debug("Starting Coder workspace", { workspaceName, currentStatus }); + // Step 3: Use coder ssh --wait=yes to handle all other states + // This auto-starts stopped workspaces and waits for startup scripts + emitStatus("starting", "Connecting to Coder workspace..."); + log.debug("Connecting to Coder workspace via SSH", { workspaceName }); - const startResult = await this.coderService.startWorkspaceAndWait( - workspaceName, - remainingMs(), - signal - ); + // Create abort signal that fires on timeout or user abort + const controller = new AbortController(); + + const checkInterval = setInterval(() => { + if (isTimedOut() || signal?.aborted) { + controller.abort(); + clearInterval(checkInterval); + } + }, 1000); + controller.signal.addEventListener("abort", () => clearInterval(checkInterval), { once: true }); + if (isTimedOut() || signal?.aborted) controller.abort(); - if (startResult.success) { + try { + for await (const _line of this.coderService.waitForStartupScripts( + workspaceName, + controller.signal + )) { + // Consume output for timeout/abort handling + } this.lastActivityAtMs = Date.now(); emitStatus("ready"); return { ready: true }; - } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); - if (isWorkspaceNotFoundError(startResult.error)) { emitStatus("error"); - return { - ready: false, - error: `Coder workspace "${workspaceName}" not found`, - errorType: "runtime_not_ready", - }; - } - // Handle "build already active" - poll until running or stopped - if (startResult.error === "build_in_progress") { - log.debug("Coder workspace build already active, polling for completion", { workspaceName }); - emitStatus("waiting", "Waiting for Coder workspace build..."); - - while (!isTimedOut()) { - if (signal?.aborted) { - emitStatus("error"); - return { ready: false, error: "Aborted", errorType: "runtime_start_failed" }; - } - - await this.sleep(CoderSSHRuntime.STATUS_POLL_INTERVAL_MS); - const pollResult = await this.coderService.getWorkspaceStatus(workspaceName, { - timeoutMs: Math.min(remainingMs(), 10_000), - signal, - }); - - if (pollResult.kind === "not_found") { - emitStatus("error"); - return { - ready: false, - error: `Coder workspace "${workspaceName}" not found`, - errorType: "runtime_not_ready", - }; - } - - const pollStatus = getStatus(pollResult); - - if (pollStatus === "running") { - this.lastActivityAtMs = Date.now(); - emitStatus("ready"); - return { ready: true }; - } - - if (pollStatus === "stopped") { - // Build finished but workspace ended up stopped - retry start once - log.debug("Coder workspace stopped after build, retrying start", { workspaceName }); - emitStatus("starting", "Starting Coder workspace..."); - - const retryResult = await this.coderService.startWorkspaceAndWait( - workspaceName, - remainingMs(), - signal - ); - - if (retryResult.success) { - this.lastActivityAtMs = Date.now(); - emitStatus("ready"); - return { ready: true }; - } + if (isTimedOut()) { + return { + ready: false, + error: "Coder workspace start timed out", + errorType: "runtime_start_failed", + }; + } - if (isWorkspaceNotFoundError(retryResult.error)) { - emitStatus("error"); - return { - ready: false, - error: `Coder workspace "${workspaceName}" not found`, - errorType: "runtime_not_ready", - }; - } + if (signal?.aborted) { + return { ready: false, error: "Aborted", errorType: "runtime_start_failed" }; + } - emitStatus("error"); - return { - ready: false, - error: `Failed to start Coder workspace: ${retryResult.error ?? "unknown error"}`, - errorType: "runtime_start_failed", - }; - } + // Map "not found" errors to runtime_not_ready + if (/not found|no access/i.test(errorMsg)) { + return { + ready: false, + error: `Coder workspace "${workspaceName}" not found`, + errorType: "runtime_not_ready", + }; } - emitStatus("error"); return { ready: false, - error: "Coder workspace is still starting... Please retry shortly.", + error: `Failed to connect to Coder workspace: ${errorMsg}`, errorType: "runtime_start_failed", }; + } finally { + clearInterval(checkInterval); } - - // Other start failure - emitStatus("error"); - return { - ready: false, - error: `Failed to start Coder workspace: ${startResult.error ?? "unknown error"}`, - errorType: "runtime_start_failed", - }; } /** Promise-based sleep helper */ - private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + private sleep(ms: number, abortSignal?: AbortSignal): Promise { + if (abortSignal?.aborted) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + abortSignal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + const onAbort = () => { + clearTimeout(timeout); + abortSignal?.removeEventListener("abort", onAbort); + resolve(); + }; + + abortSignal?.addEventListener("abort", onAbort, { once: true }); + }); } /** @@ -639,12 +605,63 @@ export class CoderSSHRuntime extends SSHRuntime { initLogger.logStdout(line); } initLogger.logStep("Coder workspace created successfully"); + + // Wait for startup scripts to complete + initLogger.logStep("Waiting for startup scripts..."); + for await (const line of this.coderService.waitForStartupScripts( + coderWorkspaceName, + abortSignal + )) { + initLogger.logStdout(line); + } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); log.error("Failed to create Coder workspace", { error, config: this.coderConfig }); initLogger.logStderr(`Failed to create Coder workspace: ${errorMsg}`); throw new Error(`Failed to create Coder workspace: ${errorMsg}`); } + } else if (this.coderConfig.workspaceName) { + // For existing workspaces, wait for "stopping"/"canceling" to clear before SSH + // (coder ssh --wait=yes can't autostart while a stop/cancel build is in progress) + const workspaceName = this.coderConfig.workspaceName; + let status = await this.coderService.getWorkspaceStatus(workspaceName, { + signal: abortSignal, + }); + + if (status.kind === "ok" && (status.status === "stopping" || status.status === "canceling")) { + initLogger.logStep(`Waiting for Coder workspace "${workspaceName}" to stop...`); + while ( + status.kind === "ok" && + (status.status === "stopping" || status.status === "canceling") + ) { + if (abortSignal?.aborted) { + throw new Error("Aborted while waiting for Coder workspace to stop"); + } + await this.sleep(CoderSSHRuntime.STATUS_POLL_INTERVAL_MS, abortSignal); + status = await this.coderService.getWorkspaceStatus(workspaceName, { + signal: abortSignal, + }); + } + } + + // waitForStartupScripts (coder ssh --wait=yes) handles all other states: + // - stopped: auto-starts, streams build logs, waits for scripts + // - starting/pending: waits for build + scripts + // - running: waits for scripts (fast if already done) + initLogger.logStep(`Connecting to Coder workspace "${workspaceName}"...`); + try { + for await (const line of this.coderService.waitForStartupScripts( + workspaceName, + abortSignal + )) { + initLogger.logStdout(line); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + log.error("Failed waiting for Coder workspace", { error, config: this.coderConfig }); + initLogger.logStderr(`Failed connecting to Coder workspace: ${errorMsg}`); + throw new Error(`Failed connecting to Coder workspace: ${errorMsg}`); + } } // Ensure SSH config is set up for Coder workspaces diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index a11852e1e4..d62df99c55 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -2047,6 +2047,14 @@ export class AIService extends EventEmitter { return this.streamManager.debugTriggerStreamError(workspaceId, errorMessage); } + /** + * Wait for workspace initialization to complete (if running). + * Public wrapper for agent discovery and other callers. + */ + async waitForInit(workspaceId: string, abortSignal?: AbortSignal): Promise { + return this.initStateManager.waitForInit(workspaceId, abortSignal); + } + async deleteWorkspace(workspaceId: string): Promise> { try { const workspaceDir = this.config.getSessionDir(workspaceId); diff --git a/src/node/services/coderService.test.ts b/src/node/services/coderService.test.ts index 579f2296a3..e466f8f2c2 100644 --- a/src/node/services/coderService.test.ts +++ b/src/node/services/coderService.test.ts @@ -351,26 +351,69 @@ describe("CoderService", () => { }); }); - describe("startWorkspaceAndWait", () => { - it("returns success true on exit code 0", async () => { - mockCoderCommandResult({ exitCode: 0 }); + describe("waitForStartupScripts", () => { + it("streams stdout/stderr lines while waiting", async () => { + const stdout = Readable.from([Buffer.from("Waiting for agent...\nAgent ready\n")]); + const stderr = Readable.from([]); + const events = new EventEmitter(); - const result = await service.startWorkspaceAndWait("ws-1", 1000); + spawnSpy!.mockReturnValue({ + stdout, + stderr, + kill: vi.fn(), + on: events.on.bind(events), + } as never); - expect(result).toEqual({ success: true }); - }); + setTimeout(() => events.emit("close", 0), 0); - it("maps build already active errors to build_in_progress", async () => { - mockCoderCommandResult({ - exitCode: 1, - stderr: "workspace build is already active", + const lines: string[] = []; + for await (const line of service.waitForStartupScripts("my-ws")) { + lines.push(line); + } + + expect(lines).toContain("$ coder ssh my-ws --wait=yes -- true"); + expect(lines).toContain("Waiting for agent..."); + expect(lines).toContain("Agent ready"); + expect(spawnSpy).toHaveBeenCalledWith("coder", ["ssh", "my-ws", "--wait=yes", "--", "true"], { + stdio: ["ignore", "pipe", "pipe"], }); + }); + + it("throws when exit code is non-zero", async () => { + const stdout = Readable.from([]); + const stderr = Readable.from([Buffer.from("Connection refused\n")]); + const events = new EventEmitter(); - const result = await service.startWorkspaceAndWait("ws-1", 1000); + spawnSpy!.mockReturnValue({ + stdout, + stderr, + kill: vi.fn(), + on: events.on.bind(events), + } as never); + + setTimeout(() => events.emit("close", 1), 0); + + const lines: string[] = []; + const run = async () => { + for await (const line of service.waitForStartupScripts("my-ws")) { + lines.push(line); + } + }; - expect(result).toEqual({ success: false, error: "build_in_progress" }); + let thrown: unknown; + try { + await run(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeTruthy(); + expect(thrown instanceof Error ? thrown.message : String(thrown)).toBe( + "coder ssh --wait failed (exit 1): Connection refused" + ); }); }); + describe("createWorkspace", () => { // Capture original fetch once per describe block to avoid nested mock issues let originalFetch: typeof fetch; @@ -591,7 +634,9 @@ describe("CoderService", () => { } expect(thrown).toBeTruthy(); - expect(thrown instanceof Error ? thrown.message : String(thrown)).toContain("exit code 42"); + expect(thrown instanceof Error ? thrown.message : String(thrown)).toContain( + "coder create failed (exit 42)" + ); }); it("aborts before spawn when already aborted", async () => { diff --git a/src/node/services/coderService.ts b/src/node/services/coderService.ts index 159f24e527..1999f6fcb2 100644 --- a/src/node/services/coderService.ts +++ b/src/node/services/coderService.ts @@ -103,6 +103,129 @@ function createGracefulTerminator( return { terminate, cleanup }; } +/** + * Stream output from a coder CLI command line by line. + * Yields lines as they arrive from stdout/stderr. + * Throws on non-zero exit with stderr content in the error message. + * + * @param args Command arguments (e.g., ["start", "-y", "my-ws"]) + * @param errorPrefix Prefix for error messages (e.g., "coder start failed") + * @param abortSignal Optional signal to cancel the command + * @param abortMessage Message to throw when aborted + */ +async function* streamCoderCommand( + args: string[], + errorPrefix: string, + abortSignal?: AbortSignal, + abortMessage = "Coder command aborted" +): AsyncGenerator { + if (abortSignal?.aborted) { + throw new Error(abortMessage); + } + + // Yield the command we're about to run so it's visible in UI + yield `$ coder ${args.join(" ")}`; + + const child = spawn("coder", args, { + stdio: ["ignore", "pipe", "pipe"], + }); + + const terminator = createGracefulTerminator(child); + + const abortHandler = () => { + terminator.terminate(); + }; + abortSignal?.addEventListener("abort", abortHandler); + + try { + // Use an async queue to stream lines as they arrive + const lineQueue: string[] = []; + const stderrLines: string[] = []; + let streamsDone = false; + let resolveNext: (() => void) | null = null; + + const pushLine = (line: string) => { + lineQueue.push(line); + if (resolveNext) { + resolveNext(); + resolveNext = null; + } + }; + + let pending = 2; + const markDone = () => { + pending--; + if (pending === 0) { + streamsDone = true; + if (resolveNext) { + resolveNext(); + resolveNext = null; + } + } + }; + + const processStream = (stream: NodeJS.ReadableStream | null, isStderr: boolean) => { + if (!stream) { + markDone(); + return; + } + let buffer = ""; + stream.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + const parts = buffer.split("\n"); + buffer = parts.pop() ?? ""; + for (const line of parts) { + const trimmed = line.trim(); + if (trimmed) { + pushLine(trimmed); + if (isStderr) stderrLines.push(trimmed); + } + } + }); + stream.on("end", () => { + if (buffer.trim()) { + pushLine(buffer.trim()); + if (isStderr) stderrLines.push(buffer.trim()); + } + markDone(); + }); + stream.on("error", markDone); + }; + + processStream(child.stdout, false); + processStream(child.stderr, true); + + // Yield lines as they arrive + while (!streamsDone || lineQueue.length > 0) { + if (lineQueue.length > 0) { + yield lineQueue.shift()!; + } else if (!streamsDone) { + await new Promise((resolve) => { + resolveNext = resolve; + }); + } + } + + // Wait for process to exit + const exitCode = await new Promise((resolve) => { + child.on("close", resolve); + child.on("error", () => resolve(null)); + }); + + if (abortSignal?.aborted) { + throw new Error(abortMessage); + } + + if (exitCode !== 0) { + const errorDetail = stderrLines.length > 0 ? `: ${stderrLines.join(" | ")}` : ""; + throw new Error(`${errorPrefix} (exit ${String(exitCode)})${errorDetail}`); + } + } finally { + terminator.cleanup(); + abortSignal?.removeEventListener("abort", abortHandler); + } +} + interface CoderCommandResult { exitCode: number | null; stdout: string; @@ -711,42 +834,20 @@ export class CoderService { } /** - * Start a workspace and wait for it to be ready. - * Blocks until the workspace is running (or timeout). - * - * @param workspaceName Workspace name - * @param timeoutMs Maximum time to wait - * @param signal Optional abort signal to cancel - * @returns Object with success/error info + * Wait for Coder workspace startup scripts to complete. + * Runs `coder ssh --wait=yes -- true` and streams output. */ - async startWorkspaceAndWait( + async *waitForStartupScripts( workspaceName: string, - timeoutMs: number, - signal?: AbortSignal - ): Promise<{ success: boolean; error?: string }> { - const result = await this.runCoderCommand(["start", "-y", workspaceName], { - timeoutMs, - signal, - }); - - const interpreted = interpretCoderResult(result); - - if (interpreted.ok) { - return { success: true }; - } - - if (interpreted.error === "aborted" || interpreted.error === "timeout") { - return { success: false, error: interpreted.error }; - } - - if (interpreted.combined.includes("workspace build is already active")) { - return { success: false, error: "build_in_progress" }; - } - - return { - success: false, - error: interpreted.error, - }; + abortSignal?: AbortSignal + ): AsyncGenerator { + log.debug("Waiting for Coder startup scripts", { workspaceName }); + yield* streamCoderCommand( + ["ssh", workspaceName, "--wait=yes", "--", "true"], + "coder ssh --wait failed", + abortSignal, + "Coder startup script wait aborted" + ); } /** @@ -809,101 +910,12 @@ export class CoderService { args.push("--parameter", p.encoded); } - // Yield the command we're about to run so it's visible in UI - yield `$ coder ${args.join(" ")}`; - - const child = spawn("coder", args, { - stdio: ["ignore", "pipe", "pipe"], - }); - - const terminator = createGracefulTerminator(child); - - const abortHandler = () => { - terminator.terminate(); - }; - abortSignal?.addEventListener("abort", abortHandler); - - try { - // Use an async queue to stream lines as they arrive (not buffer until end) - const lineQueue: string[] = []; - let streamsDone = false; - let resolveNext: (() => void) | null = null; - - const pushLine = (line: string) => { - lineQueue.push(line); - if (resolveNext) { - resolveNext(); - resolveNext = null; - } - }; - - // Set up stream processing - let pending = 2; - const markDone = () => { - pending--; - if (pending === 0) { - streamsDone = true; - if (resolveNext) { - resolveNext(); - resolveNext = null; - } - } - }; - - const processStream = (stream: NodeJS.ReadableStream | null) => { - if (!stream) { - markDone(); - return; - } - let buffer = ""; - stream.on("data", (chunk: Buffer) => { - buffer += chunk.toString(); - const parts = buffer.split("\n"); - buffer = parts.pop() ?? ""; - for (const line of parts) { - const trimmed = line.trim(); - if (trimmed) pushLine(trimmed); - } - }); - stream.on("end", () => { - if (buffer.trim()) pushLine(buffer.trim()); - markDone(); - }); - stream.on("error", markDone); - }; - - processStream(child.stdout); - processStream(child.stderr); - - // Yield lines as they arrive - while (!streamsDone || lineQueue.length > 0) { - if (lineQueue.length > 0) { - yield lineQueue.shift()!; - } else if (!streamsDone) { - // Wait for more data - await new Promise((resolve) => { - resolveNext = resolve; - }); - } - } - - // Wait for process to exit - const exitCode = await new Promise((resolve) => { - child.on("close", resolve); - child.on("error", () => resolve(null)); - }); - - if (abortSignal?.aborted) { - throw new Error("Coder workspace creation aborted"); - } - - if (exitCode !== 0) { - throw new Error(`coder create failed with exit code ${String(exitCode)}`); - } - } finally { - terminator.cleanup(); - abortSignal?.removeEventListener("abort", abortHandler); - } + yield* streamCoderCommand( + args, + "coder create failed", + abortSignal, + "Coder workspace creation aborted" + ); } /** From 382d31cd429668990fb346c8833e6398e1a01df7 Mon Sep 17 00:00:00 2001 From: ethan Date: Thu, 15 Jan 2026 18:57:10 +1100 Subject: [PATCH 18/28] =?UTF-8?q?=F0=9F=A4=96=20fix:=20CI=20test=20failure?= =?UTF-8?q?s=20for=20Coder=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add getWorkspaceStatus mock to runtime.test.ts for CoderSSHRuntime tests - Add splashScreens mock to storybook orpc mock client - Fix false retry barrier appearing after init hooks complete - Add templateDisplayName to Coder workspace picker UI --- src/browser/components/AIView.tsx | 6 ++++- .../components/ChatInput/CoderControls.tsx | 7 +++-- .../Messages/ChatBarrier/RetryBarrier.tsx | 3 ++- src/browser/hooks/useResumeManager.ts | 4 ++- src/browser/stories/App.coder.stories.tsx | 21 ++++++++++++--- src/browser/stories/mocks/orpc.ts | 4 +++ .../messages/StreamingMessageAggregator.ts | 5 ++++ .../utils/messages/retryEligibility.ts | 16 ++++++++--- src/common/orpc/schemas/coder.ts | 1 + src/node/services/coderService.test.ts | 27 ++++++++++++++----- src/node/services/coderService.ts | 2 ++ tests/runtime/runtime.test.ts | 7 +++-- 12 files changed, 84 insertions(+), 19 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 68db99bda3..6b9b1acefd 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -441,7 +441,11 @@ const AIViewInner: React.FC = ({ // Uses same logic as useResumeManager for DRY const showRetryBarrier = workspaceState ? !workspaceState.canInterrupt && - hasInterruptedStream(workspaceState.messages, workspaceState.pendingStreamStartTime) + hasInterruptedStream( + workspaceState.messages, + workspaceState.pendingStreamStartTime, + workspaceState.runtimeStatus + ) : false; // Handle keyboard shortcuts (using optional refs that are safe even if not initialized) diff --git a/src/browser/components/ChatInput/CoderControls.tsx b/src/browser/components/ChatInput/CoderControls.tsx index 0dccb497c8..c021ebb8d2 100644 --- a/src/browser/components/ChatInput/CoderControls.tsx +++ b/src/browser/components/ChatInput/CoderControls.tsx @@ -308,7 +308,7 @@ export function CoderControls(props: CoderControlsProps) { {/* Existing workspace controls - min-h matches New mode (2×h-7 + gap-1 + p-2) */} {mode === "existing" && ( -
+
{loadingWorkspaces ? ( @@ -333,7 +333,10 @@ export function CoderControls(props: CoderControlsProps) { {existingWorkspaces.map((w) => ( - {w.name} ({w.templateName}) • {w.status} + {w.name} + + ({w.templateDisplayName} • {w.status}) + ))} diff --git a/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx index 269639e4b4..335852690b 100644 --- a/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -171,7 +171,8 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa // Check if current state is eligible for auto-retry const messagesEligible = isEligibleForAutoRetry( workspaceState.messages, - workspaceState.pendingStreamStartTime + workspaceState.pendingStreamStartTime, + workspaceState.runtimeStatus ); // Also check RetryState for SendMessageErrors (from resumeStream failures) diff --git a/src/browser/hooks/useResumeManager.ts b/src/browser/hooks/useResumeManager.ts index d13d88a13c..d79bf4ed01 100644 --- a/src/browser/hooks/useResumeManager.ts +++ b/src/browser/hooks/useResumeManager.ts @@ -105,7 +105,9 @@ export function useResumeManager() { // 1. Must have interrupted stream that's eligible for auto-retry (not currently streaming) if (state.canInterrupt) return false; // Currently streaming - if (!isEligibleForAutoRetry(state.messages, state.pendingStreamStartTime)) { + if ( + !isEligibleForAutoRetry(state.messages, state.pendingStreamStartTime, state.runtimeStatus) + ) { return false; } diff --git a/src/browser/stories/App.coder.stories.tsx b/src/browser/stories/App.coder.stories.tsx index f69b88eb89..6c319b6f72 100644 --- a/src/browser/stories/App.coder.stories.tsx +++ b/src/browser/stories/App.coder.stories.tsx @@ -74,9 +74,24 @@ const mockPresetsK8s: CoderPreset[] = [ /** Mock existing Coder workspaces */ const mockWorkspaces: CoderWorkspace[] = [ - { name: "mux-dev", templateName: "coder-on-coder", status: "running" }, - { name: "api-testing", templateName: "kubernetes-dev", status: "running" }, - { name: "frontend-v2", templateName: "coder-on-coder", status: "running" }, + { + name: "mux-dev", + templateName: "coder-on-coder", + templateDisplayName: "Coder on Coder", + status: "running", + }, + { + name: "api-testing", + templateName: "kubernetes-dev", + templateDisplayName: "Kubernetes Dev", + status: "running", + }, + { + name: "frontend-v2", + templateName: "coder-on-coder", + templateDisplayName: "Coder on Coder", + status: "running", + }, ]; /** diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 2549a84bca..fe0450e8e2 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -348,6 +348,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl track: () => Promise.resolve(undefined), status: () => Promise.resolve({ enabled: true, explicit: false }), }, + splashScreens: { + getViewedSplashScreens: () => Promise.resolve([]), + markSplashScreenViewed: () => Promise.resolve(undefined), + }, signing: { capabilities: () => Promise.resolve( diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index e6a042f961..9d3b539c9e 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -1544,6 +1544,11 @@ export class StreamingMessageAggregator { clearTimeout(this.initOutputThrottleTimer); this.initOutputThrottleTimer = null; } + // Reset pending stream start time so the grace period starts fresh after init completes. + // This prevents false retry barriers for slow init (e.g., Coder workspace provisioning). + if (this.pendingStreamStartTime !== null) { + this.setPendingStreamStartTime(Date.now()); + } this.invalidateCache(); return; } diff --git a/src/browser/utils/messages/retryEligibility.ts b/src/browser/utils/messages/retryEligibility.ts index 5b466cdd80..24c2d6f40b 100644 --- a/src/browser/utils/messages/retryEligibility.ts +++ b/src/browser/utils/messages/retryEligibility.ts @@ -1,5 +1,6 @@ import type { DisplayedMessage } from "@/common/types/message"; import type { StreamErrorType, SendMessageError } from "@/common/types/errors"; +import type { RuntimeStatusEvent } from "@/common/types/stream"; /** * Debug flag to force all errors to be retryable @@ -79,7 +80,8 @@ export function isNonRetryableSendError(error: SendMessageError): boolean { */ export function hasInterruptedStream( messages: DisplayedMessage[], - pendingStreamStartTime: number | null = null + pendingStreamStartTime: number | null = null, + runtimeStatus: RuntimeStatusEvent | null = null ): boolean { if (messages.length === 0) return false; @@ -105,6 +107,13 @@ export function hasInterruptedStream( return false; } + // Don't show retry barrier if runtime is still starting (e.g., Coder workspace waiting for startup scripts). + // The backend's ensureReady() is still running - this happens when reconnecting to a stopped workspace. + // runtimeStatus is set during ensureReady() and cleared when ready/error. + if (runtimeStatus !== null && lastMessage.type !== "stream-error") { + return false; + } + // ask_user_question is a special case: an unfinished tool call represents an // intentional "waiting for user input" state, not a stream interruption. // @@ -140,10 +149,11 @@ export function hasInterruptedStream( */ export function isEligibleForAutoRetry( messages: DisplayedMessage[], - pendingStreamStartTime: number | null = null + pendingStreamStartTime: number | null = null, + runtimeStatus: RuntimeStatusEvent | null = null ): boolean { // First check if there's an interrupted stream at all - if (!hasInterruptedStream(messages, pendingStreamStartTime)) { + if (!hasInterruptedStream(messages, pendingStreamStartTime, runtimeStatus)) { return false; } diff --git a/src/common/orpc/schemas/coder.ts b/src/common/orpc/schemas/coder.ts index 3defda9067..ce7c680430 100644 --- a/src/common/orpc/schemas/coder.ts +++ b/src/common/orpc/schemas/coder.ts @@ -66,6 +66,7 @@ export type CoderWorkspaceStatus = z.infer; export const CoderWorkspaceSchema = z.object({ name: z.string(), templateName: z.string(), + templateDisplayName: z.string(), status: CoderWorkspaceStatusSchema, }); diff --git a/src/node/services/coderService.test.ts b/src/node/services/coderService.test.ts index e466f8f2c2..932b1dbaba 100644 --- a/src/node/services/coderService.test.ts +++ b/src/node/services/coderService.test.ts @@ -252,9 +252,24 @@ describe("CoderService", () => { mockExecAsync.mockReturnValue({ result: Promise.resolve({ stdout: JSON.stringify([ - { name: "ws-1", template_name: "t1", latest_build: { status: "running" } }, - { name: "ws-2", template_name: "t2", latest_build: { status: "stopped" } }, - { name: "ws-3", template_name: "t3", latest_build: { status: "starting" } }, + { + name: "ws-1", + template_name: "t1", + template_display_name: "t1", + latest_build: { status: "running" }, + }, + { + name: "ws-2", + template_name: "t2", + template_display_name: "t2", + latest_build: { status: "stopped" }, + }, + { + name: "ws-3", + template_name: "t3", + template_display_name: "t3", + latest_build: { status: "starting" }, + }, ]), }), [Symbol.dispose]: noop, @@ -263,9 +278,9 @@ describe("CoderService", () => { const workspaces = await service.listWorkspaces(); expect(workspaces).toEqual([ - { name: "ws-1", templateName: "t1", status: "running" }, - { name: "ws-2", templateName: "t2", status: "stopped" }, - { name: "ws-3", templateName: "t3", status: "starting" }, + { name: "ws-1", templateName: "t1", templateDisplayName: "t1", status: "running" }, + { name: "ws-2", templateName: "t2", templateDisplayName: "t2", status: "stopped" }, + { name: "ws-3", templateName: "t3", templateDisplayName: "t3", status: "starting" }, ]); }); diff --git a/src/node/services/coderService.ts b/src/node/services/coderService.ts index 1999f6fcb2..8afc0c0e4d 100644 --- a/src/node/services/coderService.ts +++ b/src/node/services/coderService.ts @@ -664,6 +664,7 @@ export class CoderService { const workspaces = JSON.parse(stdout) as Array<{ name: string; template_name: string; + template_display_name: string; latest_build: { status: string; }; @@ -675,6 +676,7 @@ export class CoderService { .map((w) => ({ name: w.name, templateName: w.template_name, + templateDisplayName: w.template_display_name || w.template_name, status: w.latest_build.status as CoderWorkspaceStatus, })); } catch (error) { diff --git a/tests/runtime/runtime.test.ts b/tests/runtime/runtime.test.ts index b591e26467..2a8e3f6e93 100644 --- a/tests/runtime/runtime.test.ts +++ b/tests/runtime/runtime.test.ts @@ -1420,8 +1420,11 @@ describeIntegration("Runtime integration tests", () => { const { CoderSSHRuntime } = await import("@/node/runtime/CoderSSHRuntime"); const { CoderService } = await import("@/node/services/coderService"); - // Mock CoderService - forkWorkspace doesn't use it, so minimal mock is fine - const mockCoderService = {} as InstanceType; + // Mock CoderService with methods that CoderSSHRuntime may call + const mockCoderService = { + getWorkspaceStatus: () => + Promise.resolve({ kind: "running" as const, status: "running" as const }), + } as unknown as InstanceType; return new CoderSSHRuntime( { From 0efc8802ec5fc008bb1d7000f4c283863889a2b3 Mon Sep 17 00:00:00 2001 From: ethan Date: Thu, 15 Jan 2026 19:45:36 +1100 Subject: [PATCH 19/28] =?UTF-8?q?=F0=9F=A4=96=20fix:=20CI=20test=20failure?= =?UTF-8?q?s=20for=20integration=20and=20storybook=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing getWorkspaceStatus and waitForStartupScripts mocks to CoderSSHRuntime test for postCreateSetup after fork test - Return viewed splash screen ID in storybook mock to prevent modal from blocking interactions with pointer-events: none --- src/browser/stories/mocks/orpc.ts | 2 +- tests/runtime/runtime.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index fe0450e8e2..ca7f9c74b9 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -349,7 +349,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl status: () => Promise.resolve({ enabled: true, explicit: false }), }, splashScreens: { - getViewedSplashScreens: () => Promise.resolve([]), + getViewedSplashScreens: () => Promise.resolve(["mux-gateway-login"]), markSplashScreenViewed: () => Promise.resolve(undefined), }, signing: { diff --git a/tests/runtime/runtime.test.ts b/tests/runtime/runtime.test.ts index 2a8e3f6e93..c64db4d474 100644 --- a/tests/runtime/runtime.test.ts +++ b/tests/runtime/runtime.test.ts @@ -1520,6 +1520,11 @@ describeIntegration("Runtime integration tests", () => { ensureSSHConfig: async () => { // This SHOULD be called - it's safe and idempotent }, + getWorkspaceStatus: () => + Promise.resolve({ kind: "running" as const, status: "running" as const }), + waitForStartupScripts: async function* () { + // Yield nothing - workspace is already running + }, } as unknown as InstanceType; const runtime = new CoderSSHRuntime( From 90d5c230f4528aa7528e7f78a25c25ff95ea0e74 Mon Sep 17 00:00:00 2001 From: ethan Date: Thu, 15 Jan 2026 20:06:28 +1100 Subject: [PATCH 20/28] =?UTF-8?q?=F0=9F=A4=96=20fix:=20Replace=20vi.mock?= =?UTF-8?q?=20with=20spyOn=20to=20prevent=20test=20pollution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: vi.mock() in coderService.test.ts polluted the module cache globally, causing execAsync to return undefined for all other tests. This is a known Bun bug (oven-sh/bun#12823). Fixes: - Replace vi.mock with spyOn pattern in coderService.test.ts - Fix Storybook test selector (Radix Select doesn't use native options) - Use targeted mockRestore() instead of global mock.restore() - Also includes coder token deletion fix (remove --yes flag) --- src/browser/stories/App.coder.stories.tsx | 13 +- src/node/runtime/CoderSSHRuntime.test.ts | 4 +- src/node/services/coderService.test.ts | 263 ++++++++++------------ src/node/services/coderService.ts | 2 +- 4 files changed, 134 insertions(+), 148 deletions(-) diff --git a/src/browser/stories/App.coder.stories.tsx b/src/browser/stories/App.coder.stories.tsx index 6c319b6f72..d7fe15082b 100644 --- a/src/browser/stories/App.coder.stories.tsx +++ b/src/browser/stories/App.coder.stories.tsx @@ -380,19 +380,18 @@ export const CoderNoRunningWorkspaces: AppStory = { ); await userEvent.click(existingButton); - // Workspace dropdown should show "No workspaces found" + // Workspace dropdown should show "No workspaces found" placeholder + // Note: Radix UI Select doesn't render native