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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/284-an-app-that-is-live-says-nothing-product → main 1 commit fork
5 files
changed +418
−51
src/http/api.ts +37 −3
| @@ -173,6 +173,7 @@ import { decisionStats, listDecisions } from "../core/decisions.ts"; | ||
| 173 | 173 | import { globalMapJson, mapJson, marksFor } from "../web/pages/map.ts"; |
| 174 | 174 | import { appsRuntime, type AppsRuntime } from "../apps/runtime.ts"; |
| 175 | 175 | import { isEnvName } from "../apps/data.ts"; |
| 176 | import { getAppByRepo } from "../core/apps.ts"; | |
| 176 | 177 | import type { Agent } from "../core/agents.ts"; |
| 177 | 178 | import { listApps, type AppRecord } from "../core/apps.ts"; |
| 178 | 179 | import { listAssignedTo } from "../core/issues.ts"; |
| @@ -2732,6 +2733,27 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2732 | 2733 | // proxy in front of the forge cuts long responses — sends the last it |
| 2733 | 2734 | // saw and hears only what followed, never the log again from the top. |
| 2734 | 2735 | 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 | }; | |
| 2735 | 2757 | return eventStream(ctx.request, (emit, close) => { |
| 2736 | 2758 | const controller = new AbortController(); |
| 2737 | 2759 | let index = 0; |
| @@ -2749,11 +2771,23 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2749 | 2771 | close(); |
| 2750 | 2772 | }; |
| 2751 | 2773 | 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); | |
| 2753 | 2776 | 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); | |
| 2755 | 2789 | }); |
| 2756 | if (isTerminal(run.state)) finish(run); | |
| 2790 | if (isTerminal(run.state) && !stillLive()) finish(run); | |
| 2757 | 2791 | return () => { |
| 2758 | 2792 | controller.abort(); |
| 2759 | 2793 | unsubscribe(); |
src/web/studio/app.ts +9 −6
| @@ -213,11 +213,14 @@ export async function studioApp( | ||
| 213 | 213 | : autonomyStatus(ctx.app, repo); |
| 214 | 214 | |
| 215 | 215 | // ---- 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( | |
| 221 | 224 | ctx, |
| 222 | 225 | repo, |
| 223 | 226 | record, |
| @@ -633,6 +636,6 @@ export async function studioApp( | ||
| 633 | 636 | // The no-script half of the tail: while the crew's run goes, the page |
| 634 | 637 | // refreshes itself, the way the run's own page does; the script above |
| 635 | 638 | // removes this the moment it can stream lines instead. |
| 636 | ...(activeRun || deployTailing ? { refresh: { seconds: 5 } } : {}), | |
| 639 | ...(activeRun || tailing ? { refresh: { seconds: 5 } } : {}), | |
| 637 | 640 | }); |
| 638 | 641 | } |
src/web/studio/previews.ts +76 −27
| @@ -152,37 +152,76 @@ function previewsPanel( | ||
| 152 | 152 | } |
| 153 | 153 | |
| 154 | 154 | /** |
| 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. | |
| 164 | 171 | */ |
| 165 | function deployTail( | |
| 172 | function productionTail( | |
| 166 | 173 | ctx: Ctx, |
| 167 | 174 | repo: Repo, |
| 168 | 175 | 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; | |
| 172 | 183 | const agentBusy = listRuns(ctx.app.db, repo.id, { |
| 173 | 184 | kind: "agent", |
| 174 | 185 | limit: 8, |
| 175 | 186 | }).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 | }; | |
| 186 | 225 | } |
| 187 | 226 | |
| 188 | 227 | /** |
| @@ -196,7 +235,12 @@ export function appPanels( | ||
| 196 | 235 | repo: Repo, |
| 197 | 236 | record: AppRecord, |
| 198 | 237 | 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 | } { | |
| 200 | 244 | const db = ctx.app.db; |
| 201 | 245 | const base = repoBase(repo); |
| 202 | 246 | const runtime = appsRuntime(ctx.app); |
| @@ -205,7 +249,7 @@ export function appPanels( | ||
| 205 | 249 | const production = envs.find((e) => e.env === "production"); |
| 206 | 250 | const url = runtime?.urlFor(record, "production"); |
| 207 | 251 | const writer = canSeeOwnerFacts(db, repo, ctx.auth.user); |
| 208 | const tail = deployTail(ctx, repo, production); | |
| 252 | const tail = productionTail(ctx, repo, production, writer); | |
| 209 | 253 | // The state in one word, and under it the same facts the App page shows — |
| 210 | 254 | // why it is not up, and the deploy whose log says the rest. |
| 211 | 255 | const live = envPhrase(production); |
| @@ -238,14 +282,19 @@ export function appPanels( | ||
| 238 | 282 | ctx.app.config.sshHostKey, |
| 239 | 283 | ), |
| 240 | 284 | )} |
| 241 | ${tail ?? null} | |
| 285 | ${tail.box ?? null} | |
| 242 | 286 | </div> |
| 243 | 287 | <span class="spacer"></span> |
| 244 | 288 | ${lookNow(ctx, repo, record)} |
| 245 | 289 | ${openAppBtn(base, production)} |
| 246 | 290 | ${when(production, (env) => envActions(base, env, writer))} |
| 247 | 291 | </div>`; |
| 248 | return { head, previews, deployTailing: tail !== undefined }; | |
| 292 | return { | |
| 293 | head, | |
| 294 | previews, | |
| 295 | deployTailing: tail.deploying, | |
| 296 | tailing: tail.box !== undefined, | |
| 297 | }; | |
| 249 | 298 | } |
| 250 | 299 | |
| 251 | 300 | /** |
test/apps-api.test.ts +100 −11
| @@ -17,6 +17,36 @@ import { getUserByName } from "../src/core/users.ts"; | ||
| 17 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| 18 | 18 | import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts"; |
| 19 | 19 | |
| 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 | ||
| 20 | 50 | describe("apps, runs and the crew over the API", () => { |
| 21 | 51 | let dataDir: string; |
| 22 | 52 | let app: App; |
| @@ -194,7 +224,7 @@ describe("apps, runs and the crew over the API", () => { | ||
| 194 | 224 | expect(tokens?.n ?? 0).toBe(0); |
| 195 | 225 | }, 60_000); |
| 196 | 226 | |
| 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 () => { | |
| 198 | 228 | const run = await untilRun((r) => r.env === "production"); |
| 199 | 229 | expect(run.state).toBe("ok"); |
| 200 | 230 | const view = (await ( |
| @@ -209,26 +239,43 @@ describe("apps, runs and the crew over the API", () => { | ||
| 209 | 239 | await api(`/api/v1/repos/alice/hello/runs/${run.id}/log`) |
| 210 | 240 | ).text(); |
| 211 | 241 | 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 | }); | |
| 215 | 251 | 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:")); | |
| 217 | 253 | expect(events).toContain("event: line"); |
| 218 | expect(events).toContain("event: done"); | |
| 254 | expect(events).not.toContain("event: done"); | |
| 219 | 255 | // Every line is numbered, and a browser that reconnects with the last |
| 220 | 256 | // number it saw hears only what followed. |
| 221 | 257 | expect(events).toMatch(/id: 1\nevent: line\n/); |
| 222 | 258 | const total = (events.match(/event: line\n/g) ?? []).length; |
| 223 | 259 | 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 | { | |
| 226 | 266 | 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 | ); | |
| 229 | 274 | expect((resumed.match(/event: line\n/g) ?? []).length).toBe(1); |
| 230 | 275 | expect(resumed).toContain(`id: ${total}\n`); |
| 231 | expect(resumed).toContain("event: done"); | |
| 276 | expect(resumed).not.toContain("event: done"); | |
| 277 | resumeController.abort(); | |
| 278 | ||
| 232 | 279 | const one = (await ( |
| 233 | 280 | await api(`/api/v1/repos/alice/hello/runs/${run.id}`) |
| 234 | 281 | ).json()) as { id: string }; |
| @@ -274,12 +321,54 @@ describe("apps, runs and the crew over the API", () => { | ||
| 274 | 321 | ).json()) as { url: string }; |
| 275 | 322 | expect(granted.url).toContain("/__forge/grant?t="); |
| 276 | 323 | |
| 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 | ||
| 277 | 361 | const stopped = await post( |
| 278 | 362 | "/api/v1/repos/alice/hello/app/production/stop", |
| 279 | 363 | {}, |
| 280 | 364 | rw, |
| 281 | 365 | ); |
| 282 | 366 | 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(); | |
| 283 | 372 | const redeployed = await post( |
| 284 | 373 | "/api/v1/repos/alice/hello/app/production/redeploy", |
| 285 | 374 | {}, |
test/studio.test.ts +196 −4
| @@ -4164,7 +4164,10 @@ describe("the app's page tails the deploy that is bringing it alive", () => { | ||
| 4164 | 4164 | finishRun(app.db, agentRun.id, { state: "ok", summary: "planned" }); |
| 4165 | 4165 | }); |
| 4166 | 4166 | |
| 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 () => { | |
| 4168 | 4171 | putEnv("production", { |
| 4169 | 4172 | live: { |
| 4170 | 4173 | commitOid: "d0d0d0d0", |
| @@ -4175,8 +4178,194 @@ describe("the app's page tails the deploy that is bringing it alive", () => { | ||
| 4175 | 4178 | }, |
| 4176 | 4179 | }); |
| 4177 | 4180 | 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(); | |
| 4178 | 4342 | 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" }); | |
| 4180 | 4369 | }); |
| 4181 | 4370 | }); |
| 4182 | 4371 | |
| @@ -4307,7 +4496,7 @@ describe(`"Now" while production is coming up and nothing else is`, () => { | ||
| 4307 | 4496 | finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }); |
| 4308 | 4497 | }); |
| 4309 | 4498 | |
| 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 () => { | |
| 4311 | 4500 | putEnv("production", { |
| 4312 | 4501 | live: { |
| 4313 | 4502 | commitOid: "eae580a", |
| @@ -4320,7 +4509,10 @@ describe(`"Now" while production is coming up and nothing else is`, () => { | ||
| 4320 | 4509 | |
| 4321 | 4510 | const page = await (await get("/alice/ember", owner)).text(); |
| 4322 | 4511 | 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"); | |
| 4324 | 4516 | }); |
| 4325 | 4517 | }); |
| 4326 | 4518 |
new above
offline