Skip to content

Commit bfa34a5

Browse files
committed
Harden web build continuity and progress
1 parent aa2ff60 commit bfa34a5

3 files changed

Lines changed: 153 additions & 5 deletions

File tree

src/projects/cli.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ function fakeClient(
3838
sessionId: "sess-1",
3939
projectId: "proj-1",
4040
status: "building",
41+
continued: !!input.projectId,
4142
model: "codebase/d4f",
4243
}
4344
);
@@ -185,6 +186,23 @@ describe("runProjectSubcommand", () => {
185186
expect(result.stdout.join("\n")).toContain("codebase project status sess-1");
186187
});
187188

189+
it("cancels when the web API does not confirm requested project continuity", async () => {
190+
const cancelled: string[] = [];
191+
const client = fakeClient({
192+
build: { sessionId: "wrong-session", projectId: "wrong-project", status: "building" },
193+
onCancelBuild: (sessionId) => cancelled.push(sessionId),
194+
});
195+
196+
const result = await runProject(
197+
["project", "build", "--project", "proj-1", "Fix", "the", "existing", "app"],
198+
client,
199+
);
200+
201+
expect(result.code).toBe(1);
202+
expect(result.stderr.join("\n")).toContain("did not confirm continuation of project proj-1");
203+
expect(cancelled).toEqual(["wrong-session"]);
204+
});
205+
188206
it("explains payment challenges from the web build endpoint", async () => {
189207
const client = {
190208
startBuild: async () => {
@@ -219,6 +237,48 @@ describe("runProjectSubcommand", () => {
219237
expect(result.stdout.join("\n")).toContain("preview: https://codebase.design/preview/proj-1");
220238
});
221239

240+
it("streams deduplicated file and phase progress while waiting", async () => {
241+
let calls = 0;
242+
const statuses: BuildStatusResponse[] = [
243+
{
244+
sessionId: "sess-1",
245+
status: "building",
246+
filesCreated: ["index.html", "index.html"],
247+
timeline: [{ phase: "scaffold-copy", durationMs: 1250, success: true }],
248+
},
249+
{
250+
sessionId: "sess-1",
251+
status: "building",
252+
filesCreated: ["index.html"],
253+
timeline: [{ phase: "scaffold-copy", durationMs: 1250, success: true }],
254+
},
255+
{
256+
sessionId: "sess-1",
257+
status: "completed",
258+
filesCreated: ["index.html", "styles.css", "styles.css"],
259+
timeline: [
260+
{ phase: "scaffold-copy", durationMs: 1250, success: true },
261+
{ phase: "validation", skippedReason: "not-needed" },
262+
],
263+
},
264+
];
265+
const client = fakeClient({
266+
status: statuses[0],
267+
preview: { ok: true, previewPath: "/preview/proj-1" },
268+
}) as ProjectClient;
269+
client.getBuildStatus = async () => statuses[Math.min(calls++, statuses.length - 1)]!;
270+
271+
const result = await runProject(["project", "build", "--wait", "Build", "a", "demo"], client);
272+
const output = result.stdout.join("\n");
273+
274+
expect(result.code).toBe(0);
275+
expect(output.match(/wrote:\s+index\.html/g)).toHaveLength(1);
276+
expect(output.match(/wrote:\s+styles\.css/g)).toHaveLength(1);
277+
expect(output).toContain("phase: scaffold-copy 1.3s [ok]");
278+
expect(output).toContain("phase: validation [skipped: not-needed]");
279+
expect(output).toContain("still building (1 file, 1 phase)");
280+
});
281+
222282
it("backs off and keeps waiting when build status is rate limited", async () => {
223283
let calls = 0;
224284
const sleeps: number[] = [];
@@ -265,10 +325,20 @@ describe("runProjectSubcommand", () => {
265325
it("shows build status and cancel controls", async () => {
266326
const status = await runProject(
267327
["project", "status", "sess-1"],
268-
fakeClient({ status: { sessionId: "sess-1", status: "failed", projectId: "proj-1" } }),
328+
fakeClient({
329+
status: {
330+
sessionId: "sess-1",
331+
status: "failed",
332+
projectId: "proj-1",
333+
filesCreated: ["index.html", "index.html"],
334+
timeline: [{ phase: "validation", durationMs: 42, success: false }],
335+
},
336+
}),
269337
);
270338
expect(status.code).toBe(1);
271339
expect(status.stdout.join("\n")).toContain("build sess-1: failed");
340+
expect(status.stdout.join("\n")).toContain("files: index.html");
341+
expect(status.stdout.join("\n")).toContain("phase: validation 42ms [failed]");
272342

273343
const cancel = await runProject(
274344
["project", "cancel", "sess-1"],

src/projects/cli.ts

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,13 @@ async function buildCmd(
294294
scaffold: opts.scaffold,
295295
projectId: opts.projectId,
296296
});
297+
if (opts.projectId && started.continued !== true) {
298+
await client.cancelBuild(started.sessionId).catch(() => undefined);
299+
throw new ProjectClientError(
300+
`web build did not confirm continuation of project ${opts.projectId}; the unexpected build was cancelled`,
301+
409,
302+
);
303+
}
297304
handoffStore?.save({
298305
sessionId: started.sessionId,
299306
projectId: started.projectId,
@@ -314,7 +321,8 @@ async function buildCmd(
314321

315322
out("");
316323
out("waiting for build to finish...");
317-
const status = await waitForBuild(client, started.sessionId, opts.timeoutMs, opts.pollMs, sleep);
324+
const reportProgress = createBuildProgressReporter(out);
325+
const status = await waitForBuild(client, started.sessionId, opts.timeoutMs, opts.pollMs, sleep, reportProgress);
318326
handoffStore?.update({ sessionId: started.sessionId, status: status.status, model: status.model });
319327
printBuildStatus(status, out);
320328
if (status.status === "completed") {
@@ -331,6 +339,7 @@ async function waitForBuild(
331339
timeoutMs: number,
332340
pollMs: number,
333341
sleep: (ms: number) => Promise<void>,
342+
onProgress?: (status: BuildStatusResponse) => void,
334343
): Promise<BuildStatusResponse> {
335344
const deadline = Date.now() + timeoutMs;
336345
let last: BuildStatusResponse | undefined;
@@ -346,6 +355,7 @@ async function waitForBuild(
346355
}
347356
throw err;
348357
}
358+
onProgress?.(last);
349359
if (last.status !== "building") return last;
350360
const remaining = deadline - Date.now();
351361
if (remaining <= 0) break;
@@ -356,6 +366,35 @@ async function waitForBuild(
356366
);
357367
}
358368

369+
function createBuildProgressReporter(out: (msg: string) => void): (status: BuildStatusResponse) => void {
370+
const seenFiles = new Set<string>();
371+
let seenTimelineItems = 0;
372+
return (status) => {
373+
let emitted = false;
374+
for (const file of uniqueStrings(status.filesCreated)) {
375+
if (seenFiles.has(file)) continue;
376+
seenFiles.add(file);
377+
out(` wrote: ${file}`);
378+
emitted = true;
379+
}
380+
381+
const timeline = status.timeline ?? [];
382+
for (const item of timeline.slice(seenTimelineItems)) {
383+
const summary = formatTimelineItem(item);
384+
if (!summary) continue;
385+
out(` phase: ${summary}`);
386+
emitted = true;
387+
}
388+
seenTimelineItems = Math.max(seenTimelineItems, timeline.length);
389+
390+
if (!emitted && status.status === "building") {
391+
out(
392+
` still building (${seenFiles.size} file${seenFiles.size === 1 ? "" : "s"}, ${timeline.length} phase${timeline.length === 1 ? "" : "s"})`,
393+
);
394+
}
395+
};
396+
}
397+
359398
async function statusCmd(
360399
client: ProjectClient,
361400
sessionId: string | undefined,
@@ -435,9 +474,47 @@ function printBuildStatus(status: BuildStatusResponse, out: (msg: string) => voi
435474
out(`build ${status.sessionId}: ${status.status}`);
436475
if (status.projectId) out(` project: ${status.projectId}`);
437476
if (status.model) out(` model: ${status.model}`);
438-
if (status.filesCreated?.length) out(` files: ${status.filesCreated.join(", ")}`);
439-
if (status.timeline?.length)
440-
out(` events: ${status.timeline.length} timeline item${status.timeline.length === 1 ? "" : "s"}`);
477+
const files = uniqueStrings(status.filesCreated);
478+
if (files.length) out(` files: ${files.join(", ")}`);
479+
for (const item of status.timeline ?? []) {
480+
const summary = formatTimelineItem(item);
481+
if (summary) out(` phase: ${summary}`);
482+
}
483+
}
484+
485+
function uniqueStrings(values: string[] | undefined): string[] {
486+
return [
487+
...new Set(
488+
(values ?? []).filter((value) => typeof value === "string" && value.trim()).map((value) => value.trim()),
489+
),
490+
];
491+
}
492+
493+
function formatTimelineItem(value: unknown): string | undefined {
494+
if (!value || typeof value !== "object") return undefined;
495+
const item = value as Record<string, unknown>;
496+
if (typeof item.phase !== "string" || !item.phase.trim()) return undefined;
497+
const parts = [cleanTimelineText(item.phase)];
498+
if (typeof item.durationMs === "number" && Number.isFinite(item.durationMs) && item.durationMs > 0) {
499+
parts.push(formatTimelineDuration(item.durationMs));
500+
}
501+
if (typeof item.skippedReason === "string" && item.skippedReason.trim()) {
502+
parts.push(`[skipped: ${cleanTimelineText(item.skippedReason)}]`);
503+
} else if (item.success === false) {
504+
parts.push("[failed]");
505+
} else if (item.success === true) {
506+
parts.push("[ok]");
507+
}
508+
return parts.join(" ");
509+
}
510+
511+
function cleanTimelineText(value: string): string {
512+
return value.replace(/\s+/g, " ").trim().slice(0, 100);
513+
}
514+
515+
function formatTimelineDuration(durationMs: number): string {
516+
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
517+
return `${(durationMs / 1000).toFixed(durationMs < 10_000 ? 1 : 0)}s`;
441518
}
442519

443520
async function printPreview(

src/projects/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface BuildStartResponse {
3232
sessionId: string;
3333
projectId: string;
3434
status: string;
35+
continued?: boolean;
3536
model?: string;
3637
poll?: string;
3738
events?: string;

0 commit comments

Comments
 (0)