Skip to content

Commit fb150b4

Browse files
bkudiessCopilot
andcommitted
feat(wizard): deliver progress over the RPC wizard protocol
WizardSessionPrompter.progress() was a no-op, so every spin.update/stop during installs, plugin/channel downloads, and auth waits was dropped for non-terminal clients (e.g. the Windows companion), which appeared frozen during the slowest parts of onboarding. Emit progress as non-interactive 'progress' steps (already in the wizard step schema). Latest-wins single slot, drained ahead of the pending prompt; next() loops so a progress wake never decides completion, which keeps concurrent next() callers from surfacing a spurious done while running. Clients re-poll without an answer to continue; progress steps are not answerable. Refs #94661. Co-authored-by: Copilot <[email protected]>
1 parent e9229ab commit fb150b4

2 files changed

Lines changed: 211 additions & 20 deletions

File tree

src/wizard/session.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,133 @@ describe("WizardSession", () => {
131131
}
132132
await session.answer(plainStep.id, "alice");
133133
});
134+
135+
test("delivers progress steps before the next prompt and advances on re-poll", async () => {
136+
const session = new WizardSession(async (prompter) => {
137+
const spin = prompter.progress("Working");
138+
spin.update("Step 1");
139+
spin.stop("Done step");
140+
await prompter.text({ message: "Name" });
141+
});
142+
143+
// progress()/update()/stop() collapse latest-wins to the final message.
144+
const progress = await session.next();
145+
expect(progress.done).toBe(false);
146+
expect(progress.step?.type).toBe("progress");
147+
expect(progress.step?.message).toBe("Done step");
148+
149+
// Re-poll WITHOUT an answer advances to the interactive prompt.
150+
const prompt = await session.next();
151+
expect(prompt.step?.type).toBe("text");
152+
if (!prompt.step) {
153+
throw new Error("expected text step");
154+
}
155+
await session.answer(prompt.step.id, "Peter");
156+
157+
const done = await session.next();
158+
expect(done.done).toBe(true);
159+
expect(done.status).toBe("done");
160+
});
161+
162+
test("delivers progress emitted while a client is waiting on next()", async () => {
163+
let releaseWork: () => void = () => {};
164+
const work = new Promise<void>((resolve) => {
165+
releaseWork = resolve;
166+
});
167+
const session = new WizardSession(async (prompter) => {
168+
const spin = prompter.progress("Working");
169+
await work;
170+
spin.update("Halfway");
171+
await prompter.text({ message: "Name" });
172+
});
173+
174+
// Initial label emitted synchronously at construction.
175+
const first = await session.next();
176+
expect(first.step?.type).toBe("progress");
177+
expect(first.step?.message).toBe("Working");
178+
179+
// No pending step yet: this poll blocks until progress wakes it.
180+
const waiting = session.next();
181+
releaseWork();
182+
const woken = await waiting;
183+
expect(woken.step?.type).toBe("progress");
184+
expect(woken.step?.message).toBe("Halfway");
185+
186+
const prompt = await session.next();
187+
expect(prompt.step?.type).toBe("text");
188+
if (!prompt.step) {
189+
throw new Error("expected text step");
190+
}
191+
await session.answer(prompt.step.id, "x");
192+
const done = await session.next();
193+
expect(done.done).toBe(true);
194+
});
195+
196+
test("progress steps are not answerable", async () => {
197+
const session = new WizardSession(async (prompter) => {
198+
prompter.progress("Working");
199+
await prompter.text({ message: "Name" });
200+
});
201+
202+
const progress = await session.next();
203+
if (!progress.step) {
204+
throw new Error("expected progress step");
205+
}
206+
await expect(session.answer(progress.step.id, null)).rejects.toThrow(
207+
/wizard: no pending step/i,
208+
);
209+
});
210+
211+
test("cancel clears pending progress", async () => {
212+
const session = new WizardSession(async (prompter) => {
213+
prompter.progress("Working");
214+
await prompter.text({ message: "Name" });
215+
});
216+
217+
session.cancel();
218+
219+
const done = await session.next();
220+
expect(done.done).toBe(true);
221+
expect(done.status).toBe("cancelled");
222+
});
223+
224+
test("concurrent next() callers never get a spurious done while running", async () => {
225+
let releaseGate: () => void = () => {};
226+
const gate = new Promise<void>((resolve) => {
227+
releaseGate = resolve;
228+
});
229+
const session = new WizardSession(async (prompter) => {
230+
const spin = prompter.progress("start");
231+
await gate;
232+
spin.update("more");
233+
await prompter.text({ message: "Name" });
234+
});
235+
236+
// Consume the progress emitted synchronously at construction.
237+
const firstStep = await session.next();
238+
expect(firstStep.step?.message).toBe("start");
239+
240+
// Two concurrent waiters share the same internal step waiter — the race a
241+
// progress wake (which resolves that waiter with null) used to mishandle.
242+
const p1 = session.next();
243+
const p2 = session.next();
244+
releaseGate();
245+
const [r1, r2] = await Promise.all([p1, p2]);
246+
247+
// Critical invariant: while running, no caller may surface a done result.
248+
expect(r1.done).toBe(false);
249+
expect(r2.done).toBe(false);
250+
expect(
251+
[r1.step?.type, r2.step?.type].toSorted((a, b) => String(a).localeCompare(String(b))),
252+
).toEqual(["progress", "text"]);
253+
254+
const textResult = [r1, r2].find((r) => r.step?.type === "text");
255+
if (!textResult?.step) {
256+
throw new Error("expected a text step");
257+
}
258+
await session.answer(textResult.step.id, "Peter");
259+
const done = await session.next();
260+
expect(done.done).toBe(true);
261+
expect(done.status).toBe("done");
262+
});
134263
});

src/wizard/session.ts

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,18 @@ class WizardSessionPrompter implements WizardPrompter {
155155
return Boolean(res);
156156
}
157157

158-
progress(_label: string): WizardProgress {
158+
progress(label: string): WizardProgress {
159+
// Emit progress as non-interactive steps so remote clients see live status
160+
// during slow operations. Clients render the message and re-poll wizard.next
161+
// WITHOUT an answer to continue. Local CLI keeps using the clack prompter.
162+
this.session.emitProgress(label);
159163
return {
160-
update: (_message) => {},
161-
stop: (_message) => {},
164+
update: (message) => this.session.emitProgress(message),
165+
stop: (message) => {
166+
if (message !== undefined) {
167+
this.session.emitProgress(message);
168+
}
169+
},
162170
};
163171
}
164172

@@ -176,6 +184,9 @@ export class WizardSession {
176184
private currentStep: WizardStep | null = null;
177185
private stepDeferred: Deferred<WizardStep | null> | null = null;
178186
private pendingTerminalResolution = false;
187+
// Latest undelivered progress step. Progress is non-interactive and "latest
188+
// wins", so it is held as a single slot rather than an unbounded queue.
189+
private pendingProgress: WizardStep | null = null;
179190
private answerDeferred = new Map<string, Deferred<unknown>>();
180191
private status: WizardSessionStatus = "running";
181192
private error: string | undefined;
@@ -186,24 +197,39 @@ export class WizardSession {
186197
}
187198

188199
async next(): Promise<WizardNextResult> {
189-
if (this.currentStep) {
190-
return { done: false, step: this.currentStep, status: this.status };
191-
}
192-
if (this.pendingTerminalResolution) {
193-
this.pendingTerminalResolution = false;
194-
return { done: true, status: this.status, error: this.error };
195-
}
196-
if (this.status !== "running") {
197-
return { done: true, status: this.status, error: this.error };
198-
}
199-
if (!this.stepDeferred) {
200-
this.stepDeferred = createDeferred();
201-
}
202-
const step = await this.stepDeferred.promise;
203-
if (step) {
204-
return { done: false, step, status: this.status };
200+
// Loop so a progress wake never decides completion. A progress emit resolves
201+
// the shared step waiter with null while the session is still running; with
202+
// concurrent next() callers, one consumes the progress and the rest must
203+
// re-classify state (and re-wait) rather than surface a spurious done.
204+
for (;;) {
205+
// Deliver any in-flight progress before the pending prompt so remote
206+
// clients see live status during slow operations (installs, auth waits).
207+
// This cannot starve an interactive prompt: the runner is sequential, so
208+
// while it awaits a prompt it is not emitting progress, and latest-wins
209+
// keeps at most one progress step pending.
210+
const progress = this.takePendingProgress();
211+
if (progress) {
212+
return { done: false, step: progress, status: this.status };
213+
}
214+
if (this.currentStep) {
215+
return { done: false, step: this.currentStep, status: this.status };
216+
}
217+
if (this.pendingTerminalResolution) {
218+
this.pendingTerminalResolution = false;
219+
return { done: true, status: this.status, error: this.error };
220+
}
221+
if (this.status !== "running") {
222+
return { done: true, status: this.status, error: this.error };
223+
}
224+
if (!this.stepDeferred) {
225+
this.stepDeferred = createDeferred();
226+
}
227+
// Wait for the next event (prompt pushed, progress emitted, or terminal),
228+
// then re-loop to classify it via the checks above. The resolved value is
229+
// intentionally ignored: a real step is read back from currentStep, and a
230+
// null resolution falls through to progress/terminal/status handling.
231+
await this.stepDeferred.promise;
205232
}
206-
return { done: true, status: this.status, error: this.error };
207233
}
208234

209235
async answer(stepId: string, value: unknown): Promise<void> {
@@ -223,6 +249,7 @@ export class WizardSession {
223249
this.status = "cancelled";
224250
this.error = "cancelled";
225251
this.currentStep = null;
252+
this.pendingProgress = null;
226253
for (const [, deferred] of this.answerDeferred) {
227254
// Reject all pending prompt promises so the runner can unwind through its
228255
// normal cancellation path.
@@ -264,6 +291,41 @@ export class WizardSession {
264291
return await deferred.promise;
265292
}
266293

294+
/**
295+
* Emits a non-interactive progress step for remote clients. Fire-and-forget:
296+
* the runner does not block, and clients re-poll wizard.next without an answer
297+
* to continue. Latest-wins, so an undelivered progress step is replaced.
298+
*/
299+
emitProgress(message: string): void {
300+
if (this.status !== "running") {
301+
return;
302+
}
303+
this.pendingProgress = {
304+
id: randomUUID(),
305+
type: "progress",
306+
message,
307+
executor: "client",
308+
};
309+
this.wakePendingProgress();
310+
}
311+
312+
private takePendingProgress(): WizardStep | null {
313+
const step = this.pendingProgress;
314+
this.pendingProgress = null;
315+
return step;
316+
}
317+
318+
private wakePendingProgress(): void {
319+
// Wake a long-polling next() so live progress is delivered mid-operation.
320+
// Resolves with null; next() re-checks the pending progress slot on wake.
321+
if (!this.stepDeferred) {
322+
return;
323+
}
324+
const deferred = this.stepDeferred;
325+
this.stepDeferred = null;
326+
deferred.resolve(null);
327+
}
328+
267329
private resolveStep(step: WizardStep | null) {
268330
if (!this.stepDeferred) {
269331
if (step === null) {

0 commit comments

Comments
 (0)