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

An app that is live says nothing: production's own output reaches the page only while it is deploying #321

Merged opened this

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

Merged

scout/forge:scout/284-an-app-that-is-live-says-nothing-product → main 1 commit fork

  • Merged. scout/284-an-app-that-is-live-says-nothing-product was merged into main as 7116c7b.

Review

Sign in to review this change.
5 files changed +418 −51
src/http/api.ts +37 −3
@@ -173,6 +173,7 @@ import { decisionStats, listDecisions } from "../core/decisions.ts";
173173 import { globalMapJson, mapJson, marksFor } from "../web/pages/map.ts";
174174 import { appsRuntime, type AppsRuntime } from "../apps/runtime.ts";
175175 import { isEnvName } from "../apps/data.ts";
176 import { getAppByRepo } from "../core/apps.ts";
176177 import type { Agent } from "../core/agents.ts";
177178 import { listApps, type AppRecord } from "../core/apps.ts";
178179 import { listAssignedTo } from "../core/issues.ts";
@@ -2732,6 +2733,27 @@ export function apiRoutes(router: Router<Handler>): void {
27322733 // proxy in front of the forge cuts long responses — sends the last it
27332734 // saw and hears only what followed, never the log again from the top.
27342735 const seen = Number(ctx.request.headers.get("last-event-id") ?? "0") || 0;
2736 // A deploy run's own file keeps growing for as long as its app process
2737 // runs (`apps/process.ts` pumps its stdout/stderr for the process's
2738 // life, into the same log) — long past the run itself finishing
2739 // (`apps/runtime.ts` calls `finishRun` at the swap-in, not at exit). So
2740 // a finished run whose environment still points at it as the one
2741 // serving has not actually stopped saying things, and the stream must
2742 // not hang up on it (#284).
2743 const stillLive = (): boolean => {
2744 if (!isEnvName(run.env)) return false;
2745 const record = getAppByRepo(ctx.app.db, repo.id);
2746 const runtime = appsRuntime(ctx.app);
2747 if (!record || !runtime) return false;
2748 const status = runtime.envStatus(record, run.env);
2749 // "stopped" and "failed" keep the last run's id too — as history, not
2750 // as a claim that it is still the one serving — so only "running" and
2751 // "deploying" (a swap-in still checking its own tests) count.
2752 return (
2753 status?.runId === run.id &&
2754 (status.state === "running" || status.state === "deploying")
2755 );
2756 };
27352757 return eventStream(ctx.request, (emit, close) => {
27362758 const controller = new AbortController();
27372759 let index = 0;
@@ -2749,11 +2771,23 @@ export function apiRoutes(router: Router<Handler>): void {
27492771 close();
27502772 };
27512773 const unsubscribe = ctx.app.bus.subscribe((event) => {
2752 if (event.kind !== "run.finished" || event.payload["runId"] !== run.id)
2774 if (event.kind === "run.finished" && event.payload["runId"] === run.id) {
2775 if (!stillLive()) finish(getRun(ctx.app.db, run.id) ?? run);
27532776 return;
2754 finish(getRun(ctx.app.db, run.id) ?? run);
2777 }
2778 // A redeploy that swaps this run out, or the environment being
2779 // stopped, is what actually retires its log — only then, for a run
2780 // already finished, does the stream close.
2781 if (
2782 (event.kind === "app.serving" || event.kind === "app.stopped") &&
2783 event.repoId === repo.id &&
2784 event.payload["env"] === run.env &&
2785 isTerminal(run.state) &&
2786 !stillLive()
2787 )
2788 finish(getRun(ctx.app.db, run.id) ?? run);
27552789 });
2756 if (isTerminal(run.state)) finish(run);
2790 if (isTerminal(run.state) && !stillLive()) finish(run);
27572791 return () => {
27582792 controller.abort();
27592793 unsubscribe();
src/web/studio/app.ts +9 −6
@@ -213,11 +213,14 @@ export async function studioApp(
213213 : autonomyStatus(ctx.app, repo);
214214
215215 // ---- the app itself ----
216 // `deployTailing` says the head card already carries a live tail of
217 // production's own deploy (`studio/previews.ts`'s `deployTail`) — the
218 // no-script refresh below must keep the page moving for that case too,
219 // not only for an agent run's tail beside a "Now" row.
220 const { head, previews, deployTailing } = appPanels(
216 // `deployTailing` says the head card is tailing production's own deploy,
217 // narrowly — just the "Now" panel's empty-state words below care about
218 // that distinction. `tailing` says the head card carries any live tail at
219 // all, deploy or a running app's own output (`studio/previews.ts`'s
220 // `productionTail`, #284) — the no-script refresh below must keep the
221 // page moving for either, not only for an agent run's tail beside a "Now"
222 // row.
223 const { head, previews, deployTailing, tailing } = appPanels(
221224 ctx,
222225 repo,
223226 record,
@@ -633,6 +636,6 @@ export async function studioApp(
633636 // The no-script half of the tail: while the crew's run goes, the page
634637 // refreshes itself, the way the run's own page does; the script above
635638 // removes this the moment it can stream lines instead.
636 ...(activeRun || deployTailing ? { refresh: { seconds: 5 } } : {}),
639 ...(activeRun || tailing ? { refresh: { seconds: 5 } } : {}),
637640 });
638641 }
src/web/studio/previews.ts +76 −27
@@ -152,37 +152,76 @@ function previewsPanel(
152152 }
153153
154154 /**
155 * Production coming up, tailed: while its deploy is running and no agent
156 * run has the crew's own tail already (`studio/app.ts`'s "Now" panel), the
157 * head card gets the same peek at the log a running agent run gets beside
158 * its row — the app's own first thirty seconds are not a blank page. The
159 * agent's run wins the one stream this page opens when both are going: it
160 * is the crew's own answer to something asked, where a deploy is often
161 * background (a redeploy after an unrelated push). When "Now" would
162 * otherwise sit empty, its own empty state points back up at this tail
163 * rather than opening a second stream of the same lines.
155 * Production, tailed: while its deploy is running, and for as long as it
156 * keeps running after (#284) — its process's stdout and stderr keep landing
157 * in the same run's log past the swap-in (`apps/process.ts` pumps them for
158 * the process's own life, not just its start) — the head card gets a peek
159 * at it, the same shape a running agent run gets beside its row. No agent
160 * run may already have the crew's own tail (`studio/app.ts`'s "Now" panel):
161 * the agent's run wins the one stream this page opens when both are going,
162 * since it is the crew's own answer to something asked, where production's
163 * own voice is passive by comparison. When "Now" would otherwise sit empty
164 * while a deploy is the thing going, its own empty state points back up at
165 * this tail rather than opening a second stream of the same lines.
166 *
167 * Once the deploy has swapped in, the lines are the app's own — never the
168 * deploy's, whose word (`envPhrase`) has already moved on to "live" — and
169 * an app's stdout can carry anything the owner never meant for a stranger,
170 * so only a viewer who can see the owner's facts is shown it at all.
164171 */
165 function deployTail(
172 function productionTail(
166173 ctx: Ctx,
167174 repo: Repo,
168175 production: EnvStatus | undefined,
169 ): Html | undefined {
170 if (production?.state !== "deploying" || production.runId === undefined)
171 return undefined;
176 writer: boolean,
177 ): { box: Html | undefined; deploying: boolean } {
178 const none = { box: undefined, deploying: false };
179 if (production === undefined || production.runId === undefined) return none;
180 const deploying = production.state === "deploying";
181 if (!deploying && production.state !== "running") return none;
182 if (!deploying && !writer) return none;
172183 const agentBusy = listRuns(ctx.app.db, repo.id, {
173184 kind: "agent",
174185 limit: 8,
175186 }).some((r) => r.state === "running");
176 if (agentBusy) return undefined;
177 return logBox({
178 id: "app-run-tail",
179 who: runVoice("deploy", true, { name: undefined, isAgent: true }),
180 cap: LOG_TAIL_CAP,
181 lines: readRunLogTail(ctx.app.config.dataDir, production.runId, LOG_TAIL_CAP)
182 .lines,
183 stream: `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${production.runId}/log?follow=1`,
184 className: "log-tail",
185 });
187 if (agentBusy) return none;
188 const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${production.runId}/log?follow=1`;
189 if (deploying)
190 return {
191 box: logBox({
192 id: "app-run-tail",
193 who: runVoice("deploy", true, { name: undefined, isAgent: true }),
194 cap: LOG_TAIL_CAP,
195 lines: readRunLogTail(ctx.app.config.dataDir, production.runId, LOG_TAIL_CAP)
196 .lines,
197 stream,
198 className: "log-tail",
199 }),
200 deploying: true,
201 };
202 const { lines } = readRunLogTail(
203 ctx.app.config.dataDir,
204 production.runId,
205 LOG_TAIL_CAP,
206 );
207 if (lines.length === 0)
208 return {
209 box: html`<p class="muted small" id="app-run-tail">
210 Production is quiet — it hasn't said anything yet.
211 </p>`,
212 deploying: false,
213 };
214 return {
215 box: logBox({
216 id: "app-run-tail",
217 who: "the app",
218 cap: LOG_TAIL_CAP,
219 lines,
220 stream,
221 className: "log-tail",
222 }),
223 deploying: false,
224 };
186225 }
187226
188227 /**
@@ -196,7 +235,12 @@ export function appPanels(
196235 repo: Repo,
197236 record: AppRecord,
198237 openPulls: readonly Pull[],
199 ): { head: Html; previews: Html | null; deployTailing: boolean } {
238 ): {
239 head: Html;
240 previews: Html | null;
241 deployTailing: boolean;
242 tailing: boolean;
243 } {
200244 const db = ctx.app.db;
201245 const base = repoBase(repo);
202246 const runtime = appsRuntime(ctx.app);
@@ -205,7 +249,7 @@ export function appPanels(
205249 const production = envs.find((e) => e.env === "production");
206250 const url = runtime?.urlFor(record, "production");
207251 const writer = canSeeOwnerFacts(db, repo, ctx.auth.user);
208 const tail = deployTail(ctx, repo, production);
252 const tail = productionTail(ctx, repo, production, writer);
209253 // The state in one word, and under it the same facts the App page shows —
210254 // why it is not up, and the deploy whose log says the rest.
211255 const live = envPhrase(production);
@@ -238,14 +282,19 @@ export function appPanels(
238282 ctx.app.config.sshHostKey,
239283 ),
240284 )}
241 ${tail ?? null}
285 ${tail.box ?? null}
242286 </div>
243287 <span class="spacer"></span>
244288 ${lookNow(ctx, repo, record)}
245289 ${openAppBtn(base, production)}
246290 ${when(production, (env) => envActions(base, env, writer))}
247291 </div>`;
248 return { head, previews, deployTailing: tail !== undefined };
292 return {
293 head,
294 previews,
295 deployTailing: tail.deploying,
296 tailing: tail.box !== undefined,
297 };
249298 }
250299
251300 /**
test/apps-api.test.ts +100 −11
@@ -17,6 +17,36 @@ import { getUserByName } from "../src/core/users.ts";
1717 import { serve, type Servers } from "../src/http/server.ts";
1818 import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts";
1919
20 /** Read from an SSE body until `predicate(buffer)` holds or we time out —
21 * never to a "done" that #284's still-live stream no longer sends. */
22 async function readUntil(
23 body: ReadableStream<Uint8Array>,
24 predicate: (buf: string) => boolean,
25 timeoutMs = 3000,
26 ): Promise<string> {
27 const reader = body.getReader();
28 const decoder = new TextDecoder();
29 let buf = "";
30 const deadline = Date.now() + timeoutMs;
31 try {
32 while (Date.now() < deadline) {
33 const timeout = new Promise<{ done: true; value: undefined }>((r) =>
34 setTimeout(
35 () => r({ done: true, value: undefined }),
36 Math.max(0, deadline - Date.now()),
37 ),
38 );
39 const { done, value } = await Promise.race([reader.read(), timeout]);
40 if (done) break;
41 if (value) buf += decoder.decode(value, { stream: true });
42 if (predicate(buf)) break;
43 }
44 } finally {
45 reader.cancel().catch(() => {});
46 }
47 return buf;
48 }
49
2050 describe("apps, runs and the crew over the API", () => {
2151 let dataDir: string;
2252 let app: App;
@@ -194,7 +224,7 @@ describe("apps, runs and the crew over the API", () => {
194224 expect(tokens?.n ?? 0).toBe(0);
195225 }, 60_000);
196226
197 test("the production run, its log as text and as a live tail", async () => {
227 test("the production run, its log as text and as a live tail that outlives the run itself (#284)", async () => {
198228 const run = await untilRun((r) => r.env === "production");
199229 expect(run.state).toBe("ok");
200230 const view = (await (
@@ -209,26 +239,43 @@ describe("apps, runs and the crew over the API", () => {
209239 await api(`/api/v1/repos/alice/hello/runs/${run.id}/log`)
210240 ).text();
211241 expect(text).toContain("ready:");
212 const tail = await api(
213 `/api/v1/repos/alice/hello/runs/${run.id}/log?follow=1`,
214 );
242
243 // The run itself is already "ok", but its environment still points at
244 // it as the one serving — its process keeps writing to this very log
245 // (`apps/process.ts` pumps its stdout for the process's life), so the
246 // stream must not hang up on it the moment it connects (#284).
247 const controller = new AbortController();
248 const tail = await api(`/api/v1/repos/alice/hello/runs/${run.id}/log?follow=1`, {
249 signal: controller.signal,
250 });
215251 expect(tail.headers.get("content-type")).toContain("text/event-stream");
216 const events = await tail.text(); // the run is finished, so the stream closes after `done`
252 const events = await readUntil(tail.body!, (b) => b.includes("ready:"));
217253 expect(events).toContain("event: line");
218 expect(events).toContain("event: done");
254 expect(events).not.toContain("event: done");
219255 // Every line is numbered, and a browser that reconnects with the last
220256 // number it saw hears only what followed.
221257 expect(events).toMatch(/id: 1\nevent: line\n/);
222258 const total = (events.match(/event: line\n/g) ?? []).length;
223259 expect(total).toBeGreaterThan(2);
224 const resumed = await (
225 await api(`/api/v1/repos/alice/hello/runs/${run.id}/log?follow=1`, {
260 controller.abort();
261
262 const resumeController = new AbortController();
263 const resumedStream = await api(
264 `/api/v1/repos/alice/hello/runs/${run.id}/log?follow=1`,
265 {
226266 headers: { "last-event-id": String(total - 1) },
227 })
228 ).text();
267 signal: resumeController.signal,
268 },
269 );
270 const resumed = await readUntil(
271 resumedStream.body!,
272 (b) => (b.match(/event: line\n/g) ?? []).length >= 1,
273 );
229274 expect((resumed.match(/event: line\n/g) ?? []).length).toBe(1);
230275 expect(resumed).toContain(`id: ${total}\n`);
231 expect(resumed).toContain("event: done");
276 expect(resumed).not.toContain("event: done");
277 resumeController.abort();
278
232279 const one = (await (
233280 await api(`/api/v1/repos/alice/hello/runs/${run.id}`)
234281 ).json()) as { id: string };
@@ -274,12 +321,54 @@ describe("apps, runs and the crew over the API", () => {
274321 ).json()) as { url: string };
275322 expect(granted.url).toContain("/__forge/grant?t=");
276323
324 // The run still serving keeps a follow stream open past its own finish
325 // (#284) — but only while it still serves: stopping the environment is
326 // what actually retires it, and the stream must close then.
327 const runs = (await (
328 await api("/api/v1/repos/alice/hello/runs?env=production&limit=1")
329 ).json()) as { items: { id: string }[] };
330 const liveRunId = runs.items[0]!.id;
331 const controller = new AbortController();
332 const tail = await api(
333 `/api/v1/repos/alice/hello/runs/${liveRunId}/log?follow=1`,
334 { signal: controller.signal },
335 );
336 const reader = tail.body!.getReader();
337 const decoder = new TextDecoder();
338 let buf = "";
339 const readFor = async (
340 predicate: (b: string) => boolean,
341 timeoutMs = 3000,
342 ): Promise<void> => {
343 const deadline = Date.now() + timeoutMs;
344 while (Date.now() < deadline) {
345 const timeout = new Promise<{ done: true; value: undefined }>((r) =>
346 setTimeout(
347 () => r({ done: true, value: undefined }),
348 Math.max(0, deadline - Date.now()),
349 ),
350 );
351 const { done, value } = await Promise.race([reader.read(), timeout]);
352 if (done) break;
353 if (value) buf += decoder.decode(value, { stream: true });
354 if (predicate(buf)) break;
355 }
356 };
357 await readFor((b) => b.includes("event: line"));
358 expect(buf).toContain("event: line");
359 expect(buf).not.toContain("event: done");
360
277361 const stopped = await post(
278362 "/api/v1/repos/alice/hello/app/production/stop",
279363 {},
280364 rw,
281365 );
282366 expect(stopped.status).toBe(200);
367
368 await readFor((b) => b.includes("event: done"));
369 expect(buf).toContain("event: done");
370 reader.cancel().catch(() => {});
371 controller.abort();
283372 const redeployed = await post(
284373 "/api/v1/repos/alice/hello/app/production/redeploy",
285374 {},
test/studio.test.ts +196 −4
@@ -4164,7 +4164,10 @@ describe("the app's page tails the deploy that is bringing it alive", () => {
41644164 finishRun(app.db, agentRun.id, { state: "ok", summary: "planned" });
41654165 });
41664166
4167 test("with neither a deploy nor an agent run going, the page carries no tail at all", async () => {
4167 // Issue #284: production stays "live" long after its deploy finishes, and
4168 // a fresh live process that has written nothing to its log yet is not an
4169 // empty black box — the card says so in a line instead.
4170 test("with no agent run going and production live but silent, the head card says so in a line, not an empty box", async () => {
41684171 putEnv("production", {
41694172 live: {
41704173 commitOid: "d0d0d0d0",
@@ -4175,8 +4178,194 @@ describe("the app's page tails the deploy that is bringing it alive", () => {
41754178 },
41764179 });
41774180 const page = await (await get("/alice/kindling", owner)).text();
4181 expect(page).toContain('id="app-run-tail"');
4182 expect(page).not.toContain('class="run-log');
4183 expect(page).toContain("Production is quiet");
4184 // Still nothing to fetch — the page keeps polling in case that changes.
4185 expect(page).toMatch(/<meta\s+http-equiv="refresh"/);
4186 });
4187 });
4188
4189 /**
4190 * Issue #284: production keeps writing to the same run's log for as long as
4191 * its process runs, long after the deploy that produced it is finished —
4192 * the head card's tail follows that, not just a deploy in flight.
4193 */
4194 describe("the app's page tails production's own output while it runs (#284)", () => {
4195 let dataDir: string;
4196 let app: App;
4197 let servers: Servers;
4198 let base: string;
4199 let runtime: AppsRuntime;
4200 let repo: Repo;
4201 let record: AppRecord;
4202 let owner: string;
4203 let strangerCookie: string;
4204 let scoutId: string;
4205
4206 interface PreviewSlot {
4207 readonly app: AppRecord;
4208 readonly env: EnvName;
4209 readonly chain: Promise<void>;
4210 readonly restarts: number[];
4211 deploying?: unknown;
4212 live?: unknown;
4213 lastError?: string;
4214 lastRunId?: string;
4215 }
4216 const putEnv = (
4217 env: EnvName,
4218 slot: Omit<PreviewSlot, "app" | "env" | "chain" | "restarts">,
4219 ): void => {
4220 (runtime as unknown as { envs: Map<string, PreviewSlot> }).envs.set(
4221 `${record.id}/${env}`,
4222 { app: record, env, chain: Promise.resolve(), restarts: [], ...slot },
4223 );
4224 };
4225
4226 const signIn = async (username: string): Promise<string> =>
4227 (
4228 (
4229 await fetch(`${base}/signin`, {
4230 method: "POST",
4231 body: new URLSearchParams({
4232 username,
4233 password: "a-good-passphrase",
4234 }),
4235 redirect: "manual",
4236 })
4237 ).headers.get("set-cookie") ?? ""
4238 ).split(";")[0]!;
4239
4240 const get = (path: string, cookie: string) =>
4241 fetch(`${base}${path}`, {
4242 headers: { cookie: `${cookie}; forge_mode=studio` },
4243 redirect: "manual",
4244 });
4245
4246 beforeAll(async () => {
4247 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-live-tail-"));
4248 app = createApp({ dataDir, port: 0 });
4249 servers = await serve(app);
4250 base = servers.url;
4251 runtime = new AppsRuntime(app, {
4252 domain: "livetail.test",
4253 scheme: "http",
4254 publicPort: servers.port,
4255 forgeUrl: servers.url,
4256 concurrency: 1,
4257 });
4258 const a = await createUser(app.db, {
4259 username: "alice",
4260 email: "[email protected]",
4261 password: "a-good-passphrase",
4262 adminIfFirst: true,
4263 });
4264 if (!a.ok) throw new Error(a.error);
4265 const c = await createUser(app.db, {
4266 username: "carol",
4267 email: "[email protected]",
4268 password: "a-good-passphrase",
4269 });
4270 if (!c.ok) throw new Error(c.error);
4271 owner = await signIn("alice");
4272 strangerCookie = await signIn("carol");
4273 const made = await createRepo(app, a.value, {
4274 name: "furnace",
4275 visibility: "public",
4276 description: "an app that keeps a fire going",
4277 });
4278 if (!made.ok) throw new Error(String(made.error));
4279 repo = made.value;
4280 const seeded = await commitToBranch(app, a.value, repo, {
4281 message: "Start",
4282 files: [{ path: "README.md", content: "furnace\n" }],
4283 });
4284 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
4285 const enabled = await enableApp(app, a.value, repo, { slug: "furnace" });
4286 if (!enabled.ok) throw new Error(String(enabled.error));
4287 record = getAppByRepo(app.db, repo.id)!;
4288 const scout = await createAgent(app, a.value, { username: "scout" });
4289 if (!scout.ok) throw new Error(String(scout.error));
4290 scoutId = scout.value.user.id;
4291 expect(addToCrew(app, a.value, repo, "scout").ok).toBe(true);
4292 });
4293
4294 afterAll(async () => {
4295 servers.stop();
4296 app.close();
4297 await rm(dataDir, { recursive: true, force: true });
4298 });
4299
4300 test("production running with lines in its log carries the tail, named as the app's own", async () => {
4301 const deployRun = createRun(app.db, {
4302 repoId: repo.id,
4303 kind: "deploy",
4304 env: "production",
4305 commitOid: "b16b00b5",
4306 reason: "push",
4307 });
4308 claimRun(app.db, deployRun.id);
4309 for (let i = 0; i < 9; i++)
4310 appendRunLog(app.config.dataDir, deployRun.id, `serving request ${i}`);
4311 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
4312 putEnv("production", {
4313 live: {
4314 commitOid: "b16b00b5",
4315 handle: { port: 4503 },
4316 runId: deployRun.id,
4317 since: new Date().toISOString(),
4318 manifest: { tty: null },
4319 },
4320 });
4321
4322 const page = await (await get("/alice/furnace", owner)).text();
4323 expect(page).toContain('id="app-run-tail"');
4324 expect(page).toContain('data-who="the app"');
4325 expect(page).toContain(
4326 `data-stream="/api/v1/repos/alice/furnace/runs/${deployRun.id}/log?follow=1"`,
4327 );
4328 expect(page).not.toContain(">serving request 0<");
4329 expect(page).toContain(">serving request 8<");
4330 expect(page).toContain("Holding the last 6 lines here");
4331 expect(page).toMatch(/<meta\s+http-equiv="refresh"/);
4332
4333 // Issue #284's own point: the run this reads from is long finished —
4334 // the tail follows the env that points at it, not the run's own state.
4335 expect(getRun(app.db, deployRun.id)?.state).toBe("ok");
4336 });
4337
4338 test("a viewer who cannot see the owner's facts gets no output at all", async () => {
4339 const response = await get("/alice/furnace", strangerCookie);
4340 expect(response.status).toBe(200);
4341 const page = await response.text();
41784342 expect(page).not.toContain('id="app-run-tail"');
4179 expect(page).not.toMatch(/<meta\s+http-equiv="refresh"/);
4343 expect(page).not.toContain("serving request");
4344 });
4345
4346 test("an agent run at work still outranks production's own tail", async () => {
4347 const tell = openIssue(app, getUserByName(app.db, "alice")!, repo, {
4348 title: "Add a chimney",
4349 });
4350 if (!tell.ok) throw new Error(String(tell.error));
4351 const agentRun = createRun(app.db, {
4352 repoId: repo.id,
4353 kind: "agent",
4354 issueId: tell.value.id,
4355 actorId: scoutId,
4356 reason: "assigned",
4357 });
4358 claimRun(app.db, agentRun.id);
4359 appendRunLog(app.config.dataDir, agentRun.id, "scout is on it");
4360
4361 const page = await (await get("/alice/furnace", owner)).text();
4362 expect(page.match(/id="app-run-tail"/g)?.length).toBe(1);
4363 expect(page).not.toContain('data-who="the app"');
4364 expect(page).toContain(
4365 `data-stream="/api/v1/repos/alice/furnace/runs/${agentRun.id}/log?follow=1"`,
4366 );
4367
4368 finishRun(app.db, agentRun.id, { state: "ok", summary: "planned" });
41804369 });
41814370 });
41824371
@@ -4307,7 +4496,7 @@ describe(`"Now" while production is coming up and nothing else is`, () => {
43074496 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
43084497 });
43094498
4310 test("with nothing going at all, \"Now\" reads exactly as it does today", async () => {
4499 test("with nothing going at all, \"Now\" reads exactly as it does today — production live and silent says its own quiet line above, not through \"Now\"", async () => {
43114500 putEnv("production", {
43124501 live: {
43134502 commitOid: "eae580a",
@@ -4320,7 +4509,10 @@ describe(`"Now" while production is coming up and nothing else is`, () => {
43204509
43214510 const page = await (await get("/alice/ember", owner)).text();
43224511 expect(page).toContain("Nothing yet. Tell the crew something.");
4323 expect(page).not.toContain('id="app-run-tail"');
4512 // The head card's own quiet line (#284) is not the deploy-in-progress
4513 // wording "Now" uses — production here is live, not coming up.
4514 expect(page).not.toContain("Production is coming up");
4515 expect(page).toContain("Production is quiet");
43244516 });
43254517 });
43264518

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs