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

Merged opened this

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

Merged

scout/forge:scout/145-the-app-s-page-tails-the-deploy-that-is → main 1 commit fork

  • Merged. scout/145-the-app-s-page-tails-the-deploy-that-is was merged into main as 0d1c8e4.

Review

Sign in to review this change.
4 files changed +260 −16
src/web/client.ts +15 −12
@@ -621,18 +621,21 @@ export const studioRunJs = /* js */ `
621621 `;
622622
623623 /**
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.
636639 */
637640 export const appTailJs = /* js */ `
638641 (function () {
src/web/studio/app.ts +11 −2
@@ -173,7 +173,16 @@ export async function studioApp(
173173 : autonomyStatus(ctx.app, repo);
174174
175175 // ---- 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 );
177186
178187 // ---- the box ----
179188 // Telling the crew, and who takes it, is the owner's own front door — a
@@ -495,6 +504,6 @@ export async function studioApp(
495504 // The no-script half of the tail: while the crew's run goes, the page
496505 // refreshes itself, the way the run's own page does; the script above
497506 // removes this the moment it can stream lines instead.
498 ...(activeRun ? { refresh: { seconds: 5 } } : {}),
507 ...(activeRun || deployTailing ? { refresh: { seconds: 5 } } : {}),
499508 });
500509 }
src/web/studio/previews.ts +39 −2
@@ -18,7 +18,9 @@ import {
1818 import { getAppByRepo, type AppRecord } from "../../core/apps.ts";
1919 import { listPulls, type Pull } from "../../core/pulls.ts";
2020 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
21 import { listRuns } from "../../core/runs.ts";
2122 import type { Ctx } from "../../http/context.ts";
23 import { readRunLog } from "../../runs/log.ts";
2224 import { loadRepo, repoBase } from "../chrome.ts";
2325 import { html, when, type Html } from "../html.ts";
2426 import { fragment } from "../layout.ts";
@@ -35,6 +37,7 @@ import {
3537 time,
3638 } from "../ui.ts";
3739 import { lookNow } from "../pages/looks.ts";
40 import { LOG_TAIL_CAP, logBox } from "./log.ts";
3841
3942 /** The three words the studio uses for a preview, whatever its check says. */
4043 function previewBadge(state: EnvState | undefined): Html {
@@ -115,6 +118,38 @@ function previewsPanel(
115118 );
116119 }
117120
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
118153 /**
119154 * The head card and the previews list: what production is doing, and every
120155 * open pull's own environment. One function, called for the page's first
@@ -126,7 +161,7 @@ export function appPanels(
126161 repo: Repo,
127162 record: AppRecord,
128163 openPulls: readonly Pull[],
129 ): { head: Html; previews: Html | null } {
164 ): { head: Html; previews: Html | null; deployTailing: boolean } {
130165 const db = ctx.app.db;
131166 const base = repoBase(repo);
132167 const runtime = appsRuntime(ctx.app);
@@ -135,6 +170,7 @@ export function appPanels(
135170 const production = envs.find((e) => e.env === "production");
136171 const url = runtime?.urlFor(record, "production");
137172 const writer = canSeeOwnerFacts(db, repo, ctx.auth.user);
173 const tail = deployTail(ctx, repo, production);
138174 // The state in one word, and under it the same facts the App page shows —
139175 // why it is not up, and the deploy whose log says the rest.
140176 const live = envPhrase(production);
@@ -165,6 +201,7 @@ export function appPanels(
165201 ctx.app.config.sshHostKey,
166202 ),
167203 )}
204 ${tail ?? null}
168205 </div>
169206 <span class="spacer"></span>
170207 ${lookNow(ctx, repo, record)}
@@ -175,7 +212,7 @@ export function appPanels(
175212 })}
176213 ${when(production, (env) => envActions(base, env, writer))}
177214 </div>`;
178 return { head, previews };
215 return { head, previews, deployTailing: tail !== undefined };
179216 }
180217
181218 /**
test/studio.test.ts +195
@@ -3131,6 +3131,201 @@ describe("the app's page tails the crew's log while it works", () => {
31313131 });
31323132 });
31333133
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
31343329 /**
31353330 * Issue #65: a public app's studio page is the app, not the owner's books.
31363331 * `canSeeOwnerFacts` (`src/core/repos.ts`) is the one predicate — the same

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs