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

A deploy records the moment it started serving, and the studio says how long that took #30

Merged opened this

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

Merged

scout/forge:scout/26-a-deploy-records-the-moment-it-started-s → main 2 commits fork

  • Merged. scout/26-a-deploy-records-the-moment-it-started-s was merged into main as 6e68453.

Review

Sign in to review this change.
8 files changed +155 −15
docs/APPS.md +8 −3
@@ -75,7 +75,11 @@ around it. None is advisory.
7575 `running` — checking, not blocking — until `test` finishes and decides
7676 `ok` or `failed`. A deployment that answers is what was asked for; the
7777 suite going green is confirmed a few seconds later, not stood in front
78 of the door.
78 of the door. That instant is recorded, not just logged: the run's
79 `servingAt` is stamped once, at the swap-in, distinct from `startedAt`
80 (the deploy's own start) and `finishedAt` (which waits on `test`
81 beside it) — so how long a change took to reach its preview is a
82 number the forge can read back, not a line buried in the log.
7983 5. **A private app answers only to someone the forge vouches for.** An app's
8084 `access` is `public` or `private` (defaulting to the repository's
8185 visibility), and it is set apart from the repository's own: the code can
@@ -373,8 +377,9 @@ either derived or ephemeral:
373377 ```
374378
375379 Rows: `app` (repo, slug, access, preview_data), `app_env` (name, sealed
376 value), `run` (repo, kind, issue, commit, state, summary, cost, timings),
377 `agent` (user, model, brief, budget), and `issue.assignee_id`. The current deployment of each environment is in
380 value), `run` (repo, kind, issue, commit, state, summary, cost, timings —
381 `startedAt`, `servingAt`, `finishedAt`), `agent` (user, model, brief,
382 budget), and `issue.assignee_id`. The current deployment of each environment is in
378383 memory only: a process is not state, and a restart rebuilds every
379384 environment from the default branch and the open pulls, exactly as
380385 `core/reconcile.ts` rebuilds the index from git.
src/apps/runtime.ts +48 −9
@@ -31,6 +31,7 @@ import {
3131 CHECK_KINDS,
3232 failAbandonedRuns,
3333 finishRun,
34 markServing,
3435 type Run,
3536 } from "../core/runs.ts";
3637 import { Gate } from "../git/env.ts";
@@ -84,6 +85,10 @@ export interface EnvStatus {
8485 /** The run that produced this state — a link to its log. */
8586 readonly runId?: string | undefined;
8687 readonly since?: string | undefined;
88 /** The instant this deployment started serving, set once at the swap-in. */
89 readonly servingAt?: string | undefined;
90 /** The deploy run's own start — the reference `servingAt` is measured from. */
91 readonly deployStartedAt?: string | undefined;
8792 readonly error?: string | undefined;
8893 /**
8994 * A running environment whose deploy is still checking itself: its tests
@@ -104,6 +109,19 @@ interface Live {
104109 readonly manifest: AppManifest;
105110 /** Unset while tests are still running or there are none; the message once they fail. */
106111 readonly testsFailed?: string | undefined;
112 /**
113 * The instant this deployment started serving, set once at the swap-in —
114 * unset on the value `bringUp` returns, which does not yet know it.
115 */
116 readonly servingAt?: string | undefined;
117 /** The deploy run's own start — the reference `servingAt` is measured from. */
118 readonly deployStartedAt?: string | null | undefined;
119 }
120
121 /** How long between two instants, in seconds to one decimal place: `3.4s`. */
122 function elapsedSeconds(fromIso: string, toIso: string): string {
123 const ms = Math.max(0, Date.parse(toIso) - Date.parse(fromIso));
124 return `${(ms / 1000).toFixed(1)}s`;
107125 }
108126
109127 /** A running environment, as a terminal session needs it. */
@@ -223,9 +241,17 @@ export class AppsRuntime {
223241 state: "deploying",
224242 commitOid: state.deploying.run.commitOid,
225243 url,
226 ...(state.live === undefined ? {} : { port: state.live.handle.port }),
244 ...(state.live === undefined
245 ? {}
246 : {
247 port: state.live.handle.port,
248 since: state.live.since,
249 servingAt: state.live.servingAt,
250 ...(state.live.deployStartedAt === null
251 ? {}
252 : { deployStartedAt: state.live.deployStartedAt }),
253 }),
227254 runId: state.deploying.run.id,
228 ...(state.live === undefined ? {} : { since: state.live.since }),
229255 };
230256 }
231257 if (state.live !== undefined) {
@@ -237,10 +263,14 @@ export class AppsRuntime {
237263 port: state.live.handle.port,
238264 runId: swapped ? state.deploying!.run.id : state.live.runId,
239265 since: state.live.since,
266 servingAt: state.live.servingAt,
267 ...(state.live.deployStartedAt === null
268 ? {}
269 : { deployStartedAt: state.live.deployStartedAt }),
240270 ...(swapped ? { note: "serving — still verifying its tests" } : {}),
241271 ...(state.live.testsFailed === undefined
242272 ? {}
243 : { error: `serving — its tests failed: ${state.live.testsFailed}` }),
273 : { error: `serving · tests failed: ${state.live.testsFailed}` }),
244274 };
245275 }
246276 return {
@@ -601,20 +631,30 @@ export class AppsRuntime {
601631 // It answers: swap it in now, before its tests are done. `state.deploying`
602632 // stays set — the run is still open, and a stop or a teardown must still
603633 // be able to reach in and kill the test step — until they finish below.
634 // `servingAt` is stamped here, at the swap-in, the same instant the log
635 // says so — never the moment the tests beside it finish.
604636 const previous = state.live;
605 state.live = outcome.value;
637 const served = markServing(this.app.db, run.id);
638 const servingAt = served?.servingAt ?? new Date().toISOString();
639 const deployStartedAt = claimed.startedAt;
640 const live: Live = { ...outcome.value, servingAt, deployStartedAt };
641 state.live = live;
606642 state.lastError = undefined;
607643 if (previous !== undefined) {
608644 write(
609645 `stopping the previous deployment (${previous.commitOid.slice(0, 7)})`,
610646 );
611647 await previous.handle.stop();
612 if (previous.dir !== outcome.value.dir)
648 if (previous.dir !== live.dir)
613649 rmSync(previous.dir, { recursive: true, force: true });
614650 }
615 this.watch(state, repo, outcome.value);
651 this.watch(state, repo, live);
616652 const url = this.urlFor(state.app, state.env);
617 write(`serving ${url}`);
653 write(
654 deployStartedAt
655 ? `serving ${url} — ${elapsedSeconds(deployStartedAt, servingAt)} after the deploy started`
656 : `serving ${url}`,
657 );
618658
619659 const tested = await outcome.tested;
620660 state.deploying = undefined;
@@ -630,8 +670,7 @@ export class AppsRuntime {
630670 }
631671 // The tests failed, but the deployment that answered is what was asked
632672 // for: it keeps serving, and the run — the check a pull shows — fails.
633 if (state.live === outcome.value)
634 state.live = { ...outcome.value, testsFailed: tested.error };
673 if (state.live === live) state.live = { ...live, testsFailed: tested.error };
635674 const done = finishRun(this.app.db, run.id, {
636675 state: "failed",
637676 summary: `test: ${tested.error}`,
src/core/runs.ts +19
@@ -52,6 +52,8 @@ export interface Run {
5252 readonly turns: number;
5353 readonly createdAt: string;
5454 readonly startedAt: string | null;
55 /** When this deploy's environment started serving — set once, at the swap-in. */
56 readonly servingAt: string | null;
5557 readonly finishedAt: string | null;
5658 }
5759
@@ -75,6 +77,7 @@ interface RunRow {
7577 turns: number;
7678 created_at: string;
7779 started_at: string | null;
80 serving_at: string | null;
7881 finished_at: string | null;
7982 }
8083
@@ -98,6 +101,7 @@ const toRun = (r: RunRow): Run => ({
98101 turns: r.turns,
99102 createdAt: r.created_at,
100103 startedAt: r.started_at,
104 servingAt: r.serving_at,
101105 finishedAt: r.finished_at,
102106 });
103107
@@ -163,6 +167,21 @@ export function claimRun(db: Database, runId: string): Run | undefined {
163167 return changes > 0 ? getRun(db, runId) : undefined;
164168 }
165169
170 /**
171 * Record the instant a deploy's environment started serving — the swap-in,
172 * not the tests finishing beside it. Set once: a second call, on a run
173 * already stamped, changes nothing.
174 */
175 export function markServing(db: Database, runId: string): Run | undefined {
176 const changes = db
177 .query(
178 `UPDATE run SET serving_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
179 WHERE id = ?1 AND state = 'running' AND serving_at IS NULL`,
180 )
181 .run(runId).changes;
182 return changes > 0 ? getRun(db, runId) : undefined;
183 }
184
166185 /**
167186 * Finish a run. Only a running run finishes; a cancelled one stays
168187 * cancelled however late its worker reports in. Returns the final row, or
src/core/schema.ts +6
@@ -730,4 +730,10 @@ export const migrations: readonly string[] = [
730730 CREATE INDEX decision_issue ON decision(issue_id, kind, created_at);
731731 CREATE INDEX decision_repo ON decision(repo_id, created_at);
732732 `,
733
734 // 23 — the moment a deploy started serving: set once, at the swap-in,
735 // distinct from `finished_at` which waits on the tests beside it.
736 `
737 ALTER TABLE run ADD COLUMN serving_at TEXT;
738 `,
733739 ];
src/web/pages/apps.ts +2
@@ -65,6 +65,7 @@ import {
6565 listCard,
6666 row,
6767 selectField,
68 servedPhrase,
6869 sha,
6970 time,
7071 userLink,
@@ -477,6 +478,7 @@ const envFacts = (base: string, env: EnvStatus): Html =>
477478 ${when(env.commitOid !== "", () =>
478479 sha(env.commitOid, `${base}/commit/${env.commitOid}`),
479480 )}
481 ${when(servedPhrase(env), (phrase) => html`<span>live ${phrase}</span>`)}
480482 ${when(env.since, (since) => html`<span>up ${time(since)}</span>`)}
481483 ${when(
482484 env.runId,
src/web/pages/studio.ts +7 −1
@@ -79,6 +79,7 @@ import {
7979 listCard,
8080 pill,
8181 row,
82 servedPhrase,
8283 time,
8384 userTag,
8485 type Tone,
@@ -497,9 +498,14 @@ export async function studioApp(
497498 const production = envs.find((e) => e.env === "production");
498499 const url = runtime?.urlFor(record, "production");
499500 const state = production?.state ?? "stopped";
501 const servedAfter = production ? servedPhrase(production) : undefined;
500502 const liveWord =
501503 state === "running"
502 ? "live"
504 ? production?.error !== undefined
505 ? "serving · tests failed"
506 : servedAfter !== undefined
507 ? `live ${servedAfter}`
508 : "live"
503509 : state === "failed"
504510 ? "not up"
505511 : state === "stopped"
src/web/ui.ts +15 −1
@@ -10,7 +10,7 @@
1010 * reaches for an inline `style` attribute, because the CSP forbids it.
1111 */
1212
13 import type { EnvState } from "../apps/runtime.ts";
13 import type { EnvState, EnvStatus } from "../apps/runtime.ts";
1414 import type { RunKind, RunState } from "../core/runs.ts";
1515 import { icon, type IconName } from "./icons.ts";
1616 import { Html, html, raw, when } from "./html.ts";
@@ -51,6 +51,20 @@ export function time(iso: string): Html {
5151 >`;
5252 }
5353
54 /**
55 * How long a deployment took to start serving, worded for a person:
56 * `3.4s after the push`. Undefined until it has — never a zero.
57 */
58 export function servedPhrase(env: EnvStatus): string | undefined {
59 if (env.servingAt === undefined || env.deployStartedAt === undefined)
60 return undefined;
61 const ms = Math.max(
62 0,
63 Date.parse(env.servingAt) - Date.parse(env.deployStartedAt),
64 );
65 return `${(ms / 1000).toFixed(1)}s after the push`;
66 }
67
5468 /** Bytes, three significant figures, never more precision than is true. */
5569 export function fileSize(bytes: number): string {
5670 if (bytes < 1024) return `${bytes} B`;
test/apps.test.ts +50 −1
@@ -17,7 +17,11 @@ import { AppsRuntime } from "../src/apps/runtime.ts";
1717 import { envDir } from "../src/apps/data.ts";
1818 import { newApp, openEnvUrl, updateAppSettings } from "../src/actions/apps.ts";
1919 import { addToCrew, createAgent, listCrew } from "../src/actions/crew.ts";
20 import { assignIssue, openIssue } from "../src/actions/issues.ts";
20 import {
21 assignIssue,
22 openIssue,
23 setIssueState,
24 } from "../src/actions/issues.ts";
2125 import { mergePull, openPull } from "../src/actions/pulls.ts";
2226 import { forkRepo } from "../src/actions/repos.ts";
2327 import {
@@ -543,6 +547,9 @@ describe("apps: production, previews, data, gates, crew", () => {
543547 env: `pull-${opened.value.number}`,
544548 }).find((r) => r.commitOid === committed.value.commitOid);
545549 expect(run?.state).toBe("running");
550 // The deploy answered and swapped in well before its tests could have
551 // finished, so `servingAt` is already recorded on the still-open run.
552 expect(run?.servingAt).not.toBeNull();
546553
547554 const done = await settled(
548555 (r) =>
@@ -550,6 +557,11 @@ describe("apps: production, previews, data, gates, crew", () => {
550557 r.commitOid === committed.value.commitOid,
551558 );
552559 expect(done.state).toBe("ok");
560 expect(done.servingAt).not.toBeNull();
561 expect(Date.parse(done.servingAt!)).toBeLessThan(
562 Date.parse(done.finishedAt!),
563 );
564
553565 // Close it out: a later test counts open previews and expects only the
554566 // one it opens itself.
555567 const merged = await mergePull(
@@ -645,6 +657,43 @@ describe("apps: production, previews, data, gates, crew", () => {
645657 expect(signout.headers.get("set-cookie") ?? "").toContain("Max-Age=0");
646658 }, 90_000);
647659
660 test("a deploy that never answers leaves servingAt null", async () => {
661 const committed = await commitToBranch(app, alice, repo, {
662 branch: "never-answers",
663 base: repo.defaultBranch,
664 message: "Never answers",
665 files: [
666 {
667 path: "forge.json",
668 content: `${JSON.stringify(
669 { app: { run: "sleep 30", health: "/", ready: 1 } },
670 null,
671 2,
672 )}\n`,
673 },
674 ],
675 });
676 expect(committed.ok).toBe(true);
677 if (!committed.ok) return;
678 const opened = await openPull(app, alice, repo, {
679 title: "Never answers",
680 baseBranch: repo.defaultBranch,
681 headBranch: "never-answers",
682 });
683 expect(opened.ok).toBe(true);
684 if (!opened.ok) return;
685
686 const failed = await settled(latest(`pull-${opened.value.number}`));
687 expect(failed.state).toBe("failed");
688 expect(failed.servingAt).toBeNull();
689
690 // Close it — a pull that never comes up is not this test's to leave
691 // open for the next one, which counts what git still has open.
692 expect(setIssueState(app, alice, repo, opened.value, "closed").ok).toBe(
693 true,
694 );
695 }, 20_000);
696
648697 test("the crew: an agent reads and writes its repo, never the default branch, never a merge", async () => {
649698 const made = await createAgent(app, alice, {
650699 username: "bot",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs