feat(deploy): live SSE progress for deploy_component#531
Conversation
|
Reviewed; no blockers found. |
When the CLI sends `Accept: text/event-stream` on `deploy_component`, the operations API now returns Server-Sent Events instead of a single buffered response. A ProgressEmitter is attached to the operation request and the handler emits `phase` events at the extract → install → load → replicate → restart boundaries; the stream terminates with a `done` event (carrying the operation result) or an `error` event. The CLI parses the stream live, rendering each phase as it happens so multi-minute deploys no longer look hung. Non-SSE callers see no behavior change — the emitter is undefined on that path and every emission is optional-chained. Builds on #530. First slice of #526. Follow-ups: streaming live npm install stdout/stderr as `install` events, and re-emitting per-peer SSE events once the direct-HTTPS replication relay lands in #524 follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
676513c to
bf19a2a
Compare
…ress Pulls in slice 1's post-rebase state (the .js→.ts conversion of cliOperations and common_utils on main). Slice 2 conflicts: - bin/cliOperations.ts: kept slice 1's ESM imports + layered in the SSE consumer import (parseSSE, renderDeployProgress). - utility/common_utils.ts: kept slice 2's streamResponse short-circuit inside the new typed response handler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… into feat/deploy-component-progress
… into feat/deploy-component-progress
…eaders access
Two bugs flagged by review:
1. Blocker — `req.progress` (a ProgressEmitter) leaked into
`replicateOperation(req)`, which serialises the request and forwards
it to peer nodes. Functions don't survive that serialisation, so
peers received `{ progress: { listeners: [] } }` — a truthy plain
object whose `emit()` method throws `TypeError`. Every SSE deploy on
a cluster would fail on every peer node.
Fix: `delete req.progress` before the replicateOperation call. The
local alias `const progress = req.progress` keeps the origin's
emitter intact for the post-replicate restart phase events.
2. Test break — the SSE branch in `handlePostRequest` read
`req.headers.accept` unguarded. Existing unit tests dispatch through
that path with synthetic req shapes that don't set headers; production
Fastify requests always do. Optional-chained the access.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two visibility improvements requested mid-review for #531: 1. **Upload progress bar.** The CLI now computes the uncompressed source- tree total (`getPackagedDirectorySize`, mirrors the same skip_node_modules/skip_symlinks predicates as `streamPackagedDirectory`) and wraps the multipart body in a counting Transform that drives a `cli-progress` SingleBar. In a non-TTY environment (CI logs, redirected output) the renderer falls back to periodic `Uploaded X / ~Y (Z%)` lines logged on each 10% step, so logs stay grep-able. Percentage is against uncompressed source bytes, not wire bytes — the actual upload is gzipped and finishes slightly before 100%, an acceptable trade-off versus walking the tree twice. 2. **Live `npm install` stdout/stderr.** `nonInteractiveSpawn` accepts an optional `onLine` callback; `installApplication` plumbs it through all three install code paths (custom command, devEngines packageManager, npm fallback). Lines are buffered until a newline so a chunk that splits mid-line doesn't fire a half-event; trailing partial lines are flushed on close. The deploy progress emitter fires `install { manager, stream, line }` SSE events; the CLI renderer prefixes each line with `|` (stdout) or `!` (stderr) and shows the manager name once. Install-done summarises with a log-line count so the user knows how much they just saw. Was deferred as a follow-up in the original #531 PR description; brought forward at reviewer request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When useSse=true, httpRequest uses streamResponse mode and returns a raw IncomingMessage. The previous fallback branch read response.body which is undefined in that mode (e.g. on a 401 auth failure). Drain the stream chunks manually so error bodies are captured correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
heskew
left a comment
There was a problem hiding this comment.
🤖 PR Reviewed by Antigravity AI Coding Assistant.
Overall, the implementation of Server-Sent Events for deploy progress is excellent. The ProgressEmitter is cleanly decoupled and resilient. We have left three inline suggestions regarding split-chunk UTF-8 decoding, resource cleanup on early disconnect, and error parsing robustness.
…, error stringify - parseSSE: use StringDecoder so multi-byte chars split across chunk boundaries decode correctly instead of corrupting (e.g. box-drawing chars, emojis) - createSSEResponseStream: gate writes behind an active flag; listen to stream close/end to unsubscribe the emitter immediately on client disconnect so the subscription doesn't linger for the full operation lifetime - cliOperations SSE error: JSON.stringify fallback when sseError has no .message so the error renders as JSON rather than [object Object] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🤖 Interactive Review Suggestions by Antigravity AI Coding Assistant.
I have converted my previous review findings into interactive GitHub Suggested Changes. You can now apply these fixes with a single click directly inside the PR file viewer!
Well, 3.5 flash is eager (and fast) but not really picking up on needed skills and memory yet...
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
heskew
left a comment
There was a problem hiding this comment.
🤖 PR Reviewed by Gemini CLI
I've identified a minor specification compliance issue regarding multi-line SSE data. See the suggestion below.
Per the SSE spec, if a data value contains newlines each line must be emitted as a separate data: field. A single data: field with embedded newlines is not valid. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nice. Testing for potentially using Gemini for the automated workflow reviews? |
|
Pausing this PR — converting to draft. This work is being rebuilt on top of the design in #641: a replicated In the new design:
This work resumes as part of Slice B in #641 once Slice A (table + blob-backed multipart receive) lands. — Claude |
Wires the ProgressEmitter (resurrected from the paused #531) into the new DeploymentRecorder so every deploy_component lifecycle phase is captured on the row's event_log AND streamable live via SSE. Same content-negotiated branch serves get_deployment, letting Studio (or any client) replay a deploy's history and tail in-flight events through a single endpoint. What's new - DeploymentRecorder subscribes to a ProgressEmitter and coalesces writes: every emit appends to a bounded event_log (200 cap, head+tail retention so the lifecycle spine survives a noisy install); chained puts collapse a burst into one round trip. Emits a `_recorder_finished` sentinel on finish() so SSE tailers terminate cleanly even on crash paths. - deployComponent emits prepare/load/replicate/restart/success phase events around their respective steps. Strips req.progress before replicateOperation so peers see a clean payload. Skips recording entirely on replicated (peer-side) executions — origin owns the canonical row. - An in-memory activeEmitters Map keyed by deployment_id lets get_deployment SSE locate the live emitter and tail it. - handlePostRequest gains a content-negotiated SSE branch (req.headers.accept includes text/event-stream + op in SSE_PROGRESS_OPERATIONS). Prime write on the PassThrough so Fastify starts piping immediately — empirically Fastify buffers a returned Readable until end-of-stream without it, collapsing all intermediate writes into a single flush. - get_deployment with SSE subscribes to the live emitter BEFORE reading the row, then replays the historical event_log and dedupes by timestamp so no event is lost in the stitching gap. A polling fallback resolves the SSE promise even if the deploy disappears without signaling a terminal event. - CLI sends Accept: text/event-stream for deploy_component; consumes the SSE response via parseSSE; renders phase/install/error events through DeployRenderer. - httpRequest gains a streamResponse option that yields the raw IncomingMessage as a Readable instead of buffering — what the SSE consumer needs. Ported from #531 (with the multi-line data spec fix, StringDecoder, and disconnect cleanup already applied earlier in the session): - server/serverHelpers/progressEmitter.ts (+ tests) - bin/sseConsumer.ts (+ tests) - bin/deployRenderer.ts (+ tests) Integration coverage: integrationTests/deploy/deploy-tracking-events.test.ts asserts event_log shape on success, SSE replay+done on get_deployment, and the failure path emits an error event into the log. Refs #641 (Slice B1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the ProgressEmitter (resurrected from the paused #531) into the new DeploymentRecorder so every deploy_component lifecycle phase is captured on the row's event_log AND streamable live via SSE. Same content-negotiated branch serves get_deployment, letting Studio (or any client) replay a deploy's history and tail in-flight events through a single endpoint. What's new - DeploymentRecorder subscribes to a ProgressEmitter and coalesces writes: every emit appends to a bounded event_log (200 cap, head+tail retention so the lifecycle spine survives a noisy install); chained puts collapse a burst into one round trip. Emits a `_recorder_finished` sentinel on finish() so SSE tailers terminate cleanly even on crash paths. - deployComponent emits prepare/load/replicate/restart/success phase events around their respective steps. Strips req.progress before replicateOperation so peers see a clean payload. Skips recording entirely on replicated (peer-side) executions — origin owns the canonical row. - An in-memory activeEmitters Map keyed by deployment_id lets get_deployment SSE locate the live emitter and tail it. - handlePostRequest gains a content-negotiated SSE branch (req.headers.accept includes text/event-stream + op in SSE_PROGRESS_OPERATIONS). Prime write on the PassThrough so Fastify starts piping immediately — empirically Fastify buffers a returned Readable until end-of-stream without it, collapsing all intermediate writes into a single flush. - get_deployment with SSE subscribes to the live emitter BEFORE reading the row, then replays the historical event_log and dedupes by timestamp so no event is lost in the stitching gap. A polling fallback resolves the SSE promise even if the deploy disappears without signaling a terminal event. - CLI sends Accept: text/event-stream for deploy_component; consumes the SSE response via parseSSE; renders phase/install/error events through DeployRenderer. - httpRequest gains a streamResponse option that yields the raw IncomingMessage as a Readable instead of buffering — what the SSE consumer needs. Ported from #531 (with the multi-line data spec fix, StringDecoder, and disconnect cleanup already applied earlier in the session): - server/serverHelpers/progressEmitter.ts (+ tests) - bin/sseConsumer.ts (+ tests) - bin/deployRenderer.ts (+ tests) Integration coverage: integrationTests/deploy/deploy-tracking-events.test.ts asserts event_log shape on success, SSE replay+done on get_deployment, and the failure path emits an error event into the log. Refs #641 (Slice B1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Closing in favor of the deployment-tracking redesign tracked in #641. What landed from this PR via Slice B1 (#657, merged):
What's deferred to Slice B2:
The CLI-side bits, the SSE infrastructure, and the per-deploy 🤖 Generated by Claude |
Summary
ProgressEmitterand anAccept: text/event-streampath to the operations API fordeploy_component.deployComponentandApplication.prepareApplicationemitphaseevents at the extract → install → load → replicate → restart boundaries;installevents stream each line ofnpm install/pnpm/yarn/custom-command stdout & stderr live; the stream terminates withdone(operation result) orerror.deploy_componentand renders a real upload progress bar (cli-progress in TTY, periodic text lines in non-TTY) followed by live phase and install events. Stdout still receives the final JSON/YAML result so existing CLI consumers don't break.First slice of #526.
Where to look
server/serverHelpers/progressEmitter.ts— small pub-sub + thecreateSSEResponseStreamhelper. The interesting subtlety is thesubscribe()snapshot before iteration (so listeners that unsubscribe themselves during dispatch don't shift indexes), and the swallowed-exception policy so a buggy listener can never break the operation.server/serverHelpers/serverHandlers.js— the new SSE branch inhandlePostRequest. Headers (Content-Type,Cache-Control,X-Accel-Buffering) are set before the stream so reverse proxies and Fastify compression don't buffer the response.components/operations.js+components/Application.ts— emission points.Application.prepareApplicationemits theextract done → install startboundary becauseApplicationitself doesn't know which phase comes next; the rest is indeployComponent.installApplicationbuilds anonLinecallback that forwards each complete stdout/stderr line as aninstallSSE event; threaded through all three install code paths (custom command, devEngines packageManager, npm fallback).components/Application.tsnonInteractiveSpawn— line-bufferedonLineparameter so a chunk that splits mid-line doesn't fire a half-event; trailing partial lines flushed on close.components/packageComponent.ts—getPackagedDirectorySizemirrorsstreamPackagedDirectory's skip predicates so the CLI's upload bar has a meaningful 100% target.bin/deployRenderer.ts— owns the upload bar + SSE event rendering.tapUploadStreamis an identity Transform that counts bytes for the progress bar; in non-TTY environments it falls back to periodicUploaded X / ~Y (Z%)lines on each 10% step so logs stay grep-able.renderEventhandlesphase,install,error,done.bin/sseConsumer.ts— SSE record parser (handles split chunks, CRLF, comment lines, multi-linedata:).bin/cliOperations.ts— wires the newstreamResponseoption inhttpRequestand only opts into SSE for operations in the allowlist (SSE_OPERATIONS).Backward compatibility
errorevent. The CLI converts an in-band error into a non-zero exit.Accept: text/event-streamstill receive the buffered JSON/YAML response.ProgressEmitteris stripped fromreqbeforereplicateOperation(req)so peer nodes never see a serialized{listeners:[]}object that would otherwise throwTypeError: progress.emit is not a function.Test plan
npx mocha unitTests/server/serverHelpers/progressEmitter.test.js— 6/6 pass.npx mocha unitTests/bin/sseConsumer.test.js— 9/9 pass.npx mocha unitTests/bin/deployRenderer.test.js— 6/6 pass (upload counting in non-TTY mode, endUpload idempotency, phase rendering with no duplicate-start, install line forwarding with manager header / stream prefix / line count, error & phase-error rendering).npx mocha unitTests/components/packageDirectorySize.test.js— 5/5 pass (sum of sizes, skip_node_modules, .cache/webpack exclusion, empty dir, best-effort on missing path).npx mocha unitTests/server/serverHelpers/multipartParser.test.js unitTests/bin/multipartBuilder.test.js unitTests/components/packageComponent.test.js unitTests/server/fastifyRoutes/operations.test.js— 38/38 pass (slice 1 + existing operations.test.js suite, unaffected).Follow-ups (intentionally out of scope)
uploadevents from the multipart parser layer — would let the CLI show a server-side received-bytes bar alongside the client-side sent-bytes bar; currently the CLI's local counter is sufficient signal.🤖 Generated with Claude Code