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

The front door goes quiet the moment you ship #229

Merged opened this

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

Merged

scout/forge:scout/215-the-front-door-goes-quiet-the-moment-you → main 1 commit fork

  • Merged. scout/215-the-front-door-goes-quiet-the-moment-you was merged into main as 9efc9cc.

Review

Sign in to review this change.
5 files changed +472 −25
src/core/runs.ts +24
@@ -167,6 +167,30 @@ export function getRun(db: Database, runId: string): Run | undefined {
167167 return row ? toRun(row) : undefined;
168168 }
169169
170 /**
171 * Several runs by id, in one query — for a caller that already has a batch
172 * of run ids from elsewhere (an environment's `runId`, a `failedDeploy`'s)
173 * and wants the rows without asking once per id.
174 */
175 export function getRunsByIds(
176 db: Database,
177 runIds: readonly string[],
178 ): Map<string, Run> {
179 const out = new Map<string, Run>();
180 const unique = [...new Set(runIds)];
181 const CHUNK = 400;
182 for (let i = 0; i < unique.length; i += CHUNK) {
183 const batch = unique.slice(i, i + CHUNK);
184 const rows = db
185 .query<RunRow, string[]>(
186 `SELECT * FROM run WHERE id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})`,
187 )
188 .all(...batch);
189 for (const row of rows) out.set(row.id, toRun(row));
190 }
191 return out;
192 }
193
170194 /**
171195 * Take a queued run. The UPDATE's WHERE is the claim: a second worker's
172196 * changes count is zero and it moves on. Returns the run as running, or
src/web/studio/home.ts +44 −9
@@ -26,7 +26,7 @@ import { asksPanel, decisionsFor, writableAppsOf } from "./asks.ts";
2626 import { withLikely } from "./leans.ts";
2727 import { changeHref } from "./nouns.ts";
2828 import { stopForm } from "./run.ts";
29 import { shelfRows } from "./shelf.ts";
29 import { productionFactsForRepos, shelfRows } from "./shelf.ts";
3030 import { pullWord, wordBadge, type WordCtx } from "./words.ts";
3131
3232 /**
@@ -45,7 +45,10 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
4545 // Read once, shown twice: the panel below is these same asks, and the
4646 // shelf counts them into each row's pill.
4747 const decisions = withLikely(ctx, decisionsFor(ctx, appRepos));
48 const appRows = await shelfRows(ctx, appRepos, { decisions });
48 // Read once, shown twice again: a shipped or failed deploy is a row's own
49 // word, and — when nothing is running and no change waits — the rail's.
50 const productionFacts = productionFactsForRepos(ctx, appRepos);
51 const appRows = await shelfRows(ctx, appRepos, { decisions, productionFacts });
4952
5053 // What the crew cost today across every app on the shelf, and how many
5154 // are paused at their ceiling right now — the owner's first question at
@@ -98,7 +101,10 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
98101 );
99102 const runActors = getUsersByIds(
100103 db,
101 running.map((r) => r.actorId).filter((id): id is string => id !== null),
104 [
105 ...running.map((r) => r.actorId),
106 ...[...productionFacts.values()].map((f) => f.actorId),
107 ].filter((id): id is string => id !== null),
102108 );
103109 const byId = new Map(appRepos.map((r) => [r.id, r]));
104110 // Who is working is shown by name here because every app on this shelf is
@@ -119,6 +125,15 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
119125 running.length === 0
120126 ? [...openPullsForRepos(db, appRepos.map((r) => r.id)).values()]
121127 : [];
128 // Still nothing waiting for a look: a deploy that just landed or just
129 // failed is news too, newest first — the same facts the shelf's rows
130 // read, shared rather than asked for twice.
131 const shipped =
132 running.length === 0 && openPulls.length === 0
133 ? [...productionFacts.entries()]
134 .sort(([, a], [, b]) => (a.at < b.at ? 1 : -1))
135 .slice(0, 10)
136 : [];
122137 const now =
123138 running.length > 0
124139 ? listCard(
@@ -153,11 +168,8 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
153168 }),
154169 { head: nowHead },
155170 )
156 : openPulls.length === 0
157 ? card(html`<p class="muted small">Nobody is working right now.</p>`, {
158 head: nowHead,
159 })
160 : listCard(
171 : openPulls.length > 0
172 ? listCard(
161173 await Promise.all(
162174 openPulls.slice(0, 10).map(async (pull) => {
163175 const repo = byId.get(pull.repoId)!;
@@ -182,7 +194,30 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
182194 }),
183195 ),
184196 { head: nowHead },
185 );
197 )
198 : shipped.length > 0
199 ? listCard(
200 shipped.map(([repoId, fact]) => {
201 const repo = byId.get(repoId)!;
202 const href = `${repoBase(repo)}/runs/${fact.runId}`;
203 const who =
204 fact.actorId === null
205 ? "crew"
206 : (runActors.get(fact.actorId)?.username ?? "crew");
207 return row({
208 href,
209 nav: true,
210 lead: icon(fact.word === "shipped" ? "check" : "alert"),
211 title: html`<a href="${href}">${who} · ${repo.name}</a>`,
212 meta: html`${fact.note} · ${time(fact.at)}`,
213 trail: wordBadge(fact.word),
214 });
215 }),
216 { head: nowHead },
217 )
218 : card(html`<p class="muted small">Nobody is working right now.</p>`, {
219 head: nowHead,
220 });
186221
187222 const body = html`<h1 class="sr-only">Studio</h1>
188223 ${costLine}
src/web/studio/shelf.ts +101 −16
@@ -16,18 +16,25 @@
1616 *
1717 * What a row says it is doing, in order: a run still in flight (that is the
1818 * thing moving); else an open change, whoever opened it — the row is about
19 * the app, not about who typed it; else the last run's own word, its
20 * history; else quiet, or told-but-stuck when nobody on the crew can take
21 * it.
19 * the app, not about who typed it; else the most recent of the last run's
20 * own word and production's own fact (`words.ts`'s `productionFact` — a
21 * deploy that just landed or just failed), preferring the run's word when it
22 * already says *shipped* so a change stays credited to whoever's own history
23 * carries it rather than to the deploy behind it; else quiet, or
24 * told-but-stuck when nobody on the crew can take it.
2225 */
2326
24 import { appsRuntime } from "../../apps/runtime.ts";
27 import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts";
2528 import { appsForRepos } from "../../core/apps.ts";
2629 import { reposWithBuilder } from "../../core/agents.ts";
2730 import { reposWithUnclaimedTell } from "../../core/issues.ts";
2831 import { openPullsForRepos } from "../../core/pulls.ts";
2932 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
30 import { isTerminal, latestAgentRunsForRepos } from "../../core/runs.ts";
33 import {
34 getRunsByIds,
35 isTerminal,
36 latestAgentRunsForRepos,
37 } from "../../core/runs.ts";
3138 import { getUsersByIds } from "../../core/users.ts";
3239 import { repoDir } from "../../git/repos.ts";
3340 import type { Ctx } from "../../http/context.ts";
@@ -36,12 +43,59 @@ import { html, type Html } from "../html.ts";
3643 import { dot, envPhrase, envTone, pill, row, time } from "../ui.ts";
3744 import { decisionsFor, type Decision } from "./asks.ts";
3845 import { withLikely } from "./leans.ts";
39 import { pullWord, runWord, type WordCtx } from "./words.ts";
46 import {
47 productionFact,
48 pullWord,
49 runWord,
50 type ProductionFact,
51 type WordCtx,
52 } from "./words.ts";
4053
4154 /** Reads the caller has already made, so the shelf does not make them again. */
4255 export interface ShelfReads {
4356 /** What asks the viewer across these apps — the asks pill counts them. */
4457 readonly decisions?: readonly Decision[] | undefined;
58 /** Production's own fact for each app — a deploy that just landed or just
59 * failed. The home rail reads the same map for its own rows. */
60 readonly productionFacts?: ReadonlyMap<string, ProductionFact> | undefined;
61 }
62
63 /**
64 * Production's own fact for every one of these repositories, in one batch:
65 * `runtime.status` is in-memory (no query), so the only read is the run rows
66 * a fact points to — one `IN` for the whole shelf, never one per row.
67 */
68 export function productionFactsForRepos(
69 ctx: Ctx,
70 repos: readonly Repo[],
71 ): Map<string, ProductionFact> {
72 const db = ctx.app.db;
73 const runtime = appsRuntime(ctx.app);
74 const out = new Map<string, ProductionFact>();
75 if (runtime === undefined) return out;
76 const records = appsForRepos(
77 db,
78 repos.map((r) => r.id),
79 );
80 const productions = new Map<string, EnvStatus | undefined>();
81 const runIds = new Set<string>();
82 for (const repo of repos) {
83 const record = records.get(repo.id);
84 const production = record
85 ? runtime.status(record).find((e) => e.env === "production")
86 : undefined;
87 productions.set(repo.id, production);
88 if (production?.runId !== undefined && production.createdAt !== undefined)
89 runIds.add(production.runId);
90 if (production?.failedDeploy !== undefined)
91 runIds.add(production.failedDeploy.runId);
92 }
93 const runs = getRunsByIds(db, [...runIds]);
94 for (const [repoId, production] of productions) {
95 const fact = productionFact(production, runs);
96 if (fact !== undefined) out.set(repoId, fact);
97 }
98 return out;
4599 }
46100
47101 /**
@@ -71,11 +125,17 @@ export async function shelfRows(
71125 db,
72126 repos.map((r) => r.id),
73127 );
128 // Production's own fact, per app, one batch — a deploy that just landed or
129 // just failed, the other half of what "the last thing that happened" can
130 // mean now that it is not only an agent run.
131 const productionFacts =
132 reads.productionFacts ?? productionFactsForRepos(ctx, repos);
74133 const actors = getUsersByIds(
75134 db,
76 [...latestRuns.values()]
77 .map((r) => r.actorId)
78 .filter((id): id is string => id !== null),
135 [
136 ...[...latestRuns.values()].map((r) => r.actorId),
137 ...[...productionFacts.values()].map((f) => f.actorId),
138 ].filter((id): id is string => id !== null),
79139 );
80140 // The newest open change of every app, one read — a person's own change
81141 // counts here too, not only one an agent run produced.
@@ -118,18 +178,41 @@ export async function shelfRows(
118178 runtime,
119179 record,
120180 };
121 const word = useOpenPull
122 ? await pullWord(wordCtx, openPull)
123 : latest === undefined
181 const agentWord =
182 useOpenPull || latest === undefined
124183 ? undefined
125184 : await runWord(wordCtx, latest);
185 const agentAt =
186 latest === undefined ? undefined : (latest.finishedAt ?? latest.createdAt);
187 const deployFact =
188 useOpenPull || runIsMoving ? undefined : productionFacts.get(repo.id);
189 // Production's fact only displaces the run's own word when the run's
190 // word is not already *shipped* — a change stays credited to whoever's
191 // history carries it, never double-told as the deploy behind it — or
192 // when it is a failure at least as new: #200's case outranks a stale
193 // *shipped* the same way a fresher one would.
194 const useDeployFact =
195 deployFact !== undefined &&
196 (agentWord === undefined ||
197 deployFact.word === "stopped" ||
198 agentWord.word !== "shipped") &&
199 (agentAt === undefined || deployFact.at >= agentAt);
200 const word = useOpenPull
201 ? await pullWord(wordCtx, openPull)
202 : useDeployFact
203 ? { word: deployFact!.word, note: deployFact!.note }
204 : agentWord;
126205 const who = useOpenPull
127206 ? ownerFacts.has(repo.id)
128207 ? openPull.authorName
129208 : undefined
130 : latest?.actorId == null || !ownerFacts.has(repo.id)
131 ? undefined
132 : actors.get(latest.actorId)?.username;
209 : useDeployFact
210 ? deployFact!.actorId == null || !ownerFacts.has(repo.id)
211 ? undefined
212 : actors.get(deployFact!.actorId)?.username
213 : latest?.actorId == null || !ownerFacts.has(repo.id)
214 ? undefined
215 : actors.get(latest.actorId)?.username;
133216 const asks = asksByRepo.get(repo.id) ?? 0;
134217 // The badge is production's own word (`envPhrase`) — whether a newer
135218 // push failed behind what is serving is a fact of the environment, not
@@ -166,7 +249,9 @@ export async function shelfRows(
166249 ? html`· now`
167250 : useOpenPull
168251 ? html`· ${time(openPull.updatedAt)}`
169 : html`· ${time(latest!.finishedAt ?? latest!.createdAt)}`}`;
252 : useDeployFact
253 ? html`· ${time(deployFact!.at)}`
254 : html`· ${time(latest!.finishedAt ?? latest!.createdAt)}`}`;
170255 // Two apps can share a bare name across owners — a row for one that is
171256 // not the viewer's own names its owner, everywhere a row can appear.
172257 const mine = user !== undefined && repo.ownerId === user.id;
src/web/studio/words.ts +85
@@ -186,6 +186,91 @@ export async function runWord(
186186 return { word: "planned", note: run.summary || "nothing to show yet" };
187187 }
188188
189 /**
190 * How long a production deploy stays news on the shelf and the front door's
191 * rail, once nothing has happened since: long enough to survive a coffee
192 * break after shipping, short enough that a deploy from last week does not
193 * crowd out *quiet* as the invitation to tell the crew something. A run in
194 * flight or an open change is never bounded this way — only the one fact
195 * here that has no other way to expire.
196 */
197 export const DEPLOY_NEWS_MS = 24 * 60 * 60_000;
198
199 /** The one production fact worth telling beside a run or a change: a deploy
200 * that just landed, or one that just failed. */
201 export interface ProductionFact {
202 readonly word: Extract<Word, "shipped" | "stopped">;
203 readonly note: string;
204 readonly actorId: string | null;
205 /** The deploy run this fact is about — a link to its log. */
206 readonly runId: string;
207 /** The instant to sort and print this fact by. */
208 readonly at: string;
209 }
210
211 /**
212 * The rule the front door's *happening* half follows for a deploy, decided
213 * once so the next case (a stalled deploy, a rollback) rebases on this
214 * rather than growing a fifth special case: production's own status names
215 * two candidate facts — the deploy now serving, and, when a newer one was
216 * attempted and failed behind it, that failure (#200's case) — and this
217 * picks whichever is news, preferring the failure when both are and the
218 * failure is not older. Neither counts once `DEPLOY_NEWS_MS` has passed
219 * without anything newer.
220 *
221 * A deploy no push asked for — a redeploy someone pressed, the boot
222 * reconcile every restart runs — carries no `createdAt` (`apps/runtime.ts`'s
223 * `PUSH_REASONS`), so it is never news: a person would not call it one, and
224 * without this the front door would read *shipped* after every restart.
225 *
226 * `runs` is the batch lookup of whatever run ids `production` names, read
227 * once for the whole shelf by the caller — never a query here.
228 */
229 export function productionFact(
230 production: EnvStatus | undefined,
231 runs: ReadonlyMap<string, Run>,
232 now = Date.now(),
233 ): ProductionFact | undefined {
234 if (production === undefined) return undefined;
235 const news = (at: string | undefined): at is string =>
236 at !== undefined && now - Date.parse(at) <= DEPLOY_NEWS_MS;
237
238 const failedRun =
239 production.failedDeploy === undefined
240 ? undefined
241 : runs.get(production.failedDeploy.runId);
242 const failedAt = failedRun?.finishedAt ?? failedRun?.createdAt;
243 const failed: ProductionFact | undefined =
244 production.failedDeploy !== undefined && failedRun !== undefined && news(failedAt)
245 ? {
246 word: "stopped",
247 note: `${production.failedDeploy.commitOid.slice(0, 7)} did not come up — ${production.failedDeploy.error}`,
248 actorId: failedRun.actorId,
249 runId: failedRun.id,
250 at: failedAt!,
251 }
252 : undefined;
253
254 const shippedAt = production.servingAt ?? production.createdAt;
255 const shipped: ProductionFact | undefined =
256 production.state === "running" &&
257 production.createdAt !== undefined &&
258 production.runId !== undefined &&
259 news(shippedAt)
260 ? {
261 word: "shipped",
262 note: `production is serving ${production.commitOid.slice(0, 7)}`,
263 actorId: runs.get(production.runId)?.actorId ?? null,
264 runId: production.runId,
265 at: shippedAt!,
266 }
267 : undefined;
268
269 if (failed !== undefined && shipped !== undefined)
270 return failed.at >= shipped.at ? failed : shipped;
271 return failed ?? shipped;
272 }
273
189274 const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = {
190275 working: "loader",
191276 look: "eye",
test/studio.test.ts +218
@@ -4323,6 +4323,224 @@ describe("an open change is never quiet on the front door", () => {
43234323 });
43244324 });
43254325
4326 /**
4327 * Issue #215: once a merged change closes, it drops out of
4328 * `openPullsForRepos`, and before this the shelf and the rail had nothing
4329 * left to say about it but "quiet" — the row's *happening* half spoke only
4330 * for an agent run, never a deploy. `productionFact` (`words.ts`) reads
4331 * production's own status for the fact a merge or a push leaves behind:
4332 * the deploy that landed, or one that failed behind what still serves.
4333 */
4334 describe("a deploy that just landed is news on the front door", () => {
4335 let dataDir: string;
4336 let app: App;
4337 let servers: Servers;
4338 let base: string;
4339 let runtime: AppsRuntime;
4340 let repo: Repo;
4341 let record: AppRecord;
4342 let quietRepo: Repo;
4343 let alice: User;
4344 let cookie: string;
4345 let deployRun: Run;
4346
4347 interface PreviewSlot {
4348 readonly app: AppRecord;
4349 readonly env: EnvName;
4350 readonly chain: Promise<void>;
4351 readonly restarts: number[];
4352 deploying?: unknown;
4353 live?: unknown;
4354 lastError?: string;
4355 lastRunId?: string;
4356 }
4357 const putEnv = (
4358 appRecord: AppRecord,
4359 env: EnvName,
4360 slot: Omit<PreviewSlot, "app" | "env" | "chain" | "restarts">,
4361 ): void => {
4362 (runtime as unknown as { envs: Map<string, PreviewSlot> }).envs.set(
4363 `${appRecord.id}/${env}`,
4364 { app: appRecord, env, chain: Promise.resolve(), restarts: [], ...slot },
4365 );
4366 };
4367
4368 const get = (path: string) =>
4369 fetch(`${base}${path}`, {
4370 headers: { cookie: `${cookie}; forge_mode=studio` },
4371 redirect: "manual",
4372 });
4373
4374 const shelfRowFor = (home: string, href: string): string => {
4375 const at = home.indexOf(`href="${href}"`);
4376 expect(at).toBeGreaterThan(-1);
4377 const start = home.lastIndexOf("<li>", at);
4378 return home.slice(start, home.indexOf("</li>", at) + 5);
4379 };
4380
4381 beforeAll(async () => {
4382 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-shipped-"));
4383 app = createApp({ dataDir, port: 0 });
4384 servers = await serve(app);
4385 base = servers.url;
4386 // Uncalled `.start()`: this runtime never subscribes to the bus, so
4387 // nothing here spins a real process — its `envs` are only ever what
4388 // `putEnv` puts there, the same fiction the live-fragment tests use.
4389 runtime = new AppsRuntime(app, {
4390 domain: "shipped.test",
4391 scheme: "http",
4392 publicPort: servers.port,
4393 forgeUrl: servers.url,
4394 concurrency: 1,
4395 });
4396 const a = await createUser(app.db, {
4397 username: "alice",
4398 email: "[email protected]",
4399 password: "a-good-passphrase",
4400 adminIfFirst: true,
4401 });
4402 if (!a.ok) throw new Error(a.error);
4403 alice = a.value;
4404 cookie = (
4405 (
4406 await fetch(`${base}/signin`, {
4407 method: "POST",
4408 body: new URLSearchParams({
4409 username: "alice",
4410 password: "a-good-passphrase",
4411 }),
4412 redirect: "manual",
4413 })
4414 ).headers.get("set-cookie") ?? ""
4415 ).split(";")[0]!;
4416
4417 const made = await createRepo(app, alice, {
4418 name: "sap",
4419 visibility: "private",
4420 description: "a sap for notes",
4421 });
4422 if (!made.ok) throw new Error(String(made.error));
4423 repo = made.value;
4424 const seeded = await commitToBranch(app, alice, repo, {
4425 message: "Start",
4426 files: [{ path: "README.md", content: "sap\n" }],
4427 });
4428 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
4429 const enabled = await enableApp(app, alice, repo, { slug: "sap" });
4430 if (!enabled.ok) throw new Error(String(enabled.error));
4431 record = getAppByRepo(app.db, repo.id)!;
4432
4433 deployRun = createRun(app.db, {
4434 repoId: repo.id,
4435 kind: "deploy",
4436 env: "production",
4437 commitOid: "6c99218",
4438 reason: "merge",
4439 actorId: alice.id,
4440 });
4441 claimRun(app.db, deployRun.id);
4442 deployRun =
4443 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }) ??
4444 deployRun;
4445 const servingAt = new Date().toISOString();
4446 putEnv(record, "production", {
4447 live: {
4448 commitOid: "6c99218",
4449 handle: { port: 4321 },
4450 runId: deployRun.id,
4451 since: servingAt,
4452 servingAt,
4453 createdAt: servingAt,
4454 manifest: { tty: null },
4455 },
4456 });
4457
4458 // A second app, otherwise identical, that never shipped — the control
4459 // that must keep reading quiet.
4460 const madeQuiet = await createRepo(app, alice, {
4461 name: "quiet2",
4462 visibility: "private",
4463 description: "says nothing",
4464 });
4465 if (!madeQuiet.ok) throw new Error(String(madeQuiet.error));
4466 const seededQuiet = await commitToBranch(app, alice, madeQuiet.value, {
4467 message: "Start",
4468 files: [{ path: "README.md", content: "quiet\n" }],
4469 });
4470 if (!seededQuiet.ok) throw new Error(JSON.stringify(seededQuiet.error));
4471 const enabledQuiet = await enableApp(app, alice, madeQuiet.value, {
4472 slug: "quiet2",
4473 });
4474 if (!enabledQuiet.ok) throw new Error(String(enabledQuiet.error));
4475 quietRepo = madeQuiet.value;
4476 });
4477
4478 afterAll(async () => {
4479 servers.stop();
4480 app.close();
4481 await rm(dataDir, { recursive: true, force: true });
4482 });
4483
4484 test("the shelf row reads shipped, with who and when, not quiet", async () => {
4485 const home = await (await get("/")).text();
4486 const shelfRow = shelfRowFor(home, "/alice/sap");
4487 expect(shelfRow).not.toContain("quiet — tell the crew something");
4488 expect(shelfRow).toContain('data-word="shipped"');
4489 expect(shelfRow).toContain("alice");
4490 });
4491
4492 test('"Happening now" links to what shipped instead of reporting silence', async () => {
4493 const home = await (await get("/")).text();
4494 expect(home).not.toContain("Nobody is working right now.");
4495 expect(home).toContain(`href="/alice/sap/runs/${deployRun.id}"`);
4496 });
4497
4498 test("an app with no recent deploy still reads quiet", async () => {
4499 const home = await (await get("/")).text();
4500 const shelfRow = shelfRowFor(home, `/alice/${quietRepo.name}`);
4501 expect(shelfRow).toContain("quiet — tell the crew something");
4502 });
4503
4504 test("a deploy no push asked for is not news: a redeploy or the boot reconcile does not read shipped", async () => {
4505 const restartedAt = new Date().toISOString();
4506 putEnv(record, "production", {
4507 live: {
4508 commitOid: "6c99218",
4509 handle: { port: 4321 },
4510 runId: deployRun.id,
4511 since: restartedAt,
4512 servingAt: restartedAt,
4513 // No `createdAt`: nothing asked a push for this one — a redeploy
4514 // someone pressed, or the boot reconcile every restart runs.
4515 manifest: { tty: null },
4516 },
4517 });
4518 const home = await (await get("/")).text();
4519 const shelfRow = shelfRowFor(home, "/alice/sap");
4520 expect(shelfRow).not.toContain('data-word="shipped"');
4521 expect(shelfRow).toContain("quiet — tell the crew something");
4522 });
4523
4524 test("a deploy older than the news window reads quiet again, not shipped forever", async () => {
4525 const old = new Date(Date.now() - 25 * 60 * 60_000).toISOString();
4526 putEnv(record, "production", {
4527 live: {
4528 commitOid: "6c99218",
4529 handle: { port: 4321 },
4530 runId: deployRun.id,
4531 since: old,
4532 servingAt: old,
4533 createdAt: old,
4534 manifest: { tty: null },
4535 },
4536 });
4537 const home = await (await get("/")).text();
4538 const shelfRow = shelfRowFor(home, "/alice/sap");
4539 expect(shelfRow).not.toContain('data-word="shipped"');
4540 expect(shelfRow).toContain("quiet — tell the crew something");
4541 });
4542 });
4543
43264544 /**
43274545 * Issue #65: a public app's studio page is the app, not the owner's books.
43284546 * `canSeeOwnerFacts` (`src/core/repos.ts`) is the one predicate — the same

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs