Public: anyone can read this; the crew can change it.

A push that cannot boot leaves the front door saying "live" #216

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/200-a-push-that-cannot-boot-leaves-the-front → main 3 commits fork

  • Merged. scout/200-a-push-that-cannot-boot-leaves-the-front was merged into main as 532e005.

Review

Sign in to review this change.
7 files changed +183 −19
src/apps/runtime.ts +31
@@ -116,6 +116,16 @@ export interface EnvStatus {
116116 * that has not started has not said either way.
117117 */
118118 readonly tty?: string | null | undefined;
119 /**
120 * A deploy newer than the one serving that never came up — set only
121 * while an older deployment keeps answering in its place. A later
122 * successful deploy of this environment clears it (it is the newest
123 * attempt's own outcome, never a history), and it is never set once
124 * nothing serves at all: `state: "failed"` and `error` already say that.
125 */
126 readonly failedDeploy?:
127 | { readonly commitOid: string; readonly runId: string; readonly error: string }
128 | undefined;
119129 }
120130
121131 interface Live {
@@ -188,6 +198,8 @@ interface Environment {
188198 chain: Promise<void>;
189199 lastError?: string | undefined;
190200 lastRunId?: string | undefined;
201 /** The commit `lastError` belongs to — set together, cleared together. */
202 lastErrorCommitOid?: string | undefined;
191203 /** Crash restarts in the current window, for backing off. */
192204 restarts: number[];
193205 }
@@ -299,6 +311,22 @@ export class AppsRuntime {
299311 };
300312 }
301313 if (state.live !== undefined) {
314 // The most recent deploy attempt failed, and it was not this one — a
315 // newer commit that never came up, while this older deployment kept
316 // serving. `swapped` already covers the alternative (the newest
317 // attempt is this one and it succeeded, however its tests read).
318 const failedDeploy =
319 !swapped &&
320 state.lastError !== undefined &&
321 state.lastErrorCommitOid !== undefined &&
322 state.lastErrorCommitOid !== state.live.commitOid &&
323 state.lastRunId !== undefined
324 ? {
325 commitOid: state.lastErrorCommitOid,
326 runId: state.lastRunId,
327 error: state.lastError,
328 }
329 : undefined;
302330 return {
303331 env: state.env,
304332 state: "running",
@@ -317,6 +345,7 @@ export class AppsRuntime {
317345 ...(state.live.testsFailed === undefined
318346 ? {}
319347 : { error: `serving · tests failed: ${state.live.testsFailed}` }),
348 ...(failedDeploy === undefined ? {} : { failedDeploy }),
320349 };
321350 }
322351 return {
@@ -661,6 +690,7 @@ export class AppsRuntime {
661690 state.deploying = undefined;
662691 lease();
663692 state.lastError = outcome.error;
693 state.lastErrorCommitOid = run.commitOid;
664694 const done = finishRun(this.app.db, run.id, {
665695 state: "failed",
666696 summary: outcome.error,
@@ -697,6 +727,7 @@ export class AppsRuntime {
697727 };
698728 state.live = live;
699729 state.lastError = undefined;
730 state.lastErrorCommitOid = undefined;
700731 if (previous !== undefined) {
701732 write(
702733 `stopping the previous deployment (${previous.commitOid.slice(0, 7)})`,
src/web/pages/apps.ts +8 −4
@@ -63,8 +63,8 @@ import {
6363 empty,
6464 envActions,
6565 envFacts,
66 envPhrase,
6667 envStateBadge,
67 envTone,
6868 icon,
6969 listCard,
7070 row,
@@ -136,7 +136,7 @@ function appRow(entry: AppEntry): Html {
136136 const name = `${entry.repo.ownerName}/${entry.repo.name}`;
137137 return row({
138138 nav: true,
139 lead: dot(envTone(entry.production?.state)),
139 lead: dot(envPhrase(entry.production).tone),
140140 title: html`<a href="${base}/app">${name}</a>`,
141141 titleAttr: name,
142142 meta: html`${entry.url === undefined
@@ -597,7 +597,9 @@ function envCard(base: string, env: EnvStatus, writer: boolean): Html {
597597 extra: "env-card",
598598 ...(env.state === "failed" || env.error !== undefined
599599 ? { tone: "danger" as const }
600 : {}),
600 : env.failedDeploy !== undefined
601 ? { tone: "attn" as const }
602 : {}),
601603 head: html`${icon(number === undefined ? "rocket" : "pull")}
602604 <h2>
603605 ${number === undefined
@@ -820,7 +822,9 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html {
820822 extra: "env-card",
821823 ...(env.state === "failed" || env.error !== undefined
822824 ? { tone: "danger" as const }
823 : {}),
825 : env.failedDeploy !== undefined
826 ? { tone: "attn" as const }
827 : {}),
824828 },
825829 );
826830 }
src/web/studio/previews.ts +13 −13
@@ -12,7 +12,6 @@ import { envPull, previewEnv } from "../../apps/data.ts";
1212 import {
1313 appsRuntime,
1414 type AppsRuntime,
15 type EnvState,
1615 type EnvStatus,
1716 } from "../../apps/runtime.ts";
1817 import { getAppByRepo, type AppRecord } from "../../core/apps.ts";
@@ -67,16 +66,17 @@ function openAppBtn(base: string, env: EnvStatus | undefined): Html {
6766 });
6867 }
6968
70 /** The three words the studio uses for a preview, whatever its check says. */
71 function previewBadge(state: EnvState | undefined): Html {
72 if (state === "running")
73 return badge("live", { tone: "success", icon: "play" });
74 if (state === "deploying")
75 return badge("starting", { tone: "attn", icon: "loader" });
76 return badge("not up", {
77 ...(state === "failed" ? { tone: "danger" as const } : {}),
78 icon: "square",
79 });
69 /** The studio's own words for a preview, whatever its check says — always
70 * `envPhrase`'s, so this row never invents a second vocabulary. */
71 function previewBadge(env: EnvStatus | undefined): Html {
72 const { word, tone } = envPhrase(env);
73 const ic =
74 env?.state === "running"
75 ? "play"
76 : env?.state === "deploying"
77 ? "loader"
78 : "square";
79 return badge(word, { tone, icon: ic });
8080 }
8181
8282 /**
@@ -102,7 +102,7 @@ function previewRow(
102102 const bits: Html[] = [html`#${pull.number} ${pull.title}`];
103103 if (env?.commitOid) bits.push(html`${env.commitOid.slice(0, 7)}`);
104104 if (env?.since) bits.push(html`up ${time(env.since)}`);
105 const say = env?.error ?? env?.note;
105 const say = env?.error ?? env?.failedDeploy?.error ?? env?.note;
106106 if (say) bits.push(html`${say}`);
107107 return row({
108108 ...(href === undefined ? {} : { href, nav: true }),
@@ -112,7 +112,7 @@ function previewRow(
112112 ? `#${pull.number} ${pull.title}`
113113 : html`<span class="mono">${host}</span>`,
114114 meta: bits.reduce((a, b) => html`${a} · ${b}`),
115 trail: previewBadge(env?.state),
115 trail: previewBadge(env),
116116 });
117117 }
118118
src/web/studio/shelf.ts +13 −1
@@ -131,8 +131,20 @@ export async function shelfRows(
131131 ? undefined
132132 : actors.get(latest.actorId)?.username;
133133 const asks = asksByRepo.get(repo.id) ?? 0;
134 const tone = envTone(production?.state);
134 // The badge is production's own word (`envPhrase`) — whether a newer
135 // push failed behind what is serving is a fact of the environment, not
136 // of the run or change history `happening` reads (#191's own word for
137 // that stays put; this is the other half of the row, decided once here
138 // rather than folded into a second vocabulary). The tone rides with it:
139 // `envTone` only knows the runtime's raw state, which still reads
140 // "success" while an older deploy answers for a push that failed behind
141 // it — this row is not untroubled green just because `envTone` cannot
142 // see that fact.
135143 const live = envPhrase(production);
144 const tone =
145 production?.failedDeploy !== undefined
146 ? "attn"
147 : envTone(production?.state);
136148 const happening =
137149 word === undefined
138150 ? stuck.has(repo.id)
src/web/ui.ts +15 −1
@@ -322,6 +322,10 @@ export function envPhrase(env: EnvStatus | undefined): {
322322 return { word: "not running", tone: "default" };
323323 if (env.state === "failed") return { word: "not up", tone: "danger" };
324324 if (env.state === "deploying") return { word: "starting", tone: "default" };
325 // It answers, so it is not "not up" — but a newer push never came up
326 // behind it, so it is not plain green either.
327 if (env.failedDeploy !== undefined)
328 return { word: "live · a newer push failed", tone: "attn" };
325329 return {
326330 word: env.error === undefined ? "live" : "serving · tests failed",
327331 tone: "success",
@@ -373,7 +377,17 @@ export function envFacts(
373377 </p>`,
374378 )}
375379 ${when(env.note, (note) => html`<p class="muted small">${note}</p>`)}
376 ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)}`;
380 ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)}
381 ${when(
382 env.failedDeploy,
383 // Two `sha`s, never nested inside the run link — a link inside a link
384 // tears the paragraph apart, the same reason `userTag` gives a row.
385 (fd) => html`<p class="env-error">
386 serving ${sha(env.commitOid, `${base}/commit/${env.commitOid}`)} —
387 ${sha(fd.commitOid, `${base}/commit/${fd.commitOid}`)} did not come up:
388 ${fd.error} (<a href="${base}/runs/${fd.runId}">its deploy</a>)
389 </p>`,
390 )}`;
377391 }
378392
379393 /**
test/apps.test.ts +46
@@ -871,6 +871,52 @@ describe("apps: production, previews, data, gates, crew", () => {
871871 );
872872 expect(openPreviews.length).toBe(1); // the bot's pull is the one open pull
873873 }, 90_000);
874
875 test("a push that cannot boot leaves the old deployment serving and names the push that failed (issue #200)", async () => {
876 const before = runtime.envStatus(record, "production");
877 expect(before?.state).toBe("running");
878 const servingOid = before!.commitOid;
879 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
880 const original =
881 (await sh(["git", "show", `${repo.defaultBranch}:forge.json`], dir))
882 .out + "\n";
883
884 const broken = await commitToBranch(app, alice, repo, {
885 message: "Break forge.json",
886 files: [{ path: "forge.json", content: "{ not json" }],
887 });
888 expect(broken.ok).toBe(true);
889 if (!broken.ok) return;
890 const failed = await settled(
891 (r) => r.env === "production" && r.commitOid === broken.value.commitOid,
892 );
893 expect(failed.state).toBe("failed");
894
895 // The old deployment never moved: it still answers, at its own commit.
896 const status = runtime.envStatus(record, "production");
897 expect(status?.state).toBe("running");
898 expect(status?.commitOid).toBe(servingOid);
899 expect((await viaHost(`hello.${DOMAIN}`)).status).toBe(200);
900 // But the failed push is named, not silently dropped.
901 expect(status?.failedDeploy?.commitOid).toBe(broken.value.commitOid);
902 expect(status?.failedDeploy?.runId).toBe(failed.id);
903 expect(status?.failedDeploy?.error).toContain("forge.json");
904
905 // A later successful deploy clears it: no permanent furniture.
906 const fixed = await commitToBranch(app, alice, repo, {
907 message: "Fix forge.json",
908 files: [{ path: "forge.json", content: original }],
909 });
910 expect(fixed.ok).toBe(true);
911 if (!fixed.ok) return;
912 const ok = await settled(
913 (r) => r.env === "production" && r.commitOid === fixed.value.commitOid,
914 );
915 expect(ok.state).toBe("ok");
916 const cleared = runtime.envStatus(record, "production");
917 expect(cleared?.commitOid).toBe(fixed.value.commitOid);
918 expect(cleared?.failedDeploy).toBeUndefined();
919 }, 90_000);
874920 });
875921
876922 describe("a preview whose copied data the build cannot read starts fresh (issue #36)", () => {
test/studio.test.ts +57
@@ -2162,6 +2162,7 @@ describe("the studio head says why the app is not up", () => {
21622162 readonly deploying?: unknown;
21632163 readonly lastError?: string;
21642164 readonly lastRunId?: string;
2165 readonly lastErrorCommitOid?: string;
21652166 }
21662167 const put = (
21672168 slot: Omit<Slot, "app" | "env" | "chain" | "restarts">,
@@ -2319,6 +2320,62 @@ describe("the studio head says why the app is not up", () => {
23192320 expect(mine).toContain('href="/alice/notes/app/production/open"');
23202321 expect(await shelfBadge(writer)).toBe("serving · tests failed");
23212322 });
2323
2324 test("a newer push that failed to come up while an older deploy keeps serving: live, not plain green, both commits named, its run linked", async () => {
2325 const failed = createRun(app.db, {
2326 repoId: repo.id,
2327 kind: "deploy",
2328 env: "production",
2329 commitOid: "2".repeat(40),
2330 reason: "push",
2331 });
2332 claimRun(app.db, failed.id);
2333 finishRun(app.db, failed.id, {
2334 state: "failed",
2335 summary: "forge.json: JSON Parse error: Unexpected token '}'",
2336 });
2337 put({
2338 live: {
2339 commitOid: "1".repeat(40),
2340 handle: { port: 4321 },
2341 runId: deploy,
2342 since: new Date(Date.now() - 60_000).toISOString(),
2343 manifest: { tty: null },
2344 },
2345 lastError: "forge.json: JSON Parse error: Unexpected token '}'",
2346 lastRunId: failed.id,
2347 lastErrorCommitOid: "2".repeat(40),
2348 });
2349 const mine = await head(writer);
2350 // It answers — still "live", never "not up" — but not untroubled green.
2351 expect(mine).toContain("live");
2352 expect(mine).not.toContain("not up");
2353 expect(mine).toContain('href="/alice/notes/app/production/open"');
2354 // Both commits, named.
2355 expect(mine).toContain("1111111");
2356 expect(mine).toContain("2222222");
2357 expect(mine).toContain("did not come up");
2358 expect(mine).toContain(
2359 "forge.json: JSON Parse error: Unexpected token &#39;}&#39;",
2360 );
2361 // The failed run's log, one click away.
2362 expect(mine).toContain(`href="/alice/notes/runs/${failed.id}"`);
2363 });
2364
2365 test("a clean deploy with no failure behind it is unchanged: plain live, no failed-run link", async () => {
2366 put({
2367 live: {
2368 commitOid: "1".repeat(40),
2369 handle: { port: 4321 },
2370 runId: deploy,
2371 since: new Date(Date.now() - 60_000).toISOString(),
2372 manifest: { tty: null },
2373 },
2374 });
2375 const mine = await head(writer);
2376 expect(mine).toContain("live");
2377 expect(mine).not.toContain("did not come up");
2378 });
23222379 });
23232380
23242381 /**

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs