Public: anyone can read this; the crew can change it.
A change nobody has touched in days keeps a full preview running forever #409
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/299-a-change-nobody-has-touched-in-days-keep → main 1 commit fork
10 files
changed +405
−11
README.md +3 −1
| @@ -289,7 +289,9 @@ forge's environment is handed to runs; the machine's own Claude Code login | ||
| 289 | 289 | works when there is none). `FORGE_CREW_CONCURRENCY` (2) and |
| 290 | 290 | `FORGE_CREW_BUDGET_USD` (5) bound it; `FORGE_CREW_CEILING_USD` caps what the |
| 291 | 291 | crew may spend across every repository in a UTC day, whatever each |
| 292 | repository's own ceiling says; `FORGE_APPS=0` turns apps and crew off. | |
| 292 | repository's own ceiling says; `FORGE_APPS=0` turns apps and crew off. A preview whose pull nobody has | |
| 293 | pushed to or commented on for `FORGE_PREVIEW_IDLE_DAYS` (default 3; `0` keeps | |
| 294 | every preview warm) is stopped and reads as asleep, and asking for it wakes it. | |
| 293 | 295 | An admin with more than one Claude account keeps them under Settings → |
| 294 | 296 | Subscriptions, sealed, and grants each to a repository (Settings → Crew) or |
| 295 | 297 | an organization (its page), down to one crew member: a work plan pays for |
docs/APPS.md +9
| @@ -725,6 +725,14 @@ works the crew page says so, in words, before anyone assigns anything. | ||
| 725 | 725 | |
| 726 | 726 | ### Asleep |
| 727 | 727 | |
| 728 | A preview whose pull has had no push and no comment for three days is | |
| 729 | stopped and shown as _asleep_ with when and why — its row, data and pictures | |
| 730 | stay. It is not failed, and a restart leaves it stopped. Asking for it (a | |
| 731 | request to its host, or **Wake it** on the change's page) deploys it again | |
| 732 | on the ordinary deploy path. Production, and a pull with a run queued or | |
| 733 | running on it, are never put to sleep this way. `FORGE_PREVIEW_IDLE_DAYS` | |
| 734 | sets the age; `0` keeps every preview warm. | |
| 735 | ||
| 728 | 736 | An environment nobody asks for does not keep a process. A preview that has |
| 729 | 737 | served no request for thirty minutes, or production for a day, is stopped |
| 730 | 738 | and marked _asleep_, its checkout kept whole; the next request through the |
| @@ -864,6 +872,7 @@ one line and a link to the asks screen is posted there (`src/crew/notify.ts`). | ||
| 864 | 872 | | `FORGE_APPS` | `1` | `0` runs no apps and no crew | |
| 865 | 873 | | `FORGE_APP_DOMAIN` | the host in `FORGE_URL`/`_HOST` | what app hostnames attach to (`example` or `--x`) | |
| 866 | 874 | | `FORGE_APPS_CONCURRENCY` | `2` | deploys running their heavy steps at once | |
| 875 | | `FORGE_PREVIEW_IDLE_DAYS` | `3` | days a pull may sit untouched before its preview sleeps; `0` = never | | |
| 867 | 876 | | `FORGE_CREW_CONCURRENCY` | `2` | agent runs at once | |
| 868 | 877 | | `FORGE_CREW_BUDGET_USD` | `5` | spend per run unless the agent says otherwise | |
| 869 | 878 | | `FORGE_CREW_TIMEOUT_MIN` | `45` | wall-clock limit of one agent run | |
src/apps/proxy.ts +4
| @@ -352,6 +352,10 @@ function admit( | ||
| 352 | 352 | return err(new Response("not found", { status: 404 })); |
| 353 | 353 | } |
| 354 | 354 | } |
| 355 | // A preview stopped for want of attention is asked for: it deploys again | |
| 356 | // the ordinary way, and this request sees "starting" until it answers. | |
| 357 | if (runtime.envStatus(target.app, target.env)?.dormant !== undefined) | |
| 358 | runtime.rouse(target.app, target.env); | |
| 355 | 359 | const status = runtime.envStatus(target.app, target.env); |
| 356 | 360 | if (status === undefined || status.port === undefined) { |
| 357 | 361 | return err(unavailable(runtime, target, status)); |
src/apps/runtime.ts +147 −1
| @@ -92,6 +92,12 @@ export interface RuntimeOptions { | ||
| 92 | 92 | | undefined; |
| 93 | 93 | /** How often the runtime looks for environments to put to sleep. */ |
| 94 | 94 | readonly sweepEveryMs?: number | undefined; |
| 95 | /** | |
| 96 | * How long a pull may go without a push or a comment before its preview | |
| 97 | * is stopped (not removed: its row, data and pictures stay) and reads as | |
| 98 | * asleep until someone asks for it. Unset or 0 keeps every preview warm. | |
| 99 | */ | |
| 100 | readonly idlePullMs?: number | undefined; | |
| 95 | 101 | /** |
| 96 | 102 | * How long past its idle window a busy environment (`apps/busy.ts`) is |
| 97 | 103 | * left awake before it sleeps anyway — one crew run's length, so an app |
| @@ -176,6 +182,12 @@ export interface EnvStatus { | ||
| 176 | 182 | * person's words (`BUSY_WORDS`) — set only while the sweep is sparing it. |
| 177 | 183 | */ |
| 178 | 184 | readonly busy?: string | undefined; |
| 185 | /** | |
| 186 | * Set while a preview is stopped because its pull went quiet: when it was | |
| 187 | * stopped, and the last touch (push, comment, wake) it was measured from. | |
| 188 | * A stopped preview with this set is asleep, never failed. | |
| 189 | */ | |
| 190 | readonly dormant?: { readonly since: string; readonly idleSince: string } | undefined; | |
| 179 | 191 | } |
| 180 | 192 | |
| 181 | 193 | interface Live { |
| @@ -255,6 +267,10 @@ interface Environment { | ||
| 255 | 267 | readonly env: EnvName; |
| 256 | 268 | live?: Live | undefined; |
| 257 | 269 | asleep?: Asleep | undefined; |
| 270 | /** Stopped for want of a push or comment; asked for, it redeploys. */ | |
| 271 | dormant?: | |
| 272 | | { since: string; idleSince: string; commitOid: string } | |
| 273 | | undefined; | |
| 258 | 274 | /** One wake at a time; every request that arrives meanwhile waits on it. */ |
| 259 | 275 | waking?: Promise<void> | undefined; |
| 260 | 276 | /** When something last came through the proxy for this environment. */ |
| @@ -276,6 +292,14 @@ interface Environment { | ||
| 276 | 292 | restarts: number[]; |
| 277 | 293 | } |
| 278 | 294 | |
| 295 | /** The deploy reasons that mean a person touched the change. */ | |
| 296 | const TOUCH_REASONS: readonly string[] = [ | |
| 297 | "push", | |
| 298 | "opened", | |
| 299 | "redeploy", | |
| 300 | "wake", | |
| 301 | ]; | |
| 302 | ||
| 279 | 303 | const RESTART_WINDOW_MS = 10 * 60_000; |
| 280 | 304 | const RESTART_MAX = 3; |
| 281 | 305 | const RESTART_BACKOFF_MS = [2_000, 10_000, 30_000]; |
| @@ -451,6 +475,19 @@ export class AppsRuntime { | ||
| 451 | 475 | runId: state.queued.id, |
| 452 | 476 | }; |
| 453 | 477 | } |
| 478 | if (state.dormant !== undefined) { | |
| 479 | return { | |
| 480 | env: state.env, | |
| 481 | state: "stopped", | |
| 482 | commitOid: state.dormant.commitOid, | |
| 483 | url, | |
| 484 | runId: state.lastRunId, | |
| 485 | dormant: { | |
| 486 | since: state.dormant.since, | |
| 487 | idleSince: state.dormant.idleSince, | |
| 488 | }, | |
| 489 | }; | |
| 490 | } | |
| 454 | 491 | if (state.asleep !== undefined) { |
| 455 | 492 | return { |
| 456 | 493 | env: state.env, |
| @@ -555,7 +592,15 @@ export class AppsRuntime { | ||
| 555 | 592 | // deployed: nobody is looking at it this second, and the first who |
| 556 | 593 | // does wakes it warm. Thirty previews starting at once is what a |
| 557 | 594 | // restart used to cost. |
| 558 | if (reason === "boot" && this.settleAsleep(record, env, head)) continue; | |
| 595 | // One idle past the age stays stopped: a restart never wakes it. | |
| 596 | if (reason === "boot") { | |
| 597 | const idleSince = this.idleSince(repo, pull, env); | |
| 598 | if (idleSince !== undefined) { | |
| 599 | this.hibernateAtBoot(record, env, head, idleSince); | |
| 600 | continue; | |
| 601 | } | |
| 602 | if (this.settleAsleep(record, env, head)) continue; | |
| 603 | } | |
| 559 | 604 | this.requestDeploy(record, repo, env, head, reason); |
| 560 | 605 | } |
| 561 | 606 | // Environments on disk for pulls that are no longer open. |
| @@ -728,6 +773,7 @@ export class AppsRuntime { | ||
| 728 | 773 | ): Run | undefined { |
| 729 | 774 | if (this.stopped) return undefined; |
| 730 | 775 | const state = this.envFor(record, env); |
| 776 | state.dormant = undefined; | |
| 731 | 777 | if ( |
| 732 | 778 | (state.live?.commitOid === commitOid || |
| 733 | 779 | state.asleep?.commitOid === commitOid) && |
| @@ -1264,9 +1310,109 @@ export class AppsRuntime { | ||
| 1264 | 1310 | return true; |
| 1265 | 1311 | } |
| 1266 | 1312 | |
| 1313 | /** | |
| 1314 | * When a pull was last touched, if that is longer ago than the idle age: | |
| 1315 | * the later of its `updatedAt` (comments, reviews) and the newest deploy | |
| 1316 | * run a person caused — a push, an opening, a press of Redeploy, a wake. | |
| 1317 | * Undefined when the feature is off, the pull was touched within the age, | |
| 1318 | * or someone is deciding it right now (a queued or running run on it). | |
| 1319 | */ | |
| 1320 | private idleSince(repo: Repo, pull: Pull, env: EnvName): string | undefined { | |
| 1321 | const age = this.options.idlePullMs ?? 0; | |
| 1322 | if (age <= 0 || pull.state !== "open") return undefined; | |
| 1323 | const now = Date.now(); | |
| 1324 | let last = Date.parse(pull.updatedAt); | |
| 1325 | if (now - last < age) return undefined; | |
| 1326 | const runs = listRuns(this.app.db, repo.id, { | |
| 1327 | issueId: pull.id, | |
| 1328 | limit: 50, | |
| 1329 | }); | |
| 1330 | if (runs.some((r) => r.state === "queued" || r.state === "running")) | |
| 1331 | return undefined; | |
| 1332 | for (const r of runs) { | |
| 1333 | if (r.kind === "deploy" && r.env === env && TOUCH_REASONS.includes(r.reason)) | |
| 1334 | last = Math.max(last, Date.parse(r.createdAt)); | |
| 1335 | } | |
| 1336 | return now - last < age ? undefined : new Date(last).toISOString(); | |
| 1337 | } | |
| 1338 | ||
| 1339 | /** At boot: register a preview idle past the age as stopped, no process. */ | |
| 1340 | private hibernateAtBoot( | |
| 1341 | record: AppRecord, | |
| 1342 | env: EnvName, | |
| 1343 | commitOid: string, | |
| 1344 | idleSince: string, | |
| 1345 | ): void { | |
| 1346 | const state = this.envFor(record, env); | |
| 1347 | if (state.live !== undefined || state.deploying !== undefined) return; | |
| 1348 | state.dormant = { | |
| 1349 | since: new Date().toISOString(), | |
| 1350 | idleSince, | |
| 1351 | commitOid, | |
| 1352 | }; | |
| 1353 | const built = listRuns(this.app.db, record.repoId, { | |
| 1354 | kind: "deploy", | |
| 1355 | env, | |
| 1356 | limit: 50, | |
| 1357 | }).find((r) => r.state === "ok"); | |
| 1358 | state.lastRunId ??= built?.id; | |
| 1359 | log.info("dormant from boot", { app: record.slug, env }); | |
| 1360 | } | |
| 1361 | ||
| 1362 | /** A dormant preview someone asked for deploys again, the ordinary way. */ | |
| 1363 | rouse(record: AppRecord, env: EnvName): void { | |
| 1364 | const state = this.envs.get(key(record.id, env)); | |
| 1365 | const dormant = state?.dormant; | |
| 1366 | if (dormant === undefined) return; | |
| 1367 | const repo = getRepoById(this.app.db, record.repoId); | |
| 1368 | if (repo) this.requestDeploy(record, repo, env, dormant.commitOid, "wake"); | |
| 1369 | } | |
| 1370 | ||
| 1371 | /** Stop the previews whose pulls have gone quiet; keep everything else. */ | |
| 1372 | private async hibernate(): Promise<void> { | |
| 1373 | if ((this.options.idlePullMs ?? 0) <= 0) return; | |
| 1374 | for (const state of [...this.envs.values()]) { | |
| 1375 | const number = envPull(state.env); | |
| 1376 | if ( | |
| 1377 | number === undefined || | |
| 1378 | state.dormant !== undefined || | |
| 1379 | state.deploying !== undefined || | |
| 1380 | state.queued !== undefined || | |
| 1381 | state.waking !== undefined || | |
| 1382 | (state.live === undefined && state.asleep === undefined) | |
| 1383 | ) | |
| 1384 | continue; | |
| 1385 | const repo = getRepoById(this.app.db, state.app.repoId); | |
| 1386 | const pull = repo && getPull(this.app.db, repo.id, number); | |
| 1387 | if (!repo || !pull) continue; | |
| 1388 | const idleSince = this.idleSince(repo, pull, state.env); | |
| 1389 | if (idleSince === undefined) continue; | |
| 1390 | const commitOid = state.live?.commitOid ?? state.asleep!.commitOid; | |
| 1391 | const runId = state.live?.runId ?? state.asleep!.runId; | |
| 1392 | await this.stopEnv(state.app, state.env); | |
| 1393 | if (this.stopped || state.live !== undefined || state.queued !== undefined) | |
| 1394 | continue; | |
| 1395 | state.dormant = { | |
| 1396 | since: new Date().toISOString(), | |
| 1397 | idleSince, | |
| 1398 | commitOid, | |
| 1399 | }; | |
| 1400 | state.lastError = undefined; | |
| 1401 | state.lastErrorCommitOid = undefined; | |
| 1402 | if (runId !== "") | |
| 1403 | appendRunLog( | |
| 1404 | this.app.config.dataDir, | |
| 1405 | runId, | |
| 1406 | `asleep: nobody pushed to or commented on pull #${number} since ${idleSince}; asking for it, or Redeploy, brings it back`, | |
| 1407 | ); | |
| 1408 | log.info("dormant", { app: state.app.slug, env: state.env }); | |
| 1409 | } | |
| 1410 | } | |
| 1411 | ||
| 1267 | 1412 | /** Stop what nobody has asked for lately, and keep it ready to wake. */ |
| 1268 | 1413 | private async sweep(): Promise<void> { |
| 1269 | 1414 | if (this.stopped) return; |
| 1415 | await this.hibernate(); | |
| 1270 | 1416 | const after = this.options.sleepAfterMs ?? SLEEP_AFTER_MS; |
| 1271 | 1417 | const grace = this.options.busyGraceMs ?? BUSY_GRACE_MS; |
| 1272 | 1418 | const now = Date.now(); |
src/main.ts +8
| @@ -143,6 +143,9 @@ const apps = appDomain?.ok | ||
| 143 | 143 | forgeUrl, |
| 144 | 144 | concurrency: positive(process.env.FORGE_APPS_CONCURRENCY, 2), |
| 145 | 145 | busyGraceMs: crewRunMs, |
| 146 | // Days a pull may sit untouched before its preview sleeps; 0 keeps | |
| 147 | // every preview warm. | |
| 148 | idlePullMs: nonNegative(process.env.FORGE_PREVIEW_IDLE_DAYS, 3) * 86_400_000, | |
| 146 | 149 | }) |
| 147 | 150 | : undefined; |
| 148 | 151 | await apps?.start(); |
| @@ -216,6 +219,11 @@ runDelivery(app.db, app.log); // sweep anything queued while we were down | ||
| 216 | 219 | pruneSessions(app.db); |
| 217 | 220 | pruneTokens(app.db); |
| 218 | 221 | |
| 222 | function nonNegative(value: string | undefined, fallback: number): number { | |
| 223 | const parsed = Number(value); | |
| 224 | return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; | |
| 225 | } | |
| 226 | ||
| 219 | 227 | function positive(value: string | undefined, fallback: number): number { |
| 220 | 228 | const parsed = Number(value); |
| 221 | 229 | return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; |
src/tui/screens/app.ts +3 −1
| @@ -113,7 +113,9 @@ export function appScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 113 | 113 | ? "asleep; wakes on a request" |
| 114 | 114 | : `asleep since ${ago(env.since)}; wakes on a request`; |
| 115 | 115 | case "stopped": |
| 116 | return "stopped"; | |
| 116 | return env.dormant === undefined | |
| 117 | ? "stopped" | |
| 118 | : `asleep since ${ago(env.dormant.since)}; nobody touched the change since ${ago(env.dormant.idleSince)}`; | |
| 117 | 119 | } |
| 118 | 120 | }; |
| 119 | 121 |
src/web/pages/apps.ts +11 −7
| @@ -779,7 +779,7 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 779 | 779 | ); |
| 780 | 780 | const head = html`${icon("rocket")} |
| 781 | 781 | <h2>Preview</h2> |
| 782 | ${env === undefined ? null : envStateBadge(env.state)}`; | |
| 782 | ${env === undefined ? null : envStateBadge(env.state, env.dormant !== undefined)}`; | |
| 783 | 783 | if (!view.value.serving) |
| 784 | 784 | return card( |
| 785 | 785 | html`<p class="muted small">Nothing is serving apps on this forge.</p>`, |
| @@ -808,12 +808,16 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 808 | 808 | ), |
| 809 | 809 | )} |
| 810 | 810 | <div class="bar mt"> |
| 811 | ${btn("Open preview", { | |
| 812 | href: `${base}/app/${env.env}/open`, | |
| 813 | size: "sm", | |
| 814 | variant: "outline", | |
| 815 | icon: "external", | |
| 816 | })} | |
| 811 | ${env.dormant !== undefined && writer | |
| 812 | ? html`<form method="post" action="${base}/app/${env.env}/redeploy"> | |
| 813 | ${btn("Wake it", { size: "sm", icon: "play" })} | |
| 814 | </form>` | |
| 815 | : btn("Open preview", { | |
| 816 | href: `${base}/app/${env.env}/open`, | |
| 817 | size: "sm", | |
| 818 | variant: "outline", | |
| 819 | icon: "external", | |
| 820 | })} | |
| 817 | 821 | </div>`, |
| 818 | 822 | { |
| 819 | 823 | head, |
src/web/studio/previews.ts +3
| @@ -26,6 +26,7 @@ import { fragment } from "../layout.ts"; | ||
| 26 | 26 | import { |
| 27 | 27 | badge, |
| 28 | 28 | btn, |
| 29 | dormantNote, | |
| 29 | 30 | envActions, |
| 30 | 31 | envFacts, |
| 31 | 32 | envPhrase, |
| @@ -105,6 +106,8 @@ function previewRow( | ||
| 105 | 106 | if (env?.since) bits.push(html`up ${time(env.since)}`); |
| 106 | 107 | const say = env?.error ?? env?.failedDeploy?.error ?? env?.note; |
| 107 | 108 | if (say) bits.push(html`${say}`); |
| 109 | const asleep = env === undefined ? null : dormantNote(env); | |
| 110 | if (asleep !== null) bits.push(asleep); | |
| 108 | 111 | if (env !== undefined) { |
| 109 | 112 | const dataNote = previewDataNote(record.previewData, pull.createdAt); |
| 110 | 113 | if (dataNote !== null) bits.push(dataNote); |
src/web/ui.ts +15 −1
| @@ -282,9 +282,10 @@ export function runStateBadge(state: RunState, kind: RunKind = "deploy"): Html { | ||
| 282 | 282 | } |
| 283 | 283 | |
| 284 | 284 | /** An environment's state: what production, or a preview, is doing now. */ |
| 285 | export function envStateBadge(state: EnvState): Html { | |
| 285 | export function envStateBadge(state: EnvState, dormant = false): Html { | |
| 286 | 286 | switch (state) { |
| 287 | 287 | case "stopped": |
| 288 | if (dormant) return badge("Asleep", { icon: "clock", variant: "outline" }); | |
| 288 | 289 | return badge("Stopped", { icon: "square", variant: "outline" }); |
| 289 | 290 | case "deploying": |
| 290 | 291 | return badge("Deploying", { icon: "loader", tone: "attn" }); |
| @@ -297,6 +298,17 @@ export function envStateBadge(state: EnvState): Html { | ||
| 297 | 298 | } |
| 298 | 299 | } |
| 299 | 300 | |
| 301 | /** | |
| 302 | * Why a preview is stopped, when it is only asleep: when, and what it was | |
| 303 | * waiting for. Never a failure's words — nothing broke. | |
| 304 | */ | |
| 305 | export function dormantNote(env: EnvStatus): Html | null { | |
| 306 | const d = env.dormant; | |
| 307 | if (d === undefined) return null; | |
| 308 | return html`asleep ${time(d.since)} — nobody pushed or commented since | |
| 309 | ${time(d.idleSince)}; its data and pictures are kept`; | |
| 310 | } | |
| 311 | ||
| 300 | 312 | /** The tone an environment's state gives a `dot()` — a list's one-glance answer. */ |
| 301 | 313 | export function envTone(state: EnvState | undefined): Tone { |
| 302 | 314 | return state === "running" |
| @@ -321,6 +333,7 @@ export function envPhrase(env: EnvStatus | undefined): { | ||
| 321 | 333 | word: string; |
| 322 | 334 | tone: Tone; |
| 323 | 335 | } { |
| 336 | if (env?.dormant !== undefined) return { word: "asleep", tone: "default" }; | |
| 324 | 337 | if (env === undefined || env.state === "stopped") |
| 325 | 338 | return { word: "not running", tone: "default" }; |
| 326 | 339 | if (env.state === "failed") return { word: "not up", tone: "danger" }; |
| @@ -380,6 +393,7 @@ export function envFacts( | ||
| 380 | 393 | </p>`, |
| 381 | 394 | )} |
| 382 | 395 | ${when(env.note, (note) => html`<p class="muted small">${note}</p>`)} |
| 396 | ${when(dormantNote(env), (note) => html`<p class="muted small" data-dormant>${note}</p>`)} | |
| 383 | 397 | ${when( |
| 384 | 398 | env.busy, |
| 385 | 399 | (busy) => |
test/apps-idle.test.ts added +202
| @@ -0,0 +1,202 @@ | ||
| 1 | /** | |
| 2 | * A preview whose pull nobody has touched for days is stopped, not removed: | |
| 3 | * its page says it is asleep and why, asking for it deploys it again, and a | |
| 4 | * restart leaves it as it was. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 12 | import { newApp } from "../src/actions/apps.ts"; | |
| 13 | import { openPull } from "../src/actions/pulls.ts"; | |
| 14 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 15 | import type { AppRecord } from "../src/core/apps.ts"; | |
| 16 | import type { Repo } from "../src/core/repos.ts"; | |
| 17 | import { listRuns } from "../src/core/runs.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | ||
| 21 | const DAY = 86_400_000; | |
| 22 | ||
| 23 | describe("previews of idle pulls sleep", () => { | |
| 24 | let dataDir: string; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let runtime: AppsRuntime; | |
| 28 | let alice: User; | |
| 29 | let repo: Repo; | |
| 30 | let record: AppRecord; | |
| 31 | const DOMAIN = "apps.test"; | |
| 32 | ||
| 33 | const options = () => ({ | |
| 34 | domain: DOMAIN, | |
| 35 | scheme: "http" as const, | |
| 36 | publicPort: servers.port, | |
| 37 | forgeUrl: servers.url, | |
| 38 | sweepEveryMs: 150, | |
| 39 | idlePullMs: 3 * DAY, | |
| 40 | }); | |
| 41 | const envOf = (n: number): EnvStatus | undefined => | |
| 42 | runtime.status(record).find((e) => e.env === `pull-${n}`); | |
| 43 | ||
| 44 | async function until<T>(read: () => T | undefined, ms = 60_000): Promise<T> { | |
| 45 | const deadline = Date.now() + ms; | |
| 46 | while (Date.now() < deadline) { | |
| 47 | const value = read(); | |
| 48 | if (value !== undefined) return value; | |
| 49 | await Bun.sleep(100); | |
| 50 | } | |
| 51 | throw new Error("did not happen in time"); | |
| 52 | } | |
| 53 | const running = (n: number) => | |
| 54 | until(() => (envOf(n)?.state === "running" ? true : undefined)); | |
| 55 | ||
| 56 | async function pull(name: string): Promise<number> { | |
| 57 | const branch = await commitToBranch(app, alice, repo, { | |
| 58 | branch: `feature/${name}`, | |
| 59 | base: "main", | |
| 60 | message: name, | |
| 61 | files: [{ path: `${name}.md`, content: `${name}\n` }], | |
| 62 | }); | |
| 63 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 64 | const opened = await openPull(app, alice, repo, { | |
| 65 | title: name, | |
| 66 | baseBranch: "main", | |
| 67 | headBranch: `feature/${name}`, | |
| 68 | }); | |
| 69 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 70 | await running(opened.value.number); | |
| 71 | return opened.value.number; | |
| 72 | } | |
| 73 | ||
| 74 | /** Make a pull look untouched for `ms`: its row, and the pushes that opened it. */ | |
| 75 | function age(n: number, ms: number): void { | |
| 76 | const then = new Date(Date.now() - ms).toISOString(); | |
| 77 | app.db | |
| 78 | .query( | |
| 79 | "UPDATE issue SET updated_at = ?1 WHERE repo_id = ?2 AND number = ?3", | |
| 80 | ) | |
| 81 | .run(then, repo.id, n); | |
| 82 | app.db | |
| 83 | .query("UPDATE run SET created_at = ?1 WHERE repo_id = ?2 AND env = ?3") | |
| 84 | .run(then, repo.id, `pull-${n}`); | |
| 85 | } | |
| 86 | ||
| 87 | beforeAll(async () => { | |
| 88 | dataDir = await mkdtemp(join(tmpdir(), "forge-idle-")); | |
| 89 | app = createApp({ dataDir, port: 0 }); | |
| 90 | servers = await serve(app); | |
| 91 | runtime = new AppsRuntime(app, options()); | |
| 92 | await runtime.start(); | |
| 93 | const created = await createUser(app.db, { | |
| 94 | username: "alice", | |
| 95 | email: "[email protected]", | |
| 96 | password: "a-good-passphrase", | |
| 97 | adminIfFirst: true, | |
| 98 | }); | |
| 99 | if (!created.ok) throw new Error(created.error); | |
| 100 | alice = created.value; | |
| 101 | const made = await newApp(app, alice, { | |
| 102 | name: "hello", | |
| 103 | description: "A place for notes", | |
| 104 | visibility: "public", | |
| 105 | }); | |
| 106 | if (!made.ok) throw new Error(String(made.error)); | |
| 107 | repo = made.value.repo; | |
| 108 | record = made.value.app; | |
| 109 | }, 120_000); | |
| 110 | ||
| 111 | afterAll(async () => { | |
| 112 | await runtime.stop(); | |
| 113 | servers.stop(); | |
| 114 | app.close(); | |
| 115 | await rm(dataDir, { recursive: true, force: true }); | |
| 116 | }); | |
| 117 | ||
| 118 | test("a pull touched an hour ago keeps its preview; one idle past the age loses its process, and its page says so", async () => { | |
| 119 | const fresh = await pull("fresh"); | |
| 120 | const idle = await pull("idle"); | |
| 121 | age(fresh, 3_600_000); | |
| 122 | age(idle, 4 * DAY); | |
| 123 | await until(() => (envOf(idle)?.dormant !== undefined ? true : undefined)); | |
| 124 | const asleep = envOf(idle)!; | |
| 125 | // Stopped is not failed: no error, no port, the commit and run kept. | |
| 126 | expect(asleep.state).toBe("stopped"); | |
| 127 | expect(asleep.error).toBeUndefined(); | |
| 128 | expect(asleep.port).toBeUndefined(); | |
| 129 | expect(asleep.commitOid).not.toBe(""); | |
| 130 | expect(asleep.runId).toBeDefined(); | |
| 131 | await Bun.sleep(500); | |
| 132 | expect(envOf(fresh)?.state).toBe("running"); | |
| 133 | expect(envOf(fresh)?.dormant).toBeUndefined(); | |
| 134 | // Production is never put to sleep by a pull's age. | |
| 135 | expect(runtime.status(record).find((e) => e.env === "production")?.state).toBe("running"); | |
| 136 | ||
| 137 | const signin = await fetch(`${servers.url}/signin`, { | |
| 138 | method: "POST", | |
| 139 | body: new URLSearchParams({ | |
| 140 | username: "alice", | |
| 141 | password: "a-good-passphrase", | |
| 142 | }), | |
| 143 | redirect: "manual", | |
| 144 | }); | |
| 145 | const cookie = `${(signin.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=dev`; | |
| 146 | const page = await ( | |
| 147 | await fetch(`${servers.url}/alice/hello/pulls/${idle}`, { | |
| 148 | headers: { cookie }, | |
| 149 | }) | |
| 150 | ).text(); | |
| 151 | expect(page).toContain("Asleep"); | |
| 152 | expect(page).toContain("nobody pushed or commented since"); | |
| 153 | expect(page).toContain("Wake it"); | |
| 154 | // A reader is told it sleeps, and handed no button. | |
| 155 | const reader = await ( | |
| 156 | await fetch(`${servers.url}/alice/hello/pulls/${idle}`) | |
| 157 | ).text(); | |
| 158 | expect(reader).toContain("nobody pushed or commented since"); | |
| 159 | expect(reader).not.toContain("Wake it"); | |
| 160 | const quiet = await (await fetch(`${servers.url}/alice/hello/pulls/${fresh}`)).text(); | |
| 161 | expect(quiet).not.toContain("nobody pushed or commented since"); | |
| 162 | }, 120_000); | |
| 163 | ||
| 164 | test("asking for a stopped preview redeploys it on the ordinary path", async () => { | |
| 165 | const idle = await until(() => { | |
| 166 | const e = runtime.status(record).find((x) => x.dormant !== undefined); | |
| 167 | return e === undefined ? undefined : Number(e.env.slice(5)); | |
| 168 | }); | |
| 169 | const before = listRuns(app.db, repo.id, { kind: "deploy", limit: 100 }).length; | |
| 170 | const res = await fetch(`${servers.url}/`, { | |
| 171 | headers: { host: `hello--${idle}.${DOMAIN}` }, | |
| 172 | }); | |
| 173 | expect([200, 503]).toContain(res.status); | |
| 174 | await running(idle); | |
| 175 | expect(envOf(idle)?.dormant).toBeUndefined(); | |
| 176 | const runs = listRuns(app.db, repo.id, { kind: "deploy", limit: 100 }); | |
| 177 | expect(runs.length).toBe(before + 1); | |
| 178 | expect(runs[0]!.reason).toBe("wake"); | |
| 179 | // Woken, it counts as touched: the next sweep leaves it up. | |
| 180 | await Bun.sleep(500); | |
| 181 | expect(envOf(idle)?.state).toBe("running"); | |
| 182 | }, 120_000); | |
| 183 | ||
| 184 | test("a restart leaves a sleeping preview asleep", async () => { | |
| 185 | const idle = await pull("later"); | |
| 186 | age(idle, 5 * DAY); | |
| 187 | await until(() => (envOf(idle)?.dormant !== undefined ? true : undefined)); | |
| 188 | const before = listRuns(app.db, repo.id, { kind: "deploy", limit: 100 }).filter( | |
| 189 | (r) => r.env === `pull-${idle}`, | |
| 190 | ).length; | |
| 191 | await runtime.stop(); | |
| 192 | runtime = new AppsRuntime(app, options()); | |
| 193 | await runtime.start(); | |
| 194 | expect(envOf(idle)?.dormant).toBeDefined(); | |
| 195 | expect(envOf(idle)?.state).toBe("stopped"); | |
| 196 | await Bun.sleep(500); | |
| 197 | const after = listRuns(app.db, repo.id, { kind: "deploy", limit: 100 }).filter( | |
| 198 | (r) => r.env === `pull-${idle}`, | |
| 199 | ).length; | |
| 200 | expect(after).toBe(before); | |
| 201 | }, 120_000); | |
| 202 | }); |
new above
offline