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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/26-a-deploy-records-the-moment-it-started-s → main 2 commits fork
8 files
changed +155
−15
docs/APPS.md +8 −3
| @@ -75,7 +75,11 @@ around it. None is advisory. | ||
| 75 | 75 | `running` — checking, not blocking — until `test` finishes and decides |
| 76 | 76 | `ok` or `failed`. A deployment that answers is what was asked for; the |
| 77 | 77 | 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. | |
| 79 | 83 | 5. **A private app answers only to someone the forge vouches for.** An app's |
| 80 | 84 | `access` is `public` or `private` (defaulting to the repository's |
| 81 | 85 | visibility), and it is set apart from the repository's own: the code can |
| @@ -373,8 +377,9 @@ either derived or ephemeral: | ||
| 373 | 377 | |
| 374 | 378 | |
| 375 | 379 | 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 | |
| 378 | 383 | memory only: a process is not state, and a restart rebuilds every |
| 379 | 384 | environment from the default branch and the open pulls, exactly as |
| 380 | 385 | `core/reconcile.ts` rebuilds the index from git. |
src/apps/runtime.ts +48 −9
| @@ -31,6 +31,7 @@ import { | ||
| 31 | 31 | CHECK_KINDS, |
| 32 | 32 | failAbandonedRuns, |
| 33 | 33 | finishRun, |
| 34 | markServing, | |
| 34 | 35 | type Run, |
| 35 | 36 | } from "../core/runs.ts"; |
| 36 | 37 | import { Gate } from "../git/env.ts"; |
| @@ -84,6 +85,10 @@ export interface EnvStatus { | ||
| 84 | 85 | /** The run that produced this state — a link to its log. */ |
| 85 | 86 | readonly runId?: string | undefined; |
| 86 | 87 | 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; | |
| 87 | 92 | readonly error?: string | undefined; |
| 88 | 93 | /** |
| 89 | 94 | * A running environment whose deploy is still checking itself: its tests |
| @@ -104,6 +109,19 @@ interface Live { | ||
| 104 | 109 | readonly manifest: AppManifest; |
| 105 | 110 | /** Unset while tests are still running or there are none; the message once they fail. */ |
| 106 | 111 | 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`; | |
| 107 | 125 | } |
| 108 | 126 | |
| 109 | 127 | /** A running environment, as a terminal session needs it. */ |
| @@ -223,9 +241,17 @@ export class AppsRuntime { | ||
| 223 | 241 | state: "deploying", |
| 224 | 242 | commitOid: state.deploying.run.commitOid, |
| 225 | 243 | 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 | }), | |
| 227 | 254 | runId: state.deploying.run.id, |
| 228 | ...(state.live === undefined ? {} : { since: state.live.since }), | |
| 229 | 255 | }; |
| 230 | 256 | } |
| 231 | 257 | if (state.live !== undefined) { |
| @@ -237,10 +263,14 @@ export class AppsRuntime { | ||
| 237 | 263 | port: state.live.handle.port, |
| 238 | 264 | runId: swapped ? state.deploying!.run.id : state.live.runId, |
| 239 | 265 | since: state.live.since, |
| 266 | servingAt: state.live.servingAt, | |
| 267 | ...(state.live.deployStartedAt === null | |
| 268 | ? {} | |
| 269 | : { deployStartedAt: state.live.deployStartedAt }), | |
| 240 | 270 | ...(swapped ? { note: "serving — still verifying its tests" } : {}), |
| 241 | 271 | ...(state.live.testsFailed === undefined |
| 242 | 272 | ? {} |
| 243 | : { error: `serving — its tests failed: ${state.live.testsFailed}` }), | |
| 273 | : { error: `serving · tests failed: ${state.live.testsFailed}` }), | |
| 244 | 274 | }; |
| 245 | 275 | } |
| 246 | 276 | return { |
| @@ -601,20 +631,30 @@ export class AppsRuntime { | ||
| 601 | 631 | // It answers: swap it in now, before its tests are done. `state.deploying` |
| 602 | 632 | // stays set — the run is still open, and a stop or a teardown must still |
| 603 | 633 | // 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. | |
| 604 | 636 | 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; | |
| 606 | 642 | state.lastError = undefined; |
| 607 | 643 | if (previous !== undefined) { |
| 608 | 644 | write( |
| 609 | 645 | `stopping the previous deployment (${previous.commitOid.slice(0, 7)})`, |
| 610 | 646 | ); |
| 611 | 647 | await previous.handle.stop(); |
| 612 | if (previous.dir !== outcome.value.dir) | |
| 648 | if (previous.dir !== live.dir) | |
| 613 | 649 | rmSync(previous.dir, { recursive: true, force: true }); |
| 614 | 650 | } |
| 615 | this.watch(state, repo, outcome.value); | |
| 651 | this.watch(state, repo, live); | |
| 616 | 652 | 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 | ); | |
| 618 | 658 | |
| 619 | 659 | const tested = await outcome.tested; |
| 620 | 660 | state.deploying = undefined; |
| @@ -630,8 +670,7 @@ export class AppsRuntime { | ||
| 630 | 670 | } |
| 631 | 671 | // The tests failed, but the deployment that answered is what was asked |
| 632 | 672 | // 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 }; | |
| 635 | 674 | const done = finishRun(this.app.db, run.id, { |
| 636 | 675 | state: "failed", |
| 637 | 676 | summary: `test: ${tested.error}`, |
src/core/runs.ts +19
| @@ -52,6 +52,8 @@ export interface Run { | ||
| 52 | 52 | readonly turns: number; |
| 53 | 53 | readonly createdAt: string; |
| 54 | 54 | readonly startedAt: string | null; |
| 55 | /** When this deploy's environment started serving — set once, at the swap-in. */ | |
| 56 | readonly servingAt: string | null; | |
| 55 | 57 | readonly finishedAt: string | null; |
| 56 | 58 | } |
| 57 | 59 | |
| @@ -75,6 +77,7 @@ interface RunRow { | ||
| 75 | 77 | turns: number; |
| 76 | 78 | created_at: string; |
| 77 | 79 | started_at: string | null; |
| 80 | serving_at: string | null; | |
| 78 | 81 | finished_at: string | null; |
| 79 | 82 | } |
| 80 | 83 | |
| @@ -98,6 +101,7 @@ const toRun = (r: RunRow): Run => ({ | ||
| 98 | 101 | turns: r.turns, |
| 99 | 102 | createdAt: r.created_at, |
| 100 | 103 | startedAt: r.started_at, |
| 104 | servingAt: r.serving_at, | |
| 101 | 105 | finishedAt: r.finished_at, |
| 102 | 106 | }); |
| 103 | 107 | |
| @@ -163,6 +167,21 @@ export function claimRun(db: Database, runId: string): Run | undefined { | ||
| 163 | 167 | return changes > 0 ? getRun(db, runId) : undefined; |
| 164 | 168 | } |
| 165 | 169 | |
| 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 | ||
| 166 | 185 | /** |
| 167 | 186 | * Finish a run. Only a running run finishes; a cancelled one stays |
| 168 | 187 | * 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[] = [ | ||
| 730 | 730 | CREATE INDEX decision_issue ON decision(issue_id, kind, created_at); |
| 731 | 731 | CREATE INDEX decision_repo ON decision(repo_id, created_at); |
| 732 | 732 | `, |
| 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 | `, | |
| 733 | 739 | ]; |
src/web/pages/apps.ts +2
| @@ -65,6 +65,7 @@ import { | ||
| 65 | 65 | listCard, |
| 66 | 66 | row, |
| 67 | 67 | selectField, |
| 68 | servedPhrase, | |
| 68 | 69 | sha, |
| 69 | 70 | time, |
| 70 | 71 | userLink, |
| @@ -477,6 +478,7 @@ const envFacts = (base: string, env: EnvStatus): Html => | ||
| 477 | 478 | ${when(env.commitOid !== "", () => |
| 478 | 479 | sha(env.commitOid, `${base}/commit/${env.commitOid}`), |
| 479 | 480 | )} |
| 481 | ${when(servedPhrase(env), (phrase) => html`<span>live ${phrase}</span>`)} | |
| 480 | 482 | ${when(env.since, (since) => html`<span>up ${time(since)}</span>`)} |
| 481 | 483 | ${when( |
| 482 | 484 | env.runId, |
src/web/pages/studio.ts +7 −1
| @@ -79,6 +79,7 @@ import { | ||
| 79 | 79 | listCard, |
| 80 | 80 | pill, |
| 81 | 81 | row, |
| 82 | servedPhrase, | |
| 82 | 83 | time, |
| 83 | 84 | userTag, |
| 84 | 85 | type Tone, |
| @@ -497,9 +498,14 @@ export async function studioApp( | ||
| 497 | 498 | const production = envs.find((e) => e.env === "production"); |
| 498 | 499 | const url = runtime?.urlFor(record, "production"); |
| 499 | 500 | const state = production?.state ?? "stopped"; |
| 501 | const servedAfter = production ? servedPhrase(production) : undefined; | |
| 500 | 502 | const liveWord = |
| 501 | 503 | state === "running" |
| 502 | ? "live" | |
| 504 | ? production?.error !== undefined | |
| 505 | ? "serving · tests failed" | |
| 506 | : servedAfter !== undefined | |
| 507 | ? `live ${servedAfter}` | |
| 508 | : "live" | |
| 503 | 509 | : state === "failed" |
| 504 | 510 | ? "not up" |
| 505 | 511 | : state === "stopped" |
src/web/ui.ts +15 −1
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | * reaches for an inline `style` attribute, because the CSP forbids it. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import type { EnvState } from "../apps/runtime.ts"; | |
| 13 | import type { EnvState, EnvStatus } from "../apps/runtime.ts"; | |
| 14 | 14 | import type { RunKind, RunState } from "../core/runs.ts"; |
| 15 | 15 | import { icon, type IconName } from "./icons.ts"; |
| 16 | 16 | import { Html, html, raw, when } from "./html.ts"; |
| @@ -51,6 +51,20 @@ export function time(iso: string): Html { | ||
| 51 | 51 | >`; |
| 52 | 52 | } |
| 53 | 53 | |
| 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 | ||
| 54 | 68 | /** Bytes, three significant figures, never more precision than is true. */ |
| 55 | 69 | export function fileSize(bytes: number): string { |
| 56 | 70 | if (bytes < 1024) return `${bytes} B`; |
test/apps.test.ts +50 −1
| @@ -17,7 +17,11 @@ import { AppsRuntime } from "../src/apps/runtime.ts"; | ||
| 17 | 17 | import { envDir } from "../src/apps/data.ts"; |
| 18 | 18 | import { newApp, openEnvUrl, updateAppSettings } from "../src/actions/apps.ts"; |
| 19 | 19 | 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"; | |
| 21 | 25 | import { mergePull, openPull } from "../src/actions/pulls.ts"; |
| 22 | 26 | import { forkRepo } from "../src/actions/repos.ts"; |
| 23 | 27 | import { |
| @@ -543,6 +547,9 @@ describe("apps: production, previews, data, gates, crew", () => { | ||
| 543 | 547 | env: `pull-${opened.value.number}`, |
| 544 | 548 | }).find((r) => r.commitOid === committed.value.commitOid); |
| 545 | 549 | 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(); | |
| 546 | 553 | |
| 547 | 554 | const done = await settled( |
| 548 | 555 | (r) => |
| @@ -550,6 +557,11 @@ describe("apps: production, previews, data, gates, crew", () => { | ||
| 550 | 557 | r.commitOid === committed.value.commitOid, |
| 551 | 558 | ); |
| 552 | 559 | 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 | ||
| 553 | 565 | // Close it out: a later test counts open previews and expects only the |
| 554 | 566 | // one it opens itself. |
| 555 | 567 | const merged = await mergePull( |
| @@ -645,6 +657,43 @@ describe("apps: production, previews, data, gates, crew", () => { | ||
| 645 | 657 | expect(signout.headers.get("set-cookie") ?? "").toContain("Max-Age=0"); |
| 646 | 658 | }, 90_000); |
| 647 | 659 | |
| 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 | ||
| 648 | 697 | test("the crew: an agent reads and writes its repo, never the default branch, never a merge", async () => { |
| 649 | 698 | const made = await createAgent(app, alice, { |
| 650 | 699 | username: "bot", |
new above
offline