Public: anyone can read this; the crew can change it.
The app's page tails the deploy that is bringing it alive, not only the crew's run #150
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/145-the-app-s-page-tails-the-deploy-that-is → main 1 commit fork
4 files
changed +260
−16
src/web/client.ts +15 −12
| @@ -621,18 +621,21 @@ export const studioRunJs = /* js */ ` | ||
| 621 | 621 | `; |
| 622 | 622 | |
| 623 | 623 | /** |
| 624 | * The tail beside a running row on the app's own page in the studio | |
| 625 | * (\`studio/app.ts\`, \`#app-run-tail\`): the same lines a run's own page | |
| 626 | * streams, a handful at a time, so a person on the app's page sees the crew | |
| 627 | * at work without opening the run. \`data-cap\` bounds what stays in the DOM | |
| 628 | * as new lines arrive — a chatty agent left open cannot grow the page | |
| 629 | * without bound. There is exactly one of these on a page at once, so it | |
| 630 | * opens one more stream beside the app's own state stream and the page's | |
| 631 | * live regions, never one per row. It carries no controls of its own: Stop | |
| 632 | * lives on the row, the full transcript is one click away, and the row's own | |
| 633 | * word swaps in once the run is over (the page's existing live region for | |
| 634 | * "Now" already repaints on \`run.*\`) — this only ever appends lines and | |
| 635 | * stops. | |
| 624 | * The tail beside a running row, or in the head card, on the app's own page | |
| 625 | * in the studio (\`studio/app.ts\`, \`studio/previews.ts\`, \`#app-run-tail\`): | |
| 626 | * the same lines a run's own page streams, a handful at a time, so a person | |
| 627 | * on the app's page sees the crew at work — or the app itself coming up, | |
| 628 | * before any agent has touched it — without opening the run. \`data-cap\` | |
| 629 | * bounds what stays in the DOM as new lines arrive — a chatty run left open | |
| 630 | * cannot grow the page without bound. There is exactly one of these on a | |
| 631 | * page at once (an agent run's own work outranks a background deploy, so | |
| 632 | * only one of the two ever renders the id), so it opens one more stream | |
| 633 | * beside the app's own state stream and the page's live regions, never one | |
| 634 | * per row. It carries no controls of its own: Stop lives on the row, the | |
| 635 | * full transcript is one click away, and the row's own word — or the head | |
| 636 | * card's own state — swaps in once the run is over (the page's existing | |
| 637 | * live regions already repaint on \`run.*\`) — this only ever appends lines | |
| 638 | * and stops. | |
| 636 | 639 | */ |
| 637 | 640 | export const appTailJs = /* js */ ` |
| 638 | 641 | (function () { |
src/web/studio/app.ts +11 −2
| @@ -173,7 +173,16 @@ export async function studioApp( | ||
| 173 | 173 | : autonomyStatus(ctx.app, repo); |
| 174 | 174 | |
| 175 | 175 | // ---- the app itself ---- |
| 176 | const { head, previews } = appPanels(ctx, repo, record, openPulls); | |
| 176 | // `deployTailing` says the head card already carries a live tail of | |
| 177 | // production's own deploy (`studio/previews.ts`'s `deployTail`) — the | |
| 178 | // no-script refresh below must keep the page moving for that case too, | |
| 179 | // not only for an agent run's tail beside a "Now" row. | |
| 180 | const { head, previews, deployTailing } = appPanels( | |
| 181 | ctx, | |
| 182 | repo, | |
| 183 | record, | |
| 184 | openPulls, | |
| 185 | ); | |
| 177 | 186 | |
| 178 | 187 | // ---- the box ---- |
| 179 | 188 | // Telling the crew, and who takes it, is the owner's own front door — a |
| @@ -495,6 +504,6 @@ export async function studioApp( | ||
| 495 | 504 | // The no-script half of the tail: while the crew's run goes, the page |
| 496 | 505 | // refreshes itself, the way the run's own page does; the script above |
| 497 | 506 | // removes this the moment it can stream lines instead. |
| 498 | ...(activeRun ? { refresh: { seconds: 5 } } : {}), | |
| 507 | ...(activeRun || deployTailing ? { refresh: { seconds: 5 } } : {}), | |
| 499 | 508 | }); |
| 500 | 509 | } |
src/web/studio/previews.ts +39 −2
| @@ -18,7 +18,9 @@ import { | ||
| 18 | 18 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; |
| 19 | 19 | import { listPulls, type Pull } from "../../core/pulls.ts"; |
| 20 | 20 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 21 | import { listRuns } from "../../core/runs.ts"; | |
| 21 | 22 | import type { Ctx } from "../../http/context.ts"; |
| 23 | import { readRunLog } from "../../runs/log.ts"; | |
| 22 | 24 | import { loadRepo, repoBase } from "../chrome.ts"; |
| 23 | 25 | import { html, when, type Html } from "../html.ts"; |
| 24 | 26 | import { fragment } from "../layout.ts"; |
| @@ -35,6 +37,7 @@ import { | ||
| 35 | 37 | time, |
| 36 | 38 | } from "../ui.ts"; |
| 37 | 39 | import { lookNow } from "../pages/looks.ts"; |
| 40 | import { LOG_TAIL_CAP, logBox } from "./log.ts"; | |
| 38 | 41 | |
| 39 | 42 | /** The three words the studio uses for a preview, whatever its check says. */ |
| 40 | 43 | function previewBadge(state: EnvState | undefined): Html { |
| @@ -115,6 +118,38 @@ function previewsPanel( | ||
| 115 | 118 | ); |
| 116 | 119 | } |
| 117 | 120 | |
| 121 | /** | |
| 122 | * Production coming up, tailed: while its deploy is running and no agent | |
| 123 | * run has the crew's own tail already (`studio/app.ts`'s "Now" panel), the | |
| 124 | * head card gets the same peek at the log a running agent run gets beside | |
| 125 | * its row — the app's own first thirty seconds are not a blank page. The | |
| 126 | * agent's run wins the one stream this page opens when both are going: it | |
| 127 | * is the crew's own answer to something asked, where a deploy is often | |
| 128 | * background (a redeploy after an unrelated push), and it is already what | |
| 129 | * "Now" shows. | |
| 130 | */ | |
| 131 | function deployTail( | |
| 132 | ctx: Ctx, | |
| 133 | repo: Repo, | |
| 134 | production: EnvStatus | undefined, | |
| 135 | ): Html | undefined { | |
| 136 | if (production?.state !== "deploying" || production.runId === undefined) | |
| 137 | return undefined; | |
| 138 | const agentBusy = listRuns(ctx.app.db, repo.id, { | |
| 139 | kind: "agent", | |
| 140 | limit: 8, | |
| 141 | }).some((r) => r.state === "running"); | |
| 142 | if (agentBusy) return undefined; | |
| 143 | return logBox({ | |
| 144 | id: "app-run-tail", | |
| 145 | who: "the deploy", | |
| 146 | cap: LOG_TAIL_CAP, | |
| 147 | lines: readRunLog(ctx.app.config.dataDir, production.runId).lines, | |
| 148 | stream: `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${production.runId}/log?follow=1`, | |
| 149 | className: "log-tail", | |
| 150 | }); | |
| 151 | } | |
| 152 | ||
| 118 | 153 | /** |
| 119 | 154 | * The head card and the previews list: what production is doing, and every |
| 120 | 155 | * open pull's own environment. One function, called for the page's first |
| @@ -126,7 +161,7 @@ export function appPanels( | ||
| 126 | 161 | repo: Repo, |
| 127 | 162 | record: AppRecord, |
| 128 | 163 | openPulls: readonly Pull[], |
| 129 | ): { head: Html; previews: Html | null } { | |
| 164 | ): { head: Html; previews: Html | null; deployTailing: boolean } { | |
| 130 | 165 | const db = ctx.app.db; |
| 131 | 166 | const base = repoBase(repo); |
| 132 | 167 | const runtime = appsRuntime(ctx.app); |
| @@ -135,6 +170,7 @@ export function appPanels( | ||
| 135 | 170 | const production = envs.find((e) => e.env === "production"); |
| 136 | 171 | const url = runtime?.urlFor(record, "production"); |
| 137 | 172 | const writer = canSeeOwnerFacts(db, repo, ctx.auth.user); |
| 173 | const tail = deployTail(ctx, repo, production); | |
| 138 | 174 | // The state in one word, and under it the same facts the App page shows — |
| 139 | 175 | // why it is not up, and the deploy whose log says the rest. |
| 140 | 176 | const live = envPhrase(production); |
| @@ -165,6 +201,7 @@ export function appPanels( | ||
| 165 | 201 | ctx.app.config.sshHostKey, |
| 166 | 202 | ), |
| 167 | 203 | )} |
| 204 | ${tail ?? null} | |
| 168 | 205 | </div> |
| 169 | 206 | <span class="spacer"></span> |
| 170 | 207 | ${lookNow(ctx, repo, record)} |
| @@ -175,7 +212,7 @@ export function appPanels( | ||
| 175 | 212 | })} |
| 176 | 213 | ${when(production, (env) => envActions(base, env, writer))} |
| 177 | 214 | </div>`; |
| 178 | return { head, previews }; | |
| 215 | return { head, previews, deployTailing: tail !== undefined }; | |
| 179 | 216 | } |
| 180 | 217 | |
| 181 | 218 | /** |
test/studio.test.ts +195
| @@ -3131,6 +3131,201 @@ describe("the app's page tails the crew's log while it works", () => { | ||
| 3131 | 3131 | }); |
| 3132 | 3132 | }); |
| 3133 | 3133 | |
| 3134 | /** | |
| 3135 | * Issue #145: the run a brand-new app is waiting on is its first deploy, not | |
| 3136 | * an agent's — the head card tails production's own deploy the same way a | |
| 3137 | * running row is tailed, and yields to an agent run's own tail when one is | |
| 3138 | * also going, so the page never opens two streams for one repo. | |
| 3139 | */ | |
| 3140 | describe("the app's page tails the deploy that is bringing it alive", () => { | |
| 3141 | let dataDir: string; | |
| 3142 | let app: App; | |
| 3143 | let servers: Servers; | |
| 3144 | let base: string; | |
| 3145 | let runtime: AppsRuntime; | |
| 3146 | let repo: Repo; | |
| 3147 | let record: AppRecord; | |
| 3148 | let owner: string; | |
| 3149 | let scoutId: string; | |
| 3150 | ||
| 3151 | interface PreviewSlot { | |
| 3152 | readonly app: AppRecord; | |
| 3153 | readonly env: EnvName; | |
| 3154 | readonly chain: Promise<void>; | |
| 3155 | readonly restarts: number[]; | |
| 3156 | deploying?: unknown; | |
| 3157 | live?: unknown; | |
| 3158 | lastError?: string; | |
| 3159 | lastRunId?: string; | |
| 3160 | } | |
| 3161 | const putEnv = ( | |
| 3162 | env: EnvName, | |
| 3163 | slot: Omit<PreviewSlot, "app" | "env" | "chain" | "restarts">, | |
| 3164 | ): void => { | |
| 3165 | (runtime as unknown as { envs: Map<string, PreviewSlot> }).envs.set( | |
| 3166 | `${record.id}/${env}`, | |
| 3167 | { app: record, env, chain: Promise.resolve(), restarts: [], ...slot }, | |
| 3168 | ); | |
| 3169 | }; | |
| 3170 | ||
| 3171 | const signIn = async (username: string): Promise<string> => | |
| 3172 | ( | |
| 3173 | ( | |
| 3174 | await fetch(`${base}/signin`, { | |
| 3175 | method: "POST", | |
| 3176 | body: new URLSearchParams({ | |
| 3177 | username, | |
| 3178 | password: "a-good-passphrase", | |
| 3179 | }), | |
| 3180 | redirect: "manual", | |
| 3181 | }) | |
| 3182 | ).headers.get("set-cookie") ?? "" | |
| 3183 | ).split(";")[0]!; | |
| 3184 | ||
| 3185 | const get = (path: string, cookie: string) => | |
| 3186 | fetch(`${base}${path}`, { | |
| 3187 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 3188 | redirect: "manual", | |
| 3189 | }); | |
| 3190 | ||
| 3191 | beforeAll(async () => { | |
| 3192 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-deploy-tail-")); | |
| 3193 | app = createApp({ dataDir, port: 0 }); | |
| 3194 | servers = await serve(app); | |
| 3195 | base = servers.url; | |
| 3196 | runtime = new AppsRuntime(app, { | |
| 3197 | domain: "deploytail.test", | |
| 3198 | scheme: "http", | |
| 3199 | publicPort: servers.port, | |
| 3200 | forgeUrl: servers.url, | |
| 3201 | concurrency: 1, | |
| 3202 | }); | |
| 3203 | const a = await createUser(app.db, { | |
| 3204 | username: "alice", | |
| 3205 | email: "[email protected]", | |
| 3206 | password: "a-good-passphrase", | |
| 3207 | adminIfFirst: true, | |
| 3208 | }); | |
| 3209 | if (!a.ok) throw new Error(a.error); | |
| 3210 | owner = await signIn("alice"); | |
| 3211 | const made = await createRepo(app, a.value, { | |
| 3212 | name: "kindling", | |
| 3213 | visibility: "private", | |
| 3214 | description: "a new app", | |
| 3215 | }); | |
| 3216 | if (!made.ok) throw new Error(String(made.error)); | |
| 3217 | repo = made.value; | |
| 3218 | const seeded = await commitToBranch(app, a.value, repo, { | |
| 3219 | message: "Start", | |
| 3220 | files: [{ path: "README.md", content: "kindling\n" }], | |
| 3221 | }); | |
| 3222 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 3223 | const enabled = await enableApp(app, a.value, repo, { slug: "kindling" }); | |
| 3224 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 3225 | record = getAppByRepo(app.db, repo.id)!; | |
| 3226 | const scout = await createAgent(app, a.value, { username: "scout" }); | |
| 3227 | if (!scout.ok) throw new Error(String(scout.error)); | |
| 3228 | scoutId = scout.value.user.id; | |
| 3229 | expect(addToCrew(app, a.value, repo, "scout").ok).toBe(true); | |
| 3230 | }); | |
| 3231 | ||
| 3232 | afterAll(async () => { | |
| 3233 | servers.stop(); | |
| 3234 | app.close(); | |
| 3235 | await rm(dataDir, { recursive: true, force: true }); | |
| 3236 | }); | |
| 3237 | ||
| 3238 | test("a production deploy under way, with no agent run going, is tailed in the head card and named as the deploy's own stream", async () => { | |
| 3239 | const deployRun = createRun(app.db, { | |
| 3240 | repoId: repo.id, | |
| 3241 | kind: "deploy", | |
| 3242 | env: "production", | |
| 3243 | commitOid: "f00dcafe", | |
| 3244 | reason: "boot", | |
| 3245 | }); | |
| 3246 | claimRun(app.db, deployRun.id); | |
| 3247 | appendRunLog(app.config.dataDir, deployRun.id, "checking out f00dcafe"); | |
| 3248 | appendRunLog(app.config.dataDir, deployRun.id, "install …"); | |
| 3249 | putEnv("production", { | |
| 3250 | deploying: { | |
| 3251 | run: { id: deployRun.id, commitOid: "f00dcafe" }, | |
| 3252 | abort: new AbortController(), | |
| 3253 | }, | |
| 3254 | }); | |
| 3255 | ||
| 3256 | const page = await (await get("/alice/kindling", owner)).text(); | |
| 3257 | expect(page).toContain('id="app-run-tail"'); | |
| 3258 | expect(page).toContain('data-who="the deploy"'); | |
| 3259 | expect(page).toContain( | |
| 3260 | `data-stream="/api/v1/repos/alice/kindling/runs/${deployRun.id}/log?follow=1"`, | |
| 3261 | ); | |
| 3262 | expect(page).toContain("install …"); | |
| 3263 | // No script, no problem: the page keeps refreshing while the deploy | |
| 3264 | // goes — the actual meta tag, not just the script's own selector text. | |
| 3265 | expect(page).toMatch(/<meta\s+http-equiv="refresh"/); | |
| 3266 | ||
| 3267 | finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }); | |
| 3268 | }); | |
| 3269 | ||
| 3270 | test("an agent run at work outranks a background deploy: the page follows the crew, never both", async () => { | |
| 3271 | const deployRun = createRun(app.db, { | |
| 3272 | repoId: repo.id, | |
| 3273 | kind: "deploy", | |
| 3274 | env: "production", | |
| 3275 | commitOid: "d0d0d0d0", | |
| 3276 | reason: "push", | |
| 3277 | }); | |
| 3278 | claimRun(app.db, deployRun.id); | |
| 3279 | appendRunLog(app.config.dataDir, deployRun.id, "deploying d0d0d0d0"); | |
| 3280 | putEnv("production", { | |
| 3281 | deploying: { | |
| 3282 | run: { id: deployRun.id, commitOid: "d0d0d0d0" }, | |
| 3283 | abort: new AbortController(), | |
| 3284 | }, | |
| 3285 | }); | |
| 3286 | ||
| 3287 | const tell = openIssue(app, getUserByName(app.db, "alice")!, repo, { | |
| 3288 | title: "Add a footer", | |
| 3289 | }); | |
| 3290 | if (!tell.ok) throw new Error(String(tell.error)); | |
| 3291 | const agentRun = createRun(app.db, { | |
| 3292 | repoId: repo.id, | |
| 3293 | kind: "agent", | |
| 3294 | issueId: tell.value.id, | |
| 3295 | actorId: scoutId, | |
| 3296 | reason: "assigned", | |
| 3297 | }); | |
| 3298 | claimRun(app.db, agentRun.id); | |
| 3299 | appendRunLog(app.config.dataDir, agentRun.id, "scout is on it"); | |
| 3300 | ||
| 3301 | const page = await (await get("/alice/kindling", owner)).text(); | |
| 3302 | // Exactly one tail, and it is the crew's own run — never the deploy's. | |
| 3303 | expect(page.match(/id="app-run-tail"/g)?.length).toBe(1); | |
| 3304 | expect(page).not.toContain('data-who="the deploy"'); | |
| 3305 | expect(page).toContain( | |
| 3306 | `data-stream="/api/v1/repos/alice/kindling/runs/${agentRun.id}/log?follow=1"`, | |
| 3307 | ); | |
| 3308 | ||
| 3309 | finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }); | |
| 3310 | finishRun(app.db, agentRun.id, { state: "ok", summary: "planned" }); | |
| 3311 | }); | |
| 3312 | ||
| 3313 | test("with neither a deploy nor an agent run going, the page carries no tail at all", async () => { | |
| 3314 | putEnv("production", { | |
| 3315 | live: { | |
| 3316 | commitOid: "d0d0d0d0", | |
| 3317 | handle: { port: 4501 }, | |
| 3318 | runId: "irrelevant", | |
| 3319 | since: new Date().toISOString(), | |
| 3320 | manifest: { tty: null }, | |
| 3321 | }, | |
| 3322 | }); | |
| 3323 | const page = await (await get("/alice/kindling", owner)).text(); | |
| 3324 | expect(page).not.toContain('id="app-run-tail"'); | |
| 3325 | expect(page).not.toMatch(/<meta\s+http-equiv="refresh"/); | |
| 3326 | }); | |
| 3327 | }); | |
| 3328 | ||
| 3134 | 3329 | /** |
| 3135 | 3330 | * Issue #65: a public app's studio page is the app, not the owner's books. |
| 3136 | 3331 | * `canSeeOwnerFacts` (`src/core/repos.ts`) is the one predicate — the same |
new above
offline