Skip to content

Commit bed20fa

Browse files
kriszypclaude
andcommitted
feat(deploy): Slice B1 — live SSE progress + event_log on hdb_deployment
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>
1 parent 9e4242e commit bed20fa

13 files changed

Lines changed: 1314 additions & 16 deletions

File tree

bin/cliOperations.ts

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,20 @@ import * as fs from 'fs-extra';
1111
import * as YAML from 'yaml';
1212
import { streamPackagedDirectory } from '../components/packageComponent.ts';
1313
import { buildMultipartBody } from './multipartBuilder.ts';
14+
import { parseSSE } from './sseConsumer.ts';
15+
import { DeployRenderer } from './deployRenderer.ts';
1416
import { getHdbPid } from '../utility/processManagement/processManagement.js';
1517
import { initConfig, getConfigPath } from '../config/configUtils.js';
1618

1719
const OP_ALIASES = { deploy: 'deploy_component', package: 'package_component' };
1820

21+
// Operations whose responses should be consumed as text/event-stream so live phase events
22+
// (prepare, load, replicate, restart) render as they happen instead of after the whole
23+
// deploy completes. Add an operation here only after wiring its server-side
24+
// SSE_PROGRESS_OPERATIONS entry — otherwise the server returns the buffered JSON path and
25+
// the SSE parser sees no events.
26+
const SSE_OPERATIONS = new Set(['deploy_component']);
27+
1928
// Properties on `req` that the CLI itself uses for transport/UX, not the operations API.
2029
// They never get serialized into the request body.
2130
const TRANSPORT_ONLY_FIELDS = new Set([
@@ -191,6 +200,15 @@ async function cliOperations(req: any, skipResponseLog = false) {
191200
options.headers.Authorization = `Bearer ${tokens.operation_token}`;
192201
}
193202
}
203+
const useSse = SSE_OPERATIONS.has(req.operation);
204+
if (useSse) {
205+
options.headers.Accept = 'text/event-stream';
206+
options.streamResponse = true;
207+
}
208+
// One renderer owns the (future) upload bar and the SSE event rendering for a
209+
// multipart deploy. Created here so the upload-stream tap and the SSE consumer
210+
// below share the same instance.
211+
const renderer = req._multipart ? new DeployRenderer({}) : null;
194212
let body;
195213
if (req._multipart) {
196214
const packageStream = req._packageStream;
@@ -209,20 +227,66 @@ async function cliOperations(req: any, skipResponseLog = false) {
209227
// Use chunked transfer-encoding: we don't know the total size up front because the
210228
// payload is streamed from `tar.pack` and never fully buffered.
211229
options.headers['Transfer-Encoding'] = 'chunked';
212-
body = multipart.stream;
230+
// Tap the body so bytes flowing into the HTTP request advance the upload bar.
231+
// The renderer's Transform is identity — chunks pass through unmodified.
232+
body = renderer ? renderer.tapUploadStream(multipart.stream) : multipart.stream;
213233
} else {
214234
body = req;
215235
}
216236
let response: any = await httpRequest(options, body);
217237

238+
// Upload is done by the time we get the response; tear the bar down before any SSE
239+
// rendering so the bar and event lines don't fight for the same terminal row.
240+
renderer?.endUpload();
241+
218242
let responseData;
219-
try {
220-
responseData = JSON.parse(response.body);
221-
} catch {
222-
responseData = {
223-
status: response.statusCode + ' ' + (response.statusMessage || 'Unknown'),
224-
body: response.body,
225-
};
243+
if (useSse && response.headers['content-type']?.startsWith('text/event-stream')) {
244+
// Consume SSE: render phase events live, capture the final result from the `done`
245+
// event (or the error message from the `error` event). The HTTP status stays 200
246+
// until end-of-stream; failures are signaled in-band.
247+
let finalResult;
248+
let sseError;
249+
for await (const message of parseSSE(response)) {
250+
renderer?.renderEvent(message);
251+
if (message.event === 'done') {
252+
try {
253+
finalResult = JSON.parse(message.data)?.result;
254+
} catch {
255+
finalResult = message.data;
256+
}
257+
} else if (message.event === 'error') {
258+
try {
259+
sseError = JSON.parse(message.data);
260+
} catch {
261+
sseError = { message: message.data };
262+
}
263+
}
264+
}
265+
if (sseError) {
266+
const errMsg = sseError.message ?? (typeof sseError === 'object' ? JSON.stringify(sseError) : sseError);
267+
console.error(`error: ${errMsg}`);
268+
process.exit(1);
269+
}
270+
responseData = finalResult ?? { message: 'Deploy completed (no result payload).' };
271+
} else {
272+
// When useSse is true, httpRequest returns a raw IncomingMessage (streamResponse mode),
273+
// so .body is undefined. Drain the stream to get the text (e.g. a 401 error body).
274+
let bodyText: string;
275+
if (useSse) {
276+
const chunks: Buffer[] = [];
277+
for await (const chunk of response as AsyncIterable<Buffer>) chunks.push(Buffer.from(chunk));
278+
bodyText = Buffer.concat(chunks).toString('utf8');
279+
} else {
280+
bodyText = response.body;
281+
}
282+
try {
283+
responseData = JSON.parse(bodyText);
284+
} catch {
285+
responseData = {
286+
status: response.statusCode + ' ' + (response.statusMessage || 'Unknown'),
287+
body: bodyText,
288+
};
289+
}
226290
}
227291

228292
let responseLog;

bin/deployRenderer.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { Transform } from 'node:stream';
2+
// cli-progress is already a runtime dep of harper (see package.json); using its
3+
// SingleBar to render the upload phase here doesn't add a new dependency.
4+
import cliProgress from 'cli-progress';
5+
import type { SSEMessage } from './sseConsumer.ts';
6+
7+
interface RendererOptions {
8+
uploadTotal?: number;
9+
output?: NodeJS.WritableStream;
10+
}
11+
12+
interface UploadState {
13+
bar: cliProgress.SingleBar | null;
14+
sent: number;
15+
textLastLogged: number;
16+
finished: boolean;
17+
}
18+
19+
interface PhaseState {
20+
current?: string;
21+
installManager?: string;
22+
installLineCount: number;
23+
}
24+
25+
/**
26+
* Deploy-time renderer that owns the progress display across two phases:
27+
*
28+
* 1. Local upload — driven by `tapUploadStream`, which wraps the multipart body so we
29+
* can update a `cli-progress` bar against the precomputed uncompressed source-tree
30+
* total. In a non-TTY environment (CI logs, redirected output) we fall back to
31+
* periodic text lines so logs stay grep-able.
32+
*
33+
* 2. Server-side phases — driven by `renderEvent`, called for each SSE message the
34+
* CLI receives from the operations API. Phase events print one-liners; live
35+
* `install` events (npm/pnpm/yarn stdout) are throttled to one line under a
36+
* "[install]" header so a noisy `npm install` doesn't drown the terminal.
37+
*
38+
* Designed so the two phases hand off cleanly: `endUpload()` tears the bar down (so
39+
* it doesn't compete with subsequent prints) before any SSE events render.
40+
*/
41+
export class DeployRenderer {
42+
private upload: UploadState = { bar: null, sent: 0, textLastLogged: 0, finished: false };
43+
private phase: PhaseState = { installLineCount: 0 };
44+
private output: NodeJS.WritableStream;
45+
private isTty: boolean;
46+
private uploadTotal: number;
47+
48+
constructor(options: RendererOptions = {}) {
49+
this.output = options.output ?? process.stderr;
50+
// Only render a bar when stderr is a real terminal. CI runners, log redirection,
51+
// and pipes look identical from Node's perspective: !isTTY.
52+
this.isTty = Boolean((this.output as NodeJS.WriteStream).isTTY);
53+
this.uploadTotal = options.uploadTotal ?? 0;
54+
}
55+
56+
/**
57+
* Wrap an outbound stream so each byte flowing through it advances the upload bar.
58+
* The Transform is identity — chunks pass through unmodified.
59+
*/
60+
tapUploadStream<T extends NodeJS.ReadableStream>(stream: T): NodeJS.ReadableStream {
61+
this.upload.bar = this.isTty
62+
? new cliProgress.SingleBar(
63+
{
64+
format: 'Uploading [{bar}] {percentage}% | {value}/{total} bytes',
65+
barCompleteChar: '█',
66+
barIncompleteChar: '░',
67+
hideCursor: true,
68+
stream: this.output,
69+
etaBuffer: 50,
70+
},
71+
cliProgress.Presets.shades_classic
72+
)
73+
: null;
74+
this.upload.bar?.start(this.uploadTotal || 1, 0);
75+
76+
const counter = new Transform({
77+
transform: (chunk, _enc, cb) => {
78+
this.upload.sent += chunk.length;
79+
this.tickUpload();
80+
cb(null, chunk);
81+
},
82+
flush: (cb) => {
83+
this.endUpload();
84+
cb();
85+
},
86+
});
87+
stream.on('error', (err) => counter.destroy(err));
88+
stream.pipe(counter);
89+
return counter;
90+
}
91+
92+
endUpload(): void {
93+
if (this.upload.finished) return;
94+
this.upload.finished = true;
95+
if (this.upload.bar) {
96+
// Snap to total so the bar shows 100% even when our uncompressed-total estimate
97+
// is slightly off (gzip output is usually smaller than the source tree).
98+
if (this.uploadTotal > 0) this.upload.bar.update(this.uploadTotal);
99+
this.upload.bar.stop();
100+
this.upload.bar = null;
101+
} else {
102+
this.output.write(`Upload complete (${formatBytes(this.upload.sent)})\n`);
103+
}
104+
}
105+
106+
private tickUpload(): void {
107+
if (this.upload.bar) {
108+
this.upload.bar.update(this.upload.sent);
109+
return;
110+
}
111+
// Non-TTY: log a line every 10% of the total (or every 5MB if total unknown).
112+
const step = this.uploadTotal > 0 ? this.uploadTotal / 10 : 5 * 1024 * 1024;
113+
if (this.upload.sent - this.upload.textLastLogged >= step) {
114+
this.upload.textLastLogged = this.upload.sent;
115+
const pct = this.uploadTotal > 0 ? Math.min(100, Math.floor((this.upload.sent / this.uploadTotal) * 100)) : null;
116+
this.output.write(
117+
pct !== null
118+
? `Uploaded ${formatBytes(this.upload.sent)} / ~${formatBytes(this.uploadTotal)} (${pct}%)\n`
119+
: `Uploaded ${formatBytes(this.upload.sent)}\n`
120+
);
121+
}
122+
}
123+
124+
renderEvent(message: SSEMessage): void {
125+
let parsed: unknown;
126+
try {
127+
parsed = JSON.parse(message.data);
128+
} catch {
129+
parsed = message.data;
130+
}
131+
switch (message.event) {
132+
case 'phase':
133+
this.renderPhase(parsed as { phase?: string; status?: string; message?: string });
134+
break;
135+
case 'install':
136+
this.renderInstall(parsed as { manager?: string; stream?: string; line?: string });
137+
break;
138+
case 'error': {
139+
const e = parsed as { message?: string; code?: string | number };
140+
this.output.write(`error: ${e.message ?? message.data}${e.code ? ` (${e.code})` : ''}\n`);
141+
break;
142+
}
143+
case 'done':
144+
// Caller picks up final result via the SSE iterator; nothing to render here.
145+
break;
146+
}
147+
}
148+
149+
private renderPhase(data: { phase?: string; status?: string; message?: string }): void {
150+
const label = data.phase ?? '?';
151+
if (data.status === 'start') {
152+
if (this.phase.current !== label) {
153+
this.output.write(`${label}…\n`);
154+
this.phase.current = label;
155+
this.phase.installLineCount = 0;
156+
}
157+
} else if (data.status === 'done') {
158+
if (label === 'install' && this.phase.installLineCount > 0) {
159+
this.output.write(`install done (${this.phase.installLineCount} log lines)\n`);
160+
} else {
161+
this.output.write(`${label} done\n`);
162+
}
163+
} else if (data.status === 'error') {
164+
this.output.write(`${label} ERROR: ${data.message ?? 'failed'}\n`);
165+
}
166+
}
167+
168+
private renderInstall(data: { manager?: string; stream?: string; line?: string }): void {
169+
const line = (data.line ?? '').trimEnd();
170+
if (!line) return;
171+
if (data.manager && data.manager !== this.phase.installManager) {
172+
this.phase.installManager = data.manager;
173+
this.output.write(`install: using ${data.manager}\n`);
174+
}
175+
this.phase.installLineCount++;
176+
// Prefix with stream so users can distinguish stderr noise from stdout warnings.
177+
const tag = data.stream === 'stderr' ? '!' : '|';
178+
this.output.write(` ${tag} ${line}\n`);
179+
}
180+
}
181+
182+
function formatBytes(bytes: number): string {
183+
if (bytes < 1024) return `${bytes} B`;
184+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
185+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
186+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
187+
}

0 commit comments

Comments
 (0)