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

A change nobody has touched in days keeps a full preview running forever #409

Merged opened this

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

Merged

scout/forge:scout/299-a-change-nobody-has-touched-in-days-keep → main 1 commit fork

  • Merged. scout/299-a-change-nobody-has-touched-in-days-keep was merged into main as 1124a18.

Review

Sign in to review this change.
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
289289 works when there is none). `FORGE_CREW_CONCURRENCY` (2) and
290290 `FORGE_CREW_BUDGET_USD` (5) bound it; `FORGE_CREW_CEILING_USD` caps what the
291291 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.
293295 An admin with more than one Claude account keeps them under Settings →
294296 Subscriptions, sealed, and grants each to a repository (Settings → Crew) or
295297 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.
725725
726726 ### Asleep
727727
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
728736 An environment nobody asks for does not keep a process. A preview that has
729737 served no request for thirty minutes, or production for a day, is stopped
730738 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`).
864872 | `FORGE_APPS` | `1` | `0` runs no apps and no crew |
865873 | `FORGE_APP_DOMAIN` | the host in `FORGE_URL`/`_HOST` | what app hostnames attach to (`example` or `--x`) |
866874 | `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 |
867876 | `FORGE_CREW_CONCURRENCY` | `2` | agent runs at once |
868877 | `FORGE_CREW_BUDGET_USD` | `5` | spend per run unless the agent says otherwise |
869878 | `FORGE_CREW_TIMEOUT_MIN` | `45` | wall-clock limit of one agent run |
src/apps/proxy.ts +4
@@ -352,6 +352,10 @@ function admit(
352352 return err(new Response("not found", { status: 404 }));
353353 }
354354 }
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);
355359 const status = runtime.envStatus(target.app, target.env);
356360 if (status === undefined || status.port === undefined) {
357361 return err(unavailable(runtime, target, status));
src/apps/runtime.ts +147 −1
@@ -92,6 +92,12 @@ export interface RuntimeOptions {
9292 | undefined;
9393 /** How often the runtime looks for environments to put to sleep. */
9494 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;
95101 /**
96102 * How long past its idle window a busy environment (`apps/busy.ts`) is
97103 * left awake before it sleeps anyway — one crew run's length, so an app
@@ -176,6 +182,12 @@ export interface EnvStatus {
176182 * person's words (`BUSY_WORDS`) — set only while the sweep is sparing it.
177183 */
178184 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;
179191 }
180192
181193 interface Live {
@@ -255,6 +267,10 @@ interface Environment {
255267 readonly env: EnvName;
256268 live?: Live | undefined;
257269 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;
258274 /** One wake at a time; every request that arrives meanwhile waits on it. */
259275 waking?: Promise<void> | undefined;
260276 /** When something last came through the proxy for this environment. */
@@ -276,6 +292,14 @@ interface Environment {
276292 restarts: number[];
277293 }
278294
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
279303 const RESTART_WINDOW_MS = 10 * 60_000;
280304 const RESTART_MAX = 3;
281305 const RESTART_BACKOFF_MS = [2_000, 10_000, 30_000];
@@ -451,6 +475,19 @@ export class AppsRuntime {
451475 runId: state.queued.id,
452476 };
453477 }
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 }
454491 if (state.asleep !== undefined) {
455492 return {
456493 env: state.env,
@@ -555,7 +592,15 @@ export class AppsRuntime {
555592 // deployed: nobody is looking at it this second, and the first who
556593 // does wakes it warm. Thirty previews starting at once is what a
557594 // 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 }
559604 this.requestDeploy(record, repo, env, head, reason);
560605 }
561606 // Environments on disk for pulls that are no longer open.
@@ -728,6 +773,7 @@ export class AppsRuntime {
728773 ): Run | undefined {
729774 if (this.stopped) return undefined;
730775 const state = this.envFor(record, env);
776 state.dormant = undefined;
731777 if (
732778 (state.live?.commitOid === commitOid ||
733779 state.asleep?.commitOid === commitOid) &&
@@ -1264,9 +1310,109 @@ export class AppsRuntime {
12641310 return true;
12651311 }
12661312
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
12671412 /** Stop what nobody has asked for lately, and keep it ready to wake. */
12681413 private async sweep(): Promise<void> {
12691414 if (this.stopped) return;
1415 await this.hibernate();
12701416 const after = this.options.sleepAfterMs ?? SLEEP_AFTER_MS;
12711417 const grace = this.options.busyGraceMs ?? BUSY_GRACE_MS;
12721418 const now = Date.now();
src/main.ts +8
@@ -143,6 +143,9 @@ const apps = appDomain?.ok
143143 forgeUrl,
144144 concurrency: positive(process.env.FORGE_APPS_CONCURRENCY, 2),
145145 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,
146149 })
147150 : undefined;
148151 await apps?.start();
@@ -216,6 +219,11 @@ runDelivery(app.db, app.log); // sweep anything queued while we were down
216219 pruneSessions(app.db);
217220 pruneTokens(app.db);
218221
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
219227 function positive(value: string | undefined, fallback: number): number {
220228 const parsed = Number(value);
221229 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 {
113113 ? "asleep; wakes on a request"
114114 : `asleep since ${ago(env.since)}; wakes on a request`;
115115 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)}`;
117119 }
118120 };
119121
src/web/pages/apps.ts +11 −7
@@ -779,7 +779,7 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html {
779779 );
780780 const head = html`${icon("rocket")}
781781 <h2>Preview</h2>
782 ${env === undefined ? null : envStateBadge(env.state)}`;
782 ${env === undefined ? null : envStateBadge(env.state, env.dormant !== undefined)}`;
783783 if (!view.value.serving)
784784 return card(
785785 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 {
808808 ),
809809 )}
810810 <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 })}
817821 </div>`,
818822 {
819823 head,
src/web/studio/previews.ts +3
@@ -26,6 +26,7 @@ import { fragment } from "../layout.ts";
2626 import {
2727 badge,
2828 btn,
29 dormantNote,
2930 envActions,
3031 envFacts,
3132 envPhrase,
@@ -105,6 +106,8 @@ function previewRow(
105106 if (env?.since) bits.push(html`up ${time(env.since)}`);
106107 const say = env?.error ?? env?.failedDeploy?.error ?? env?.note;
107108 if (say) bits.push(html`${say}`);
109 const asleep = env === undefined ? null : dormantNote(env);
110 if (asleep !== null) bits.push(asleep);
108111 if (env !== undefined) {
109112 const dataNote = previewDataNote(record.previewData, pull.createdAt);
110113 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 {
282282 }
283283
284284 /** 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 {
286286 switch (state) {
287287 case "stopped":
288 if (dormant) return badge("Asleep", { icon: "clock", variant: "outline" });
288289 return badge("Stopped", { icon: "square", variant: "outline" });
289290 case "deploying":
290291 return badge("Deploying", { icon: "loader", tone: "attn" });
@@ -297,6 +298,17 @@ export function envStateBadge(state: EnvState): Html {
297298 }
298299 }
299300
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
300312 /** The tone an environment's state gives a `dot()` — a list's one-glance answer. */
301313 export function envTone(state: EnvState | undefined): Tone {
302314 return state === "running"
@@ -321,6 +333,7 @@ export function envPhrase(env: EnvStatus | undefined): {
321333 word: string;
322334 tone: Tone;
323335 } {
336 if (env?.dormant !== undefined) return { word: "asleep", tone: "default" };
324337 if (env === undefined || env.state === "stopped")
325338 return { word: "not running", tone: "default" };
326339 if (env.state === "failed") return { word: "not up", tone: "danger" };
@@ -380,6 +393,7 @@ export function envFacts(
380393 </p>`,
381394 )}
382395 ${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>`)}
383397 ${when(
384398 env.busy,
385399 (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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs