diff --git a/backend/src/__tests__/compose-service.test.ts b/backend/src/__tests__/compose-service.test.ts index 299eac11..fc06a25f 100644 --- a/backend/src/__tests__/compose-service.test.ts +++ b/backend/src/__tests__/compose-service.test.ts @@ -254,22 +254,21 @@ describe('ComposeService - runCommand', () => { await expectation; }); - it('kills and rejects running commands when the WebSocket disconnects', async () => { + it('keeps the command running when the WebSocket disconnects (progress socket is output-only)', async () => { const proc = createMockProcess(); mockSpawn.mockReturnValue(proc); const ws = createMockWs(); const svc = ComposeService.getInstance(1); const promise = svc.runCommand('my-stack', 'restart', ws); - const expectation = expect(promise).rejects.toThrow('client disconnected'); - let settled = false; - promise.finally(() => { settled = true; }).catch(() => undefined); + // The deploy is owned by its HTTP request; closing the progress socket + // (panel minimized, navigated away, connection blip) must not abort it. ws.emit('close'); + expect(proc.kill).not.toHaveBeenCalled(); - expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); - expect(settled).toBe(false); - proc.emit('close', null); - await expectation; + // The command still completes on its own exit, not the socket close. + proc.emit('close', 0); + await expect(promise).resolves.toBeUndefined(); }); it('rewrites ENOMEM spawn failures as host out-of-memory', async () => { diff --git a/backend/src/__tests__/generic-ws-terminal.test.ts b/backend/src/__tests__/generic-ws-terminal.test.ts new file mode 100644 index 00000000..b838a8f7 --- /dev/null +++ b/backend/src/__tests__/generic-ws-terminal.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; +import WebSocket, { type WebSocketServer } from 'ws'; +import { attachGenericConnectionHandlers, getTerminalWs } from '../websocket/generic'; + +class FakeWs extends EventEmitter { + readyState: number = WebSocket.OPEN; +} + +/** Simulate a client opening the generic /ws socket and registering for deploy + * output via the connectTerminal handshake. */ +function connect(wss: EventEmitter, sessionId?: string): FakeWs { + const ws = new FakeWs(); + wss.emit('connection', ws); + ws.emit('message', Buffer.from(JSON.stringify({ action: 'connectTerminal', ...(sessionId ? { sessionId } : {}) }))); + return ws; +} + +describe('generic ws terminal registry', () => { + let wss: EventEmitter; + beforeEach(() => { + wss = new EventEmitter(); + attachGenericConnectionHandlers(wss as unknown as WebSocketServer); + }); + + it('routes output to the socket matching the deploy session id', () => { + const a = connect(wss, 'sess-a'); + const b = connect(wss, 'sess-b'); + expect(getTerminalWs('sess-a')).toBe(a); + expect(getTerminalWs('sess-b')).toBe(b); + expect(getTerminalWs('not-a-session')).toBeUndefined(); + }); + + it('falls back to the most recent id-less socket', () => { + const a = connect(wss); + expect(getTerminalWs()).toBe(a); + const b = connect(wss); + expect(getTerminalWs()).toBe(b); + }); + + it('drops a session from the registry when its socket closes', () => { + const a = connect(wss, 'sess-x'); + expect(getTerminalWs('sess-x')).toBe(a); + a.emit('close'); + expect(getTerminalWs('sess-x')).toBeUndefined(); + }); + + it('ignores a registered socket that is no longer open', () => { + const a = connect(wss, 'sess-y'); + a.readyState = WebSocket.CLOSED; + expect(getTerminalWs('sess-y')).toBeUndefined(); + }); + + it('rebinds a socket to a new session id and drops the old mapping', () => { + const a = connect(wss, 'sess-1'); + // Same socket re-registers under a new id (a second deploy in the same tab). + a.emit('message', Buffer.from(JSON.stringify({ action: 'connectTerminal', sessionId: 'sess-2' }))); + expect(getTerminalWs('sess-1')).toBeUndefined(); + expect(getTerminalWs('sess-2')).toBe(a); + }); + + it('clears the id-less fallback when its socket closes', () => { + const a = connect(wss); + expect(getTerminalWs()).toBe(a); + a.emit('close'); + expect(getTerminalWs()).toBeUndefined(); + }); + + it('does not expose a keyed socket as the id-less fallback', () => { + // A headerless operation (bulk / rollback / legacy) resolves via getTerminalWs() + // with no id; it must never reach a keyed deploy modal's socket. + const a = connect(wss, 'sess-keyed'); + expect(getTerminalWs('sess-keyed')).toBe(a); + expect(getTerminalWs()).toBeUndefined(); + }); + + it('drops the id-less fallback when that socket later adopts a session id', () => { + const a = connect(wss); + expect(getTerminalWs()).toBe(a); + a.emit('message', Buffer.from(JSON.stringify({ action: 'connectTerminal', sessionId: 'sess-late' }))); + expect(getTerminalWs()).toBeUndefined(); + expect(getTerminalWs('sess-late')).toBe(a); + }); +}); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 4a1cbe15..6a51ff81 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -25,7 +25,7 @@ import { sendGitSourceError } from '../utils/gitSourceHttp'; import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan } from '../helpers/policyGate'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants'; -import { getTerminalWs } from '../websocket/generic'; +import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic'; // Authenticated users with edit permission can write arbitrarily large compose // files. Refuse to YAML.parse anything beyond this bound so a malformed (or @@ -311,7 +311,7 @@ async function runStackBulkOp( }; } const atomic = effectiveTier(req) === 'paid'; - await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(), atomic); + await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic); DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName); NotificationService.getInstance().broadcastEvent({ type: 'state-invalidate', @@ -892,7 +892,7 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => { const debug = isDebugEnabled(); const atomic = effectiveTier(req) === 'paid'; if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId }); - await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), atomic); + await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic); invalidateNodeCaches(req.nodeId); dlog(`[Stacks] Deploy completed: ${sanitizeForLog(stackName)}`); if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`); @@ -938,7 +938,7 @@ stacksRouter.post('/:stackName/down', async (req: Request, res: Response) => { let ok = false; try { if (isDebugEnabled()) console.debug(`[Stacks:debug] Down starting`, { stackName: sanitizeForLog(stackName), nodeId: req.nodeId }); - await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', getTerminalWs()); + await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', getTerminalWs(req.get(DEPLOY_SESSION_HEADER))); invalidateNodeCaches(req.nodeId); dlog(`[Stacks] Down completed: ${sanitizeForLog(stackName)}`); ok = true; @@ -1152,7 +1152,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => { const debug = isDebugEnabled(); const atomic = effectiveTier(req) === 'paid'; if (debug) console.debug('[Stacks:debug] Update starting', { stackName, atomic, nodeId: req.nodeId }); - await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(), atomic); + await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic); DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName); invalidateNodeCaches(req.nodeId); NotificationService.getInstance().broadcastEvent({ @@ -1209,7 +1209,7 @@ stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) => dlog(`[Stacks] Rollback initiated: ${sanitizeForLog(stackName)}`); await fsSvc.restoreStackFiles(stackName); if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return; - await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), false); + await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), false); invalidateNodeCaches(req.nodeId); dlog(`[Stacks] Rollback completed: ${sanitizeForLog(stackName)}`); res.json({ message: 'Stack rolled back successfully.' }); diff --git a/backend/src/routes/templates.ts b/backend/src/routes/templates.ts index cbaf1333..69f31f2c 100644 --- a/backend/src/routes/templates.ts +++ b/backend/src/routes/templates.ts @@ -14,7 +14,7 @@ import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; import { runPolicyGate, triggerPostDeployScan } from '../helpers/policyGate'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; -import { getTerminalWs } from '../websocket/generic'; +import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic'; export const templatesRouter = Router(); @@ -125,7 +125,7 @@ templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Respon return; } const atomic = effectiveTier(req) === 'paid'; - await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), atomic); + await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic); invalidateNodeCaches(req.nodeId); console.log(`[Templates] Deploy completed: ${stackName}`); res.json({ success: true, message: 'Template deployed successfully' }); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index acb64d63..46f17dce 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -131,9 +131,6 @@ export class ComposeService { clearTimeout(forceKillTimeout); forceKillTimeout = null; } - if (ws) { - ws.removeListener('close', onClientDisconnect); - } }; const finish = (complete: () => void) => { @@ -161,21 +158,17 @@ export class ComposeService { }, 5000); }; - const onClientDisconnect = () => { - const message = 'Command cancelled because the client disconnected'; - terminateChild(new Error(message)); - }; - + // The progress socket is output-only: a deploy/update/down is owned by the + // HTTP request that started it, so closing or losing the socket (the user + // minimizes the panel, navigates away, or the connection blips) must not + // terminate the compose process. Termination is driven solely by the + // command timeout below. timeout = setTimeout(() => { const message = `Command timed out after ${Math.round(timeoutMs / 1000)}s`; sendOutput(`${message}\n`); terminateChild(new Error(message)); }, timeoutMs); - if (ws) { - ws.once('close', onClientDisconnect); - } - const onData = (data: Buffer) => { const text = data.toString(); errorLog += text; diff --git a/backend/src/websocket/generic.ts b/backend/src/websocket/generic.ts index 3d748cdf..9e2edfaf 100644 --- a/backend/src/websocket/generic.ts +++ b/backend/src/websocket/generic.ts @@ -8,19 +8,28 @@ import { isDebugEnabled } from '../utils/debug'; import { rejectUpgrade as reject } from './reject'; /** - * Module-scope singleton: the most recent WebSocket to send - * `{action: 'connectTerminal'}` receives streaming output from any subsequent - * compose deploy/down/update. Routes that want to echo compose progress read - * the current value via `getTerminalWs()`. - * - * Intentionally single-instance. If multiple clients connect, the last one - * wins. This matches pre-refactor behavior; race-hardening is a separate - * concern. + * Header the deploy/update/down routes carry the per-deploy correlation id on, + * mirroring the `sessionId` the frontend sends in `{action:'connectTerminal'}`. + * Must stay in sync with `DEPLOY_SESSION_HEADER` in `frontend/src/lib/api.ts`. */ -let terminalWs: WebSocket | undefined; +export const DEPLOY_SESSION_HEADER = 'x-deploy-session-id'; -export function getTerminalWs(): WebSocket | undefined { - return terminalWs; +/** + * Registry of compose-progress sockets keyed by the per-deploy correlation id + * the frontend sends on `{action:'connectTerminal', sessionId}` and echoes on + * the deploy/update/down POST via {@link DEPLOY_SESSION_HEADER}. A route resolves + * the socket for its own deploy with `getTerminalWs(sessionId)`, so concurrent + * deploys from different tabs or users never cross-stream output to each other. + * + * `lastTerminalWs` is the fallback for callers that connect or stream without a + * session id (bulk operations, legacy clients): the most recent such socket wins. + */ +const terminalRegistry = new Map(); +let lastTerminalWs: WebSocket | undefined; + +export function getTerminalWs(sessionId?: string): WebSocket | undefined { + const ws = sessionId ? terminalRegistry.get(sessionId) : lastTerminalWs; + return ws && ws.readyState === WebSocket.OPEN ? ws : undefined; } interface GenericContext { @@ -85,6 +94,7 @@ export function handleGenericWs( export function attachGenericConnectionHandlers(wss: WebSocketServer): void { wss.on('connection', (ws) => { console.log('WebSocket connected'); + let registeredSessionId: string | undefined; ws.on('message', (message) => { try { @@ -92,7 +102,24 @@ export function attachGenericConnectionHandlers(wss: WebSocketServer): void { if (!data.action) return; if (data.action === 'connectTerminal') { - terminalWs = ws; + const sessionId = typeof data.sessionId === 'string' && data.sessionId ? data.sessionId : undefined; + // Rebind this socket to the new id, dropping any prior mapping it held. + if (registeredSessionId && registeredSessionId !== sessionId && terminalRegistry.get(registeredSessionId) === ws) { + terminalRegistry.delete(registeredSessionId); + } + registeredSessionId = sessionId; + if (sessionId) { + terminalRegistry.set(sessionId, ws); + // A keyed socket must never be the id-less fallback, or a headerless + // operation (bulk / rollback / legacy) would stream into this user's + // keyed deploy modal. + if (lastTerminalWs === ws) lastTerminalWs = undefined; + } else { + lastTerminalWs = ws; + } + if (isDebugEnabled()) { + console.debug('[Deploy:diag] progress stream registered', { session: sessionId ? `${sessionId.slice(0, 8)}…` : '(none)' }); + } } else if (data.action === 'streamStats') { const requestedId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId(); // When a WS is proxied from a gateway to this remote instance, the @@ -117,5 +144,12 @@ export function attachGenericConnectionHandlers(wss: WebSocketServer): void { // Malformed JSON - ignore silently } }); + + ws.on('close', () => { + if (registeredSessionId && terminalRegistry.get(registeredSessionId) === ws) { + terminalRegistry.delete(registeredSessionId); + } + if (lastTerminalWs === ws) lastTerminalWs = undefined; + }); }); } diff --git a/docs/features/deploy-progress.mdx b/docs/features/deploy-progress.mdx index 7e911488..007263a5 100644 --- a/docs/features/deploy-progress.mdx +++ b/docs/features/deploy-progress.mdx @@ -20,7 +20,7 @@ The modal opens automatically when you trigger an action. It floats centered in ### What the modal shows - **Header**: the action verb (Deploying, Updating, Installing, Restarting, Stopping), the stack name in monospace truncated at 200 px, and an elapsed-time chip that appears once the connection moves past the initial **Connecting...** state. -- **Status indicator** in the upper right: one of `Connecting...` while the stream attaches, a live ` lines` counter while output is flowing, `Succeeded · closes in s` after a clean finish, or the failure message itself when the run errors out. +- **Status indicator** in the upper right: one of `Connecting...` while the stream attaches, a live ` lines` counter while output is flowing, `Succeeded · closes in s` after a clean finish, or the failure message itself when the run errors out. If the live stream cannot attach or drops, the indicator switches to `Live progress unavailable` and the operation keeps running in the background. - **Structured log body**: one row per output line. Each row carries a timestamp, a fixed-width stage badge, and the log message. Error rows have a destructive left border and tinted background so they stand out without scanning. - **Footer**: a `Raw output` / `Hide raw` toggle on the left, with **Minimize** and **Close** buttons on the right. @@ -76,6 +76,8 @@ Click **Minimize** to collapse the modal to a small status pill anchored at the The pill is mounted as a portal, so it persists across navigation: leave the App Store mid-install and the pill follows you to the dashboard, the editor, or any other view. +Minimizing, closing, or navigating away never cancels the operation. The progress view only displays output; the deploy, update, or stop runs to completion on its own. If you dismiss the view while live output is flowing, the run still finishes in the background, and its success or failure lands in your notifications. + Minimized deploy progress pill anchored at the bottom center of a stack editor view, showing the brand-colored pulsing dot and the text 'Updating docs-demo' @@ -95,8 +97,11 @@ The HTTP API also exposes a `down` action (compose-level teardown) that streams ## Troubleshooting - - The modal opens a WebSocket to the same host and port as the rest of the API and registers itself as the recipient of compose output. If the connection cannot upgrade, the status stays at **Connecting...** indefinitely. Check that an upstream proxy or firewall is not blocking WebSocket upgrades. For nginx, ensure `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection "upgrade";` are configured. See the [configuration guide](/getting-started/configuration#reverse-proxy-setup) for a full nginx example. + + The modal opens a WebSocket to the same host and port as the rest of the API to receive compose output. If that connection cannot upgrade, the modal switches to **Live progress unavailable** and the operation continues without live output, finishing in the background with its result in your notifications. To restore live streaming, check that an upstream proxy or firewall is not blocking WebSocket upgrades. For nginx, ensure `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection "upgrade";` are configured. See the [configuration guide](/getting-started/configuration#reverse-proxy-setup) for a full nginx example. + + + That is expected. The deploy, update, or stop is driven by the action you triggered, not by the progress stream. The stream is a live view layered on top, so if it cannot connect or drops partway, the operation still runs to completion. Reopen the stack or check your notifications to confirm the final result. Restart and Stop hit the Docker Engine directly to act on the existing containers; they do not invoke `docker compose`, so there is no compose stream to render. The modal still opens to confirm the action and to surface a failure if one occurs. For a fully populated structured log, use **Deploy** or **Update** instead. diff --git a/e2e/deploy-log-panel.spec.ts b/e2e/deploy-log-panel.spec.ts index 931257b4..a89bd7fc 100644 --- a/e2e/deploy-log-panel.spec.ts +++ b/e2e/deploy-log-panel.spec.ts @@ -266,6 +266,40 @@ test.describe('Deploy feedback modal', () => { await expect(modal).toBeVisible({ timeout: 5_000 }); }); + test('deploy continues to success after minimizing mid-deploy', async ({ page }) => { + test.setTimeout(120_000); + + await enableDeployFeedback(page); + await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE); + await syncDeployFeedbackState(page); + + await page.getByTestId('stack-deploy-button').click(); + + const modal = page.locator('[data-testid="deploy-feedback-modal"]'); + await expect(modal).toBeVisible({ timeout: 10_000 }); + + // Wait until output is actually streaming so the minimize happens mid-deploy, + // not after a too-fast completion (which would pass vacuously). + await Promise.race([ + page.getByText(/Connecting\.\.\./i).waitFor({ state: 'visible', timeout: 10_000 }), + page.getByText(/\d+ lines?/i).waitFor({ state: 'visible', timeout: 10_000 }), + ]); + + // Minimize while the deploy is in flight. The progress socket closes when + // the dialog unmounts; the deploy is owned by its HTTP request and must + // keep running. Before the output-only fix this aborted the deploy with a + // "client disconnected" failure. + await page.getByRole('button', { name: 'Minimize' }).first().click(); + const pill = page.locator('[data-testid="deploy-feedback-pill"]'); + await expect(pill).toBeVisible({ timeout: 5_000 }); + + // Re-expand and confirm the deploy reached success, never a disconnect failure. + await pill.click(); + await expect(modal).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText('Succeeded')).toBeVisible({ timeout: 90_000 }); + await expect(page.getByText(/client disconnected/i)).toHaveCount(0); + }); + test.afterEach(async ({ page }) => { await deleteStackViaApi(page, HAPPY_STACK); await deleteStackViaApi(page, FAIL_STACK); diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index a9279dd8..64cb06c5 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -9,7 +9,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Search, Rocket, Loader2, Info, ExternalLink, Star, ShieldCheck } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { cn } from '@/lib/utils'; -import { apiFetch } from '@/lib/api'; +import { apiFetch, withDeploySession } from '@/lib/api'; import { useDeployFeedback } from '@/context/DeployFeedbackContext'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; @@ -189,9 +189,9 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { }); try { - const result = await runWithLog({ stackName: stackName.trim(), action: 'install' }, async (started) => { + const result = await runWithLog({ stackName: stackName.trim(), action: 'install' }, async (started, ds) => { await started; - const res = await apiFetch('/templates/deploy', { + const res = await apiFetch('/templates/deploy', withDeploySession(ds, { method: 'POST', body: JSON.stringify({ stackName: stackName.trim(), @@ -199,7 +199,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { envVars: finalEnvVars, skip_scan: !autoScan, }), - }); + })); const data = await res.json(); if (!res.ok) return { ok: false, errorMessage: data.error || 'Failed to deploy template' }; return { ok: true }; diff --git a/frontend/src/components/DeployFeedbackModal.tsx b/frontend/src/components/DeployFeedbackModal.tsx index d9f1d6bb..48b6c8f2 100644 --- a/frontend/src/components/DeployFeedbackModal.tsx +++ b/frontend/src/components/DeployFeedbackModal.tsx @@ -32,7 +32,7 @@ function formatElapsed(seconds: number): string { } export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackModalProps) { - const { panelState, logRows, onTerminalReady, onMessage, onPanelClose } = useDeployFeedback(); + const { panelState, logRows, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback(); const { isPaid } = useLicense(); const [showRaw, setShowRaw] = useState(false); @@ -46,7 +46,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM const autoCloseHoveredRef = useRef(false); const scrollRef = useRef(null); - const { isOpen, stackName, action, status, errorMessage } = panelState; + const { isOpen, stackName, action, status, errorMessage, progressUnavailable } = panelState; useEffect(() => { if (isOpen) { @@ -190,6 +190,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
{logRows.length === 0 ? ( - + ) : (
{logRows.map((row) => ( @@ -248,7 +249,9 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM style={{ height: showRaw ? '200px' : 0, overflow: 'hidden' }} >
@@ -293,12 +296,24 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM interface StatusIndicatorProps { status: 'preparing' | 'streaming' | 'succeeded' | 'failed'; + progressUnavailable: boolean; rowCount: number; errorMessage?: string; countdown: number; } -function StatusIndicator({ status, rowCount, errorMessage, countdown }: StatusIndicatorProps) { +function StatusIndicator({ status, progressUnavailable, rowCount, errorMessage, countdown }: StatusIndicatorProps) { + // While the deploy is still in flight (preparing/streaming) but the progress + // socket is gone, the deploy keeps running server-side with no live output. + if (progressUnavailable && (status === 'preparing' || status === 'streaming')) { + return ( +
+ + Live progress unavailable +
+ ); + } + if (status === 'preparing') { return (
@@ -343,9 +358,18 @@ function StatusIndicator({ status, rowCount, errorMessage, countdown }: StatusIn interface EmptyBodyProps { status: 'preparing' | 'streaming' | 'succeeded' | 'failed'; + progressUnavailable: boolean; } -function EmptyBody({ status }: EmptyBodyProps) { +function EmptyBody({ status, progressUnavailable }: EmptyBodyProps) { + if (progressUnavailable && (status === 'preparing' || status === 'streaming')) { + return ( +
+ Live progress is unavailable for this deploy. It continues running in the background. +
+ ); + } + if (status === 'preparing') { return (
diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index 1b8ea583..db32bb43 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -6,7 +6,14 @@ import type { useStackListState } from './useStackListState'; import type { useViewNavigationState } from './useViewNavigationState'; import type { OverlayState } from './useOverlayState'; -vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/lib/api', () => ({ + apiFetch: vi.fn(), + DEPLOY_SESSION_HEADER: 'x-deploy-session-id', + withDeploySession: (deploySessionId: string, options: RequestInit = {}) => ({ + ...options, + headers: { ...(options.headers as Record | undefined), 'x-deploy-session-id': deploySessionId }, + }), +})); vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, })); @@ -72,7 +79,7 @@ function makeOverlay(): OverlayState { } const runWithLog: Parameters[0]['runWithLog'] = async (_p, run) => - run(Promise.resolve()); + run(Promise.resolve(), 'test-session'); function setup(over: { editorState?: Partial } = {}) { const editorState = makeEditorState(over.editorState); diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 4bfdfab0..64268077 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -1,5 +1,5 @@ import { useRef, useCallback, useEffect } from 'react'; -import { apiFetch } from '@/lib/api'; +import { apiFetch, withDeploySession } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import type { useEditorViewState } from './useEditorViewState'; import type { useStackListState } from './useStackListState'; @@ -61,7 +61,7 @@ interface UseStackActionsOptions { isPaid: boolean; runWithLog: ( params: { stackName: string; action: ActionVerb }, - run: (deployStarted: Promise) => Promise, + run: (deployStarted: Promise, deploySessionId: string) => Promise, ) => Promise; diffPreviewEnabled: boolean; } @@ -511,6 +511,7 @@ export function useStackActions(options: UseStackActionsOptions) { stackFile: string, ignorePolicy: boolean, started?: Promise, + deploySessionId?: string, ): Promise => { const previousStatus = stackListState.stackStatuses[stackFile]; stackListState.setOptimisticStatus(stackFile, 'running'); @@ -519,7 +520,7 @@ export function useStackActions(options: UseStackActionsOptions) { ? `/stacks/${stackName}/deploy?ignorePolicy=true` : `/stacks/${stackName}/deploy`; if (started) await started; - const response = await apiFetch(path, { method: 'POST' }); + const response = await apiFetch(path, withDeploySession(deploySessionId ?? '', { method: 'POST' })); if (!response.ok) { const rawBody = await response.text(); if (response.status === 409) { @@ -598,8 +599,8 @@ export function useStackActions(options: UseStackActionsOptions) { const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); stackListState.setStackAction(stackFile, 'deploy'); try { - await runWithLog({ stackName, action: 'deploy' }, started => - runDeploy(stackName, stackFile, false, started), + await runWithLog({ stackName, action: 'deploy' }, (started, ds) => + runDeploy(stackName, stackFile, false, started, ds), ); } finally { stackListState.clearStackAction(stackFile); @@ -624,8 +625,8 @@ export function useStackActions(options: UseStackActionsOptions) { overlayState.setPolicyBypassing(true); stackListState.setStackAction(existingFile, 'deploy'); try { - await runWithLog({ stackName, action: 'deploy' }, started => - runDeploy(stackName, existingFile, true, started), + await runWithLog({ stackName, action: 'deploy' }, (started, ds) => + runDeploy(stackName, existingFile, true, started, ds), ); } finally { overlayState.setPolicyBypassing(false); @@ -719,10 +720,10 @@ export function useStackActions(options: UseStackActionsOptions) { stackListState.setStackAction(stackFile, action); stackListState.setOptimisticStatus(stackFile, optimisticStatus); try { - await runWithLog({ stackName, action }, async (started) => { + await runWithLog({ stackName, action }, async (started, ds) => { await started; try { - const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' }); + const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, withDeploySession(ds, { method: 'POST' })); if (!response.ok) { const errText = await response.text(); if (response.status === 409) { diff --git a/frontend/src/components/Terminal.tsx b/frontend/src/components/Terminal.tsx index e9707519..8221f015 100644 --- a/frontend/src/components/Terminal.tsx +++ b/frontend/src/components/Terminal.tsx @@ -7,11 +7,17 @@ import { buildXtermTheme } from '@/lib/terminalTheme'; interface TerminalComponentProps { stackName?: string; + /** Correlation id sent on the generic `connectTerminal` handshake so the + * backend streams the matching deploy's output to this socket. */ + deploySessionId?: string; onReady?: () => void; + /** Fired when the socket fails to connect or drops while still mounted, so the + * caller can stop waiting on a best-effort progress stream. */ + onError?: () => void; onMessage?: (text: string) => void; } -export default function TerminalComponent({ stackName, onReady, onMessage }: TerminalComponentProps) { +export default function TerminalComponent({ stackName, deploySessionId, onReady, onError, onMessage }: TerminalComponentProps) { const terminalRef = useRef(null); const terminalInstance = useRef(null); const fitAddonRef = useRef(null); @@ -124,8 +130,9 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter ws.onopen = () => { if (mounted) { if (!cleanStackName) { - // Generic terminal mode - send connect action - ws.send(JSON.stringify({ action: 'connectTerminal' })); + // Generic terminal mode - send connect action with the deploy + // correlation id so the backend streams this deploy's output here. + ws.send(JSON.stringify({ action: 'connectTerminal', sessionId: deploySessionId })); onReady?.(); } // For stack logs mode, the server starts streaming automatically on connection @@ -142,10 +149,21 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter ws.onerror = (err) => { console.error('WebSocket error:', err); + if (mounted) onError?.(); + }; + + ws.onclose = () => { + // Only surface unexpected closes. Intentional teardown sets mounted + // false before closing, so this skips minimize/navigation/unmount. + if (mounted) onError?.(); }; } catch (err) { console.error('Error initializing terminal:', err); + // A synchronous setup failure (e.g. WebSocket construction blocked by + // CSP) gives no onerror/onclose, so release the deploy gate now instead + // of waiting out the connect timeout. + if (mounted) onError?.(); } }; @@ -195,7 +213,7 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter searchAddonRef.current = null; serializeAddonRef.current = null; }; - }, [stackName, onReady, onMessage]); + }, [stackName, deploySessionId, onReady, onError, onMessage]); const handleDownload = () => { if (!serializeAddonRef.current) return; diff --git a/frontend/src/components/sidebar/__tests__/usePanelSessionStartedAt.test.tsx b/frontend/src/components/sidebar/__tests__/usePanelSessionStartedAt.test.tsx index 9cb0f533..73836210 100644 --- a/frontend/src/components/sidebar/__tests__/usePanelSessionStartedAt.test.tsx +++ b/frontend/src/components/sidebar/__tests__/usePanelSessionStartedAt.test.tsx @@ -9,6 +9,8 @@ function panel(over: Partial = {}): DeployPanelState { stackName: '', action: 'deploy', status: 'preparing', + progressUnavailable: false, + deploySessionId: '', sessionId: 0, ...over, }; diff --git a/frontend/src/components/sidebar/__tests__/useSidebarActivitySummary.test.ts b/frontend/src/components/sidebar/__tests__/useSidebarActivitySummary.test.ts index 05b99b9e..462766ee 100644 --- a/frontend/src/components/sidebar/__tests__/useSidebarActivitySummary.test.ts +++ b/frontend/src/components/sidebar/__tests__/useSidebarActivitySummary.test.ts @@ -19,9 +19,9 @@ function notif(overrides: Partial = {}): NotificationItem { }; } -const IDLE_PANEL: DeployPanelState = { isOpen: false, stackName: '', action: 'deploy', status: 'preparing', sessionId: 0 }; -const STREAMING_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'streaming', sessionId: 1 }; -const SUCCEEDED_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'succeeded', sessionId: 1 }; +const IDLE_PANEL: DeployPanelState = { isOpen: false, stackName: '', action: 'deploy', status: 'preparing', progressUnavailable: false, deploySessionId: '', sessionId: 0 }; +const STREAMING_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'streaming', progressUnavailable: false, deploySessionId: '', sessionId: 1 }; +const SUCCEEDED_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'succeeded', progressUnavailable: false, deploySessionId: '', sessionId: 1 }; function inputs(overrides: Partial[0]> = {}) { return { diff --git a/frontend/src/context/DeployFeedbackContext.tsx b/frontend/src/context/DeployFeedbackContext.tsx index a8d8a729..06a4b616 100644 --- a/frontend/src/context/DeployFeedbackContext.tsx +++ b/frontend/src/context/DeployFeedbackContext.tsx @@ -20,6 +20,18 @@ export interface DeployPanelState { action: ActionVerb; status: 'preparing' | 'streaming' | 'succeeded' | 'failed'; errorMessage?: string; + /** + * True once the progress socket has failed to connect or dropped. The deploy + * is owned by its HTTP request, so it still runs to completion; this only + * tells the UI that live output is no longer arriving. + */ + progressUnavailable: boolean; + /** + * Per-deploy correlation id sent to the backend on both the `connectTerminal` + * WebSocket message and the deploy POST header, so concurrent deploys never + * cross-stream output. Empty until the first runWithLog call. + */ + deploySessionId: string; /** * Monotonic id incremented on every runWithLog call. Lets external * consumers (e.g. the sidebar footer elapsed-time tracker) detect a new @@ -37,11 +49,12 @@ interface RunResult { interface DeployFeedbackContextValue { runWithLog: ( params: { stackName: string; action: ActionVerb }, - run: (deployStarted: Promise) => Promise + run: (deployStarted: Promise, deploySessionId: string) => Promise ) => Promise; panelState: DeployPanelState; logRows: ParsedLogRow[]; onTerminalReady: () => void; + onTerminalError: () => void; onMessage: (text: string) => void; onPanelClose: () => void; } @@ -51,19 +64,45 @@ const DEFAULT_PANEL_STATE: DeployPanelState = { stackName: '', action: 'deploy', status: 'preparing', + progressUnavailable: false, + deploySessionId: '', sessionId: 0, }; +/** + * Upper bound on the parsed rows kept in memory for one deploy. A verbose deploy + * (many services, many image layers) can emit thousands of lines; past this cap + * the oldest rows are dropped and a single sentinel row marks the truncation, so + * state and the rendered DOM stay bounded. The xterm raw view keeps its own + * 10k-line scrollback independently. + */ +const MAX_LOG_ROWS = 5000; +const TRUNCATION_ROW_ID = 'row-truncated'; + +/** + * Last-resort fallback: if the progress socket neither opens nor errors within + * this window, release the deploy anyway so a silently stalled connection never + * blocks the deploy itself. Connect failures normally resolve far sooner via + * onTerminalError. + */ +const PROGRESS_CONNECT_TIMEOUT_MS = 8000; + const DeployFeedbackContext = createContext(undefined); export function DeployFeedbackProvider({ children }: { children: React.ReactNode }): React.ReactElement { const [panelState, setPanelState] = useState(DEFAULT_PANEL_STATE); const [logRows, setLogRows] = useState([]); - // Holds the resolver for the current session's deployStarted promise. - // Updated at the start of each runWithLog call; not state because - // changing it must not trigger a re-render. - const readyResolverRef = useRef<(() => void) | null>(null); + // Idempotent resolver for the current session's deployStarted gate. Set at the + // start of each runWithLog call; called by onTerminalReady (stream connected), + // onTerminalError (stream failed/dropped), or the connect-timeout fallback. + // Not state: changing it must not trigger a re-render. + const settleStartRef = useRef<(() => void) | null>(null); + + // Whether the progress stream has connected for the current session. Lets + // onTerminalError distinguish a connect failure (release the deploy gate) from + // a mid-stream drop (gate already released; just mark output unavailable). + const streamReadyRef = useRef(false); // Tracks whether a session is still active so a cancelled session // cannot mutate state for the new session that replaced it. @@ -76,22 +115,43 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode const [isEnabled] = useDeployFeedbackEnabled(); const onTerminalReady = useCallback(() => { - setPanelState((prev) => ({ ...prev, status: 'streaming' })); - if (readyResolverRef.current !== null) { - readyResolverRef.current(); - readyResolverRef.current = null; - } + streamReadyRef.current = true; + setPanelState((prev) => (prev.status === 'preparing' ? { ...prev, status: 'streaming' } : prev)); + // Give the connectTerminal handshake a beat to register on the backend + // before the deploy POST fires, so the first lines are not missed. + setTimeout(() => settleStartRef.current?.(), 50); + }, []); + + const onTerminalError = useCallback(() => { + // The progress socket failed to connect or dropped mid-stream. The deploy is + // owned by its HTTP request, not this socket, so flag that live output is + // gone and, if the stream never connected, release the gate so the deploy + // still fires. + setPanelState((prev) => (prev.isOpen ? { ...prev, progressUnavailable: true } : prev)); + if (!streamReadyRef.current) settleStartRef.current?.(); }, []); const onMessage = useCallback((text: string) => { const newRows = parseLogChunk(text, idCounterRef.current); idCounterRef.current += newRows.length; - setLogRows((prev) => [...prev, ...newRows]); + setLogRows((prev) => { + const combined = prev.length > 0 && prev[0].id === TRUNCATION_ROW_ID + ? [...prev.slice(1), ...newRows] + : [...prev, ...newRows]; + if (combined.length <= MAX_LOG_ROWS) return combined; + const kept = combined.slice(combined.length - MAX_LOG_ROWS); + return [ + { id: TRUNCATION_ROW_ID, timestamp: kept[0].timestamp, stage: 'LOG', level: 'info', + message: `... earlier output truncated (showing last ${MAX_LOG_ROWS} lines) ...`, raw: '' }, + ...kept, + ]; + }); }, []); const onPanelClose = useCallback(() => { sessionIdRef.current += 1; - readyResolverRef.current = null; + settleStartRef.current = null; + streamReadyRef.current = false; setPanelState(DEFAULT_PANEL_STATE); setLogRows([]); }, []); @@ -99,15 +159,26 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode const runWithLog = useCallback( async ( params: { stackName: string; action: ActionVerb }, - run: (deployStarted: Promise) => Promise + run: (deployStarted: Promise, deploySessionId: string) => Promise ): Promise => { + // Unique, unguessable per-deploy id correlating the progress socket with + // the deploy POST so concurrent deploys cannot read each other's output. + // Uses crypto.getRandomValues (the one Crypto member available in insecure + // contexts, so it works over LAN HTTP, unlike crypto.randomUUID). When the + // feature is disabled the id is never registered on the backend, so output + // simply streams nowhere. + const idBytes = new Uint8Array(16); + crypto.getRandomValues(idBytes); + const deploySessionId = Array.from(idBytes, (b) => b.toString(16).padStart(2, '0')).join(''); + if (!isEnabled) { - return run(Promise.resolve()); + return run(Promise.resolve(), deploySessionId); } // Cancel any existing session before starting a new one. sessionIdRef.current += 1; const mySession = sessionIdRef.current; + streamReadyRef.current = false; // idCounterRef is intentionally not reset; keys must remain globally unique across sessions. setLogRows([]); @@ -117,18 +188,34 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode stackName: params.stackName, action: params.action, status: 'preparing', + progressUnavailable: false, + deploySessionId, sessionId: mySession, }); const deployStarted = new Promise((resolve) => { - readyResolverRef.current = () => { - setTimeout(resolve, 50); + let done = false; + const settle = () => { + if (done) return; + done = true; + clearTimeout(timer); + resolve(); }; + // settle only runs asynchronously (timer fire or onTerminalReady/Error), + // by which point `timer` is assigned, so the forward reference is safe. + const timer = setTimeout(() => { + // The stream neither connected nor errored within the window. Mark live + // output unavailable (so the modal stops showing "Connecting...") and + // release the deploy so a silent stall never blocks it. + setPanelState((prev) => (prev.isOpen ? { ...prev, progressUnavailable: true } : prev)); + settle(); + }, PROGRESS_CONNECT_TIMEOUT_MS); + settleStartRef.current = settle; }); let result: RunResult; try { - result = await run(deployStarted); + result = await run(deployStarted, deploySessionId); } catch (err) { const message = err instanceof Error ? err.message : 'An unexpected error occurred'; result = { ok: false, errorMessage: message }; @@ -149,7 +236,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode return ( {children} diff --git a/frontend/src/context/__tests__/DeployFeedbackContext.test.tsx b/frontend/src/context/__tests__/DeployFeedbackContext.test.tsx new file mode 100644 index 00000000..96babe58 --- /dev/null +++ b/frontend/src/context/__tests__/DeployFeedbackContext.test.tsx @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { DeployFeedbackProvider, useDeployFeedback } from '../DeployFeedbackContext'; +import { DEPLOY_FEEDBACK_KEY } from '@/hooks/use-deploy-feedback-enabled'; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe('DeployFeedbackContext', () => { + beforeEach(() => { + localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'true'); + }); + afterEach(() => { + localStorage.clear(); + }); + + it('releases the deploy when the progress stream fails before connecting', async () => { + const { result } = renderHook(() => useDeployFeedback(), { wrapper }); + + let deployRan = false; + let outer: Promise | undefined; + await act(async () => { + outer = result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => { + await started; + deployRan = true; + return { ok: true }; + }); + // Let runWithLog install the start gate and let run() reach `await started`. + await Promise.resolve(); + }); + + // The deploy must not have fired yet: it is gated on the progress stream. + expect(deployRan).toBe(false); + + // A connect failure (e.g. the admin-only /ws gate rejecting a scoped deployer, + // or a reverse proxy blocking the upgrade) must release the gate, not hang. + await act(async () => { + result.current.onTerminalError(); + await outer; + }); + + expect(deployRan).toBe(true); + expect(result.current.panelState.progressUnavailable).toBe(true); + expect(result.current.panelState.status).toBe('succeeded'); + }); + + it('marks progress unavailable on a mid-stream drop without re-running or blocking the deploy', async () => { + const { result } = renderHook(() => useDeployFeedback(), { wrapper }); + + let runCount = 0; + let outer: Promise | undefined; + await act(async () => { + outer = result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => { + await started; + runCount += 1; + return { ok: true }; + }); + await Promise.resolve(); + }); + + // Stream connects -> gate releases (after the 50ms buffer) -> deploy runs once. + await act(async () => { + result.current.onTerminalReady(); + await outer; + }); + expect(runCount).toBe(1); + expect(result.current.panelState.status).toBe('succeeded'); + + // A late socket drop only flags unavailability; it must not re-settle or re-run. + act(() => { + result.current.onTerminalError(); + }); + expect(result.current.panelState.progressUnavailable).toBe(true); + expect(runCount).toBe(1); + }); + + it('releases the deploy via the connect timeout when the stream never signals', async () => { + vi.useFakeTimers(); + try { + const { result } = renderHook(() => useDeployFeedback(), { wrapper }); + + let runCount = 0; + let outer: Promise | undefined; + await act(async () => { + outer = result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => { + await started; + runCount += 1; + return { ok: true }; + }); + await Promise.resolve(); + }); + + expect(runCount).toBe(0); + // Neither ready nor error fires; the 8s fallback must still release the deploy + // and flag live output unavailable so the modal stops showing "Connecting...". + await act(async () => { + await vi.advanceTimersByTimeAsync(8000); + await outer; + }); + expect(runCount).toBe(1); + expect(result.current.panelState.progressUnavailable).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('replaces the truncation sentinel instead of stacking it on repeated overflow', () => { + const { result } = renderHook(() => useDeployFeedback(), { wrapper }); + + act(() => { + result.current.onMessage(Array.from({ length: 6000 }, (_, i) => `a ${i}`).join('\n')); + }); + act(() => { + result.current.onMessage('b 0\nb 1'); + }); + + expect(result.current.logRows.length).toBe(5001); + expect(result.current.logRows.filter((r) => r.id === 'row-truncated').length).toBe(1); + expect(result.current.logRows[0].id).toBe('row-truncated'); + expect(result.current.logRows[result.current.logRows.length - 1].message).toContain('b 1'); + }); + + it('runs immediately with no panel when the feature is disabled', async () => { + localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'false'); + const { result } = renderHook(() => useDeployFeedback(), { wrapper }); + + let deployRan = false; + await act(async () => { + await result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => { + await started; + deployRan = true; + return { ok: true }; + }); + }); + + expect(deployRan).toBe(true); + expect(result.current.panelState.isOpen).toBe(false); + }); + + it('caps log rows and marks the truncation point', () => { + const { result } = renderHook(() => useDeployFeedback(), { wrapper }); + + const chunk = Array.from({ length: 6000 }, (_, i) => `line ${i}`).join('\n'); + act(() => { + result.current.onMessage(chunk); + }); + + expect(result.current.logRows.length).toBe(5001); + expect(result.current.logRows[0].id).toBe('row-truncated'); + expect(result.current.logRows[result.current.logRows.length - 1].message).toContain('line 5999'); + }); +}); diff --git a/frontend/src/lib/__tests__/api.test.ts b/frontend/src/lib/__tests__/api.test.ts index 897a20b8..93b073f1 100644 --- a/frontend/src/lib/__tests__/api.test.ts +++ b/frontend/src/lib/__tests__/api.test.ts @@ -8,7 +8,7 @@ * when the caller supplied any `headers` value. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { apiFetch } from '../api'; +import { apiFetch, DEPLOY_SESSION_HEADER, withDeploySession } from '../api'; const originalFetch = globalThis.fetch; @@ -74,3 +74,28 @@ describe('apiFetch header merge', () => { localStorage.removeItem('sencho-active-node'); }); }); + +describe('withDeploySession', () => { + it('uses the canonical header name (kept in sync with the backend)', () => { + expect(DEPLOY_SESSION_HEADER).toBe('x-deploy-session-id'); + }); + + it('tags a bare POST with the session header', () => { + const opts = withDeploySession('sess-123', { method: 'POST' }); + expect(opts.method).toBe('POST'); + expect((opts.headers as Record)[DEPLOY_SESSION_HEADER]).toBe('sess-123'); + }); + + it('preserves caller body and headers without clobbering them', () => { + const opts = withDeploySession('sess-abc', { + method: 'POST', + body: JSON.stringify({ a: 1 }), + headers: { 'X-Custom': 'keep' }, + }); + const headers = opts.headers as Record; + expect(opts.method).toBe('POST'); + expect(opts.body).toBe(JSON.stringify({ a: 1 })); + expect(headers['X-Custom']).toBe('keep'); + expect(headers[DEPLOY_SESSION_HEADER]).toBe('sess-abc'); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 688cc898..b2750d3d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,6 +6,27 @@ export interface ApiFetchOptions extends RequestInit { localOnly?: boolean; } +/** Header carrying a deploy's progress-stream correlation id. Mirrors the + * `sessionId` the frontend sends in the `connectTerminal` WebSocket message so + * the backend streams compose output to the matching socket. + * Must stay in sync with `DEPLOY_SESSION_HEADER` in `backend/src/websocket/generic.ts`. */ +export const DEPLOY_SESSION_HEADER = 'x-deploy-session-id'; + +/** Tag a deploy/update/down POST with its progress-stream session id, preserving + * any caller-supplied options and headers. */ +export function withDeploySession( + deploySessionId: string, + options: ApiFetchOptions = {}, +): ApiFetchOptions { + return { + ...options, + headers: { + ...(options.headers as Record | undefined), + [DEPLOY_SESSION_HEADER]: deploySessionId, + }, + }; +} + export async function apiFetch( endpoint: string, options: ApiFetchOptions = {}