Public: anyone can read this; the crew can change it.
Sleep windows follow the slider: the runtime reads its idle windows from the position, live #10186
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/506-sleep-windows-follow-the-slider-the-runt → main 5 commits fork 2 threads · 2 unresolved
11 files
changed +441
−29
docs/APPS.md +14 −6
| @@ -534,6 +534,10 @@ that before the deploy fails; `tty` is the app's terminal interface (below); | ||
| 534 | 534 | file is read from the commit being deployed, never from the database — a |
| 535 | 535 | pull that changes how the app runs is previewed the way it will run. |
| 536 | 536 | |
| 537 | `app.always_on: true` keeps production awake whatever the profit slider | |
| 538 | says — the one per-app exception to it, shown on the app page. Previews | |
| 539 | still sleep as the forge says. | |
| 540 | ||
| 537 | 541 | `toolbar`, at the top level, is `true` unless you set it to `false`. The |
| 538 | 542 | forge adds one `<script>` tag to the HTML pages your app serves (before |
| 539 | 543 | `</body>`, or at the end): a small pill in the bottom-right corner, collapsed |
| @@ -1431,17 +1435,21 @@ works the crew page says so, in words, before anyone assigns anything. | ||
| 1431 | 1435 | |
| 1432 | 1436 | ### Asleep |
| 1433 | 1437 | |
| 1434 | A preview whose pull has had no push and no comment for three days is | |
| 1435 | stopped and shown as _asleep_ with when and why — its row, data and pictures | |
| 1438 | A preview whose pull has had no push and no comment for as long as the | |
| 1439 | profit slider says (three days at its default of 50, one at 0, never at | |
| 1440 | 100; `docs/PROFIT.md`) is stopped and shown as _asleep_ with when and why — its row, data and pictures | |
| 1436 | 1441 | stay. It is not failed, and a restart leaves it stopped. Asking for it (a |
| 1437 | 1442 | request to its host, or **Wake it** on the change's page) deploys it again |
| 1438 | 1443 | on the ordinary deploy path. Production, and a pull with a run queued or |
| 1439 | 1444 | running on it, are never put to sleep this way. `FORGE_PREVIEW_IDLE_DAYS` |
| 1440 | sets the age; `0` keeps every preview warm. | |
| 1445 | pins the age instead of the slider; `0` keeps every preview warm. | |
| 1441 | 1446 | |
| 1442 | 1447 | An environment nobody asks for does not keep a process. A preview that has |
| 1443 | served no request for thirty minutes, or production for a day, is stopped | |
| 1444 | and marked _asleep_, its checkout kept whole; the next request through the | |
| 1448 | served no request for thirty minutes, or production for a day — the profit | |
| 1449 | slider's 50; the sweep reads the slider's position on every pass, so moving | |
| 1450 | it changes when environments sleep within a minute, with no restart — is | |
| 1451 | stopped and marked _asleep_ (its log names the window and the position: | |
| 1452 | "asleep: nothing asked for it in 10 min (economy 0)"), its checkout kept whole; the next request through the | |
| 1445 | 1453 | proxy starts it again — warm, without install or test, in about the time a |
| 1446 | 1454 | page takes — and waits for it rather than showing a closed door. The forge |
| 1447 | 1455 | coming back does the same: production starts warm from the checkout it kept, |
| @@ -1604,7 +1612,7 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does. | ||
| 1604 | 1612 | | `FORGE_APPS` | `1` | `0` runs no apps and no crew | |
| 1605 | 1613 | | `FORGE_APP_DOMAIN` | the host in `FORGE_URL`/`_HOST` | what app hostnames attach to (`example` or `--x`) | |
| 1606 | 1614 | | `FORGE_APPS_CONCURRENCY` | `2` | deploys running their heavy steps at once | |
| 1607 | | `FORGE_PREVIEW_IDLE_DAYS` | `3` | days a pull may sit untouched before its preview sleeps; `0` = never | | |
| 1615 | | `FORGE_PREVIEW_IDLE_DAYS` | the profit slider | days a pull may sit untouched before its preview sleeps; `0` = never; unset follows the slider (3 at 50) | | |
| 1608 | 1616 | | `FORGE_CREW_CONCURRENCY` | 1 per 3 GiB above 2 GiB, 2–16 | agent runs at once (live: Settings → Capacity) | |
| 1609 | 1617 | | `FORGE_CHECKS_CONCURRENCY` | `1` | checks at once (live: Settings → Capacity) | |
| 1610 | 1618 | | `FORGE_CHECKS_PLACEMENT` | `process` | `job` runs a check's commands as a cluster Job (see Placement above) | |
docs/features/forge-sleep.md +10
| @@ -14,3 +14,13 @@ Code: `src/spawn/sleep.ts` (limits, waking page), `src/spawn/host.ts` (`wakeGate | ||
| 14 | 14 | - Terminal: the "Forges you sent" screen lists awake/asleep and size, `s` stops one, `x` removes one (confirmed); both call `spawn/manage.ts`, which writes `forge.stopped` / `forge.removed` to the event outbox, so web viewers see the change. Create: the New forge screen (`src/tui/screens/newforge.ts`). MCP: `spawn`. |
| 15 | 15 | - A stranger's request wakes a sleeping forge only within an address rate (`takeDoor`) and the awake ceiling, and never refreshes its idleness. A request does only when the child itself vouches for it: its session cookie or token (Bearer, `token`, or a `forge_` token as the Basic password) is looked up read-only in the child's own database (`src/spawn/vouch.ts`), so a made-up `authorization` header or `forge_session` cookie is a stranger's. A Basic password is not checked there and counts as a stranger's. A forge with a request in flight or crew work queued or running is not put to sleep. Not covered: an open websocket, and a response body still streaming from a cell forge after its headers. |
| 16 | 16 | - The wake path for a cell forge, and the hold around cell forwards, are not exercised by tests in this sandbox (cells are skipped). |
| 17 | ||
| 18 | ## Apps sleep on the profit slider | |
| 19 | ||
| 20 | The apps a forge runs sleep too, and the profit slider on the economics page says when. The next request wakes a sleeping app. | |
| 21 | ||
| 22 | - **Move the slider.** Each sweep, once a minute, reads the slider's position. No restart is needed. Production sleeps after 10 min with no requests at position 0, after 1 day at 50 (the default) and never at 100. A quiet pull's preview stops after 1 day at 0, 3 days at 50 and never at 100. When an app goes to sleep, its deploy log names the window and the position, e.g. `asleep: nothing asked for it in 10 min (economy 0); the next request wakes it`. | |
| 23 | - **Keep one app awake.** Put `"always_on": true` under `app` in its `forge.json` and push. Its production never sleeps, whatever the position. Its previews still follow the slider. Any value other than `true` or `false` fails the deploy and says why. The app page shows the row "Sleeps: never (app.always_on)". The terminal's deploys screen adds "never sleeps (app.always_on)" to production's line. | |
| 24 | - **Pin the preview age.** Setting `FORGE_PREVIEW_IDLE_DAYS` on the forge fixes a quiet pull's preview age, whatever the position. `0` keeps every preview warm. Leave it unset to follow the slider. | |
| 25 | ||
| 26 | Code: `src/apps/sleep_window.ts` (windows, `previewIdleMs`), `src/apps/runtime.ts` (the sweep). Tests: `test/sleep-window.test.ts`, `test/apps-sleep-slider.test.ts`, `test/preview-idle-slider.test.ts`. |
src/apps/manifest.ts +10
| @@ -52,6 +52,12 @@ export interface AppManifest { | ||
| 52 | 52 | readonly pages?: readonly string[] | undefined; |
| 53 | 53 | /** `app.launch`: how this repository's demos open unless a pin says otherwise. */ |
| 54 | 54 | readonly launch?: Launch | undefined; |
| 55 | /** | |
| 56 | * `app.always_on: true`: production never sleeps, whatever the profit | |
| 57 | * slider says (docs/PROFIT.md) — the one per-app exception. Previews | |
| 58 | * still follow the forge. Unset or false follows the slider. | |
| 59 | */ | |
| 60 | readonly alwaysOn?: true | undefined; | |
| 55 | 61 | /** |
| 56 | 62 | * Set when `app.kind` is `"service"`: declared TCP ports, always on, |
| 57 | 63 | * a placement preference and synced volumes. `null` for an ordinary |
| @@ -167,6 +173,9 @@ export function parseManifest( | ||
| 167 | 173 | const launch = app["launch"]; |
| 168 | 174 | if (launch !== undefined && !isLaunch(launch)) |
| 169 | 175 | return invalid(`forge.json: app.launch must be one of ${LAUNCHES.map((l) => `"${l}"`).join(", ")}`); |
| 176 | const alwaysOn = app["always_on"]; | |
| 177 | if (alwaysOn !== undefined && typeof alwaysOn !== "boolean") | |
| 178 | return invalid("forge.json: app.always_on must be true or false"); | |
| 170 | 179 | const egress = egressField(parsed.value); |
| 171 | 180 | if (!egress.ok) return egress; |
| 172 | 181 | const limits = limitsField(app, "app"); |
| @@ -211,6 +220,7 @@ export function parseManifest( | ||
| 211 | 220 | looksAsOwner: looksAsOwner.value, |
| 212 | 221 | pages: pages.value, |
| 213 | 222 | ...(launch === undefined ? {} : { launch }), |
| 223 | ...(alwaysOn === true ? { alwaysOn: true as const } : {}), | |
| 214 | 224 | service, |
| 215 | 225 | egress: egress.value, |
| 216 | 226 | limits: limits.value, |
src/apps/runtime.ts +51 −11
| @@ -101,6 +101,9 @@ import { parseManifest, type AppManifest } from "./manifest.ts"; | ||
| 101 | 101 | import { identityKey } from "./proxy.ts"; |
| 102 | 102 | import { noteReached } from "./egress_report.ts"; |
| 103 | 103 | import { egressSetting } from "../core/capacity.ts"; |
| 104 | import { policyAt } from "../core/slider.ts"; | |
| 105 | import { getEconomics } from "../settings/economics.ts"; | |
| 106 | import { asleepWords, sleepWindow, type WindowSource } from "./sleep_window.ts"; | |
| 104 | 107 | import { noteProtection } from "../kernel/protection.ts"; |
| 105 | 108 | import { cellsAvailable, resolveLimits, type CellSpec } from "./contain.ts"; |
| 106 | 109 | import { |
| @@ -126,18 +129,26 @@ export interface RuntimeOptions { | ||
| 126 | 129 | readonly concurrency?: number | undefined; |
| 127 | 130 | /** |
| 128 | 131 | * How long an environment serves nobody before its process is stopped |
| 129 | * and it sleeps, to be woken by the next request. A preview is looked at | |
| 130 | * and forgotten; production is left alone for a day before it rests. | |
| 132 | * and it sleeps, to be woken by the next request. Unset (the forge's | |
| 133 | * own runtime) reads the profit slider's position on every sweep | |
| 134 | * (`apps/sleep_window.ts`); set, it is fixed — tests and nothing else. | |
| 131 | 135 | */ |
| 132 | 136 | readonly sleepAfterMs?: |
| 133 | 137 | | { readonly production: number; readonly preview: number } |
| 134 | 138 | | undefined; |
| 139 | /** | |
| 140 | * The sweep's clock; a test hands in one that runs ahead to prove a | |
| 141 | * window of minutes without waiting minutes. @default Date.now | |
| 142 | */ | |
| 143 | readonly clock?: (() => number) | undefined; | |
| 135 | 144 | /** How often the runtime looks for environments to put to sleep. */ |
| 136 | 145 | readonly sweepEveryMs?: number | undefined; |
| 137 | 146 | /** |
| 138 | 147 | * How long a pull may go without a push or a comment before its preview |
| 139 | 148 | * is stopped (not removed: its row, data and pictures stay) and reads as |
| 140 | * asleep until someone asks for it. Unset or 0 keeps every preview warm. | |
| 149 | * asleep until someone asks for it. Unset follows the profit slider | |
| 150 | * (`policyAt(position).idlePullMs`, read each sweep; `null` is never); | |
| 151 | * 0 keeps every preview warm. | |
| 141 | 152 | */ |
| 142 | 153 | readonly idlePullMs?: number | undefined; |
| 143 | 154 | /** |
| @@ -246,6 +257,8 @@ export interface EnvStatus { | ||
| 246 | 257 | * person's words (`BUSY_WORDS`) — set only while the sweep is sparing it. |
| 247 | 258 | */ |
| 248 | 259 | readonly busy?: string | undefined; |
| 260 | /** Production whose `forge.json` sets `app.always_on: true`: it never sleeps. */ | |
| 261 | readonly alwaysOn?: true | undefined; | |
| 249 | 262 | /** |
| 250 | 263 | * Set while a preview is stopped because its pull went quiet: when it was |
| 251 | 264 | * stopped, and the last touch (push, comment, wake) it was measured from. |
| @@ -593,6 +606,9 @@ export class AppsRuntime { | ||
| 593 | 606 | looks: state.live.manifest.looks, |
| 594 | 607 | looksAsOwner: state.live.manifest.looksAsOwner, |
| 595 | 608 | toolbar: state.live.manifest.toolbar, |
| 609 | ...(state.env === "production" && state.live.manifest.alwaysOn === true | |
| 610 | ? { alwaysOn: true as const } | |
| 611 | : {}), | |
| 596 | 612 | ...(state.live.deployStartedAt === null |
| 597 | 613 | ? {} |
| 598 | 614 | : { deployStartedAt: state.live.deployStartedAt }), |
| @@ -1916,8 +1932,8 @@ export class AppsRuntime { | ||
| 1916 | 1932 | * or someone is deciding it right now (a queued or running run on it). |
| 1917 | 1933 | */ |
| 1918 | 1934 | private idleSince(repo: Repo, pull: Pull, env: EnvName): string | undefined { |
| 1919 | const age = this.options.idlePullMs ?? 0; | |
| 1920 | if (age <= 0 || pull.state !== "open") return undefined; | |
| 1935 | const age = this.idlePullAge(); | |
| 1936 | if (age === null || pull.state !== "open") return undefined; | |
| 1921 | 1937 | const now = Date.now(); |
| 1922 | 1938 | let last = Date.parse(pull.updatedAt); |
| 1923 | 1939 | if (now - last < age) return undefined; |
| @@ -1968,7 +1984,7 @@ export class AppsRuntime { | ||
| 1968 | 1984 | |
| 1969 | 1985 | /** Stop the previews whose pulls have gone quiet; keep everything else. */ |
| 1970 | 1986 | private async hibernate(): Promise<void> { |
| 1971 | if ((this.options.idlePullMs ?? 0) <= 0) return; | |
| 1987 | if (this.idlePullAge() === null) return; | |
| 1972 | 1988 | for (const state of [...this.envs.values()]) { |
| 1973 | 1989 | const number = envPull(state.env); |
| 1974 | 1990 | if ( |
| @@ -2007,13 +2023,32 @@ export class AppsRuntime { | ||
| 2007 | 2023 | } |
| 2008 | 2024 | } |
| 2009 | 2025 | |
| 2026 | /** | |
| 2027 | * How long a pull may sit untouched before its preview stops; `null` is | |
| 2028 | * never. A fixed option wins (0 is off); unset follows the slider, read | |
| 2029 | * now — so moving it changes the next sweep, with no restart. | |
| 2030 | */ | |
| 2031 | idlePullAge(): number | null { | |
| 2032 | const fixed = this.options.idlePullMs; | |
| 2033 | if (fixed !== undefined) return fixed > 0 ? fixed : null; | |
| 2034 | return policyAt(getEconomics(this.app.db).position).idlePullMs; | |
| 2035 | } | |
| 2036 | ||
| 2037 | /** Where this sweep's idle windows come from: a fixed option, else the slider as it stands. */ | |
| 2038 | private windowSource(): WindowSource { | |
| 2039 | const fixed = this.options.sleepAfterMs; | |
| 2040 | return fixed !== undefined | |
| 2041 | ? { kind: "fixed", sleepAfterMs: fixed } | |
| 2042 | : { kind: "slider", position: getEconomics(this.app.db).position }; | |
| 2043 | } | |
| 2044 | ||
| 2010 | 2045 | /** Stop what nobody has asked for lately, and keep it ready to wake. */ |
| 2011 | 2046 | private async sweep(): Promise<void> { |
| 2012 | 2047 | if (this.stopped) return; |
| 2013 | 2048 | await this.hibernate(); |
| 2014 | const after = this.options.sleepAfterMs ?? SLEEP_AFTER_MS; | |
| 2049 | const source = this.windowSource(); | |
| 2015 | 2050 | const grace = this.options.busyGraceMs ?? BUSY_GRACE_MS; |
| 2016 | const now = Date.now(); | |
| 2051 | const now = this.options.clock?.() ?? Date.now(); | |
| 2017 | 2052 | this.metered(() => beatAwake(this.app.db, new Date(now).toISOString())); |
| 2018 | 2053 | for (const state of this.envs.values()) { |
| 2019 | 2054 | const live = state.live; |
| @@ -2029,8 +2064,13 @@ export class AppsRuntime { | ||
| 2029 | 2064 | state.waking !== undefined |
| 2030 | 2065 | ) |
| 2031 | 2066 | continue; |
| 2032 | const limit = | |
| 2033 | state.env === "production" ? after.production : after.preview; | |
| 2067 | const window = sleepWindow( | |
| 2068 | source, | |
| 2069 | state.env === "production", | |
| 2070 | live.manifest.alwaysOn === true, | |
| 2071 | ); | |
| 2072 | const limit = window.ms; | |
| 2073 | if (limit === null) continue; | |
| 2034 | 2074 | const last = state.lastRequestAt ?? Date.parse(live.since); |
| 2035 | 2075 | if (now - last < limit) continue; |
| 2036 | 2076 | // Idle by the clock; asked whether it is mid-work, and spared while |
| @@ -2065,7 +2105,7 @@ export class AppsRuntime { | ||
| 2065 | 2105 | this.app.config.dataDir, |
| 2066 | 2106 | live.runId, |
| 2067 | 2107 | busy === undefined |
| 2068 | ? `asleep: nothing asked for it in ${Math.round(limit / 60_000)} minutes; the next request wakes it` | |
| 2108 | ? asleepWords({ ms: limit, why: window.why }) | |
| 2069 | 2109 | : `asleep: still busy ${Math.round(grace / 60_000)} minutes past its idle window, which is as long as busy keeps an app awake; the next request wakes it`, |
| 2070 | 2110 | ); |
| 2071 | 2111 | log.info("asleep", { app: state.app.slug, env: state.env }); |
src/apps/sleep_window.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | /** | |
| 2 | * How long an environment may serve nobody before the sweep puts it to | |
| 3 | * sleep, and the words its log says when it does. The window comes from | |
| 4 | * the profit slider's position (`core/slider.ts`), read on every sweep so | |
| 5 | * moving the slider takes effect within one pass; an app's | |
| 6 | * `app.always_on: true` keeps its production awake whatever the position. | |
| 7 | * | |
| 8 | * Plain data in, plain data out. | |
| 9 | */ | |
| 10 | ||
| 11 | import { duration, policyAt } from "../core/slider.ts"; | |
| 12 | ||
| 13 | /** Where a window came from: the slider at a position, or a fixed setting (tests, a child forge). */ | |
| 14 | export type WindowSource = | |
| 15 | | { readonly kind: "slider"; readonly position: number } | |
| 16 | | { readonly kind: "fixed"; readonly sleepAfterMs: { readonly production: number; readonly preview: number } }; | |
| 17 | ||
| 18 | export interface SleepWindow { | |
| 19 | /** Idle milliseconds before it sleeps; `null` is never. */ | |
| 20 | readonly ms: number | null; | |
| 21 | /** Why it is this long, for the log: "economy 20", "always_on". */ | |
| 22 | readonly why: string; | |
| 23 | } | |
| 24 | ||
| 25 | /** The window for one environment: production or a preview. */ | |
| 26 | export function sleepWindow( | |
| 27 | source: WindowSource, | |
| 28 | production: boolean, | |
| 29 | alwaysOn: boolean, | |
| 30 | ): SleepWindow { | |
| 31 | if (production && alwaysOn) return { ms: null, why: "always_on" }; | |
| 32 | if (source.kind === "fixed") { | |
| 33 | const { sleepAfterMs } = source; | |
| 34 | return { ms: production ? sleepAfterMs.production : sleepAfterMs.preview, why: "set for this forge" }; | |
| 35 | } | |
| 36 | const after = policyAt(source.position).sleepAfterMs; | |
| 37 | return { ms: production ? after.production : after.preview, why: `economy ${source.position}` }; | |
| 38 | } | |
| 39 | ||
| 40 | /** "asleep: nothing asked for it in 10 min (economy 20); the next request wakes it" */ | |
| 41 | export function asleepWords(window: SleepWindow & { readonly ms: number }): string { | |
| 42 | return `asleep: nothing asked for it in ${duration(window.ms)} (${window.why}); the next request wakes it`; | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * `FORGE_PREVIEW_IDLE_DAYS` as the runtime's `idlePullMs`: unset is | |
| 47 | * `undefined`, so the runtime follows the slider each sweep; set pins the | |
| 48 | * age (0 keeps every preview warm). A value that is not a non-negative | |
| 49 | * number pins the default age, as it always has. | |
| 50 | */ | |
| 51 | export function previewIdleMs(raw: string | undefined, fallbackDays: number): number | undefined { | |
| 52 | if (raw === undefined) return undefined; | |
| 53 | const days = Number(raw); | |
| 54 | return (Number.isFinite(days) && days >= 0 ? days : fallbackDays) * 86_400_000; | |
| 55 | } |
src/main.ts +3 −7
| @@ -44,6 +44,7 @@ import { runAheadReads } from "./origins/ahead.ts"; | ||
| 44 | 44 | import { runGuardSweep } from "./origins/credential.ts"; |
| 45 | 45 | import { startForgeSsh } from "./tui/ssh.ts"; |
| 46 | 46 | import { AppsRuntime, IDLE_PULL_DAYS } from "./apps/runtime.ts"; |
| 47 | import { previewIdleMs } from "./apps/sleep_window.ts"; | |
| 47 | 48 | import { listApps } from "./core/apps.ts"; |
| 48 | 49 | import { holdAppHosts, releaseAppHosts } from "./apps/holding.ts"; |
| 49 | 50 | import { resolveAppDomain } from "./apps/host.ts"; |
| @@ -410,8 +411,8 @@ const apps = appDomain?.ok | ||
| 410 | 411 | concurrency: positive(process.env.FORGE_APPS_CONCURRENCY, 2), |
| 411 | 412 | busyGraceMs: crewRunMs, |
| 412 | 413 | // Days a pull may sit untouched before its preview sleeps; 0 keeps |
| 413 | // every preview warm. | |
| 414 | idlePullMs: nonNegative(process.env.FORGE_PREVIEW_IDLE_DAYS, IDLE_PULL_DAYS) * 86_400_000, | |
| 414 | // every preview warm. Unset follows the profit slider, live. | |
| 415 | idlePullMs: previewIdleMs(process.env.FORGE_PREVIEW_IDLE_DAYS, IDLE_PULL_DAYS), | |
| 415 | 416 | }) |
| 416 | 417 | : undefined; |
| 417 | 418 | // Which other forge serves this data's apps, read before this one starts its own. |
| @@ -591,11 +592,6 @@ runDelivery(app.db, app.log, app.bus); // sweep anything queued while we were do | ||
| 591 | 592 | pruneSessions(app.db); |
| 592 | 593 | pruneTokens(app.db); |
| 593 | 594 | |
| 594 | function nonNegative(value: string | undefined, fallback: number): number { | |
| 595 | const parsed = Number(value); | |
| 596 | return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; | |
| 597 | } | |
| 598 | ||
| 599 | 595 | function positive(value: string | undefined, fallback: number): number { |
| 600 | 596 | const parsed = Number(value); |
| 601 | 597 | return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; |
src/tui/screens/deploys.ts +13 −5
| @@ -143,6 +143,8 @@ interface Production { | ||
| 143 | 143 | readonly state: string; |
| 144 | 144 | readonly redeploy: boolean; |
| 145 | 145 | readonly stop: boolean; |
| 146 | /** Its forge.json sets `app.always_on`: production never sleeps. */ | |
| 147 | readonly alwaysOn: boolean; | |
| 146 | 148 | } |
| 147 | 149 | |
| 148 | 150 | /** |
| @@ -152,10 +154,16 @@ interface Production { | ||
| 152 | 154 | */ |
| 153 | 155 | function readProduction(f: Session, repo: Repo): Production { |
| 154 | 156 | const view = appView(f.app, f.user, repo); |
| 155 | if (!view.ok) return { state: "", redeploy: false, stop: false }; | |
| 156 | const state = view.value.environments.find((e) => e.env === "production")?.state ?? ""; | |
| 157 | if (!view.ok) return { state: "", redeploy: false, stop: false, alwaysOn: false }; | |
| 158 | const env = view.value.environments.find((e) => e.env === "production"); | |
| 159 | const state = env?.state ?? ""; | |
| 157 | 160 | const writer = view.value.serving && f.user !== undefined && canWrite(f.db, repo, f.user); |
| 158 | return { state, redeploy: writer, stop: writer && state !== "" && state !== "stopped" }; | |
| 161 | return { | |
| 162 | state, | |
| 163 | redeploy: writer, | |
| 164 | stop: writer && state !== "" && state !== "stopped", | |
| 165 | alwaysOn: env?.alwaysOn === true, | |
| 166 | }; | |
| 159 | 167 | } |
| 160 | 168 | |
| 161 | 169 | /** `#N title · check <word> · <preview address or none>` */ |
| @@ -210,7 +218,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 210 | 218 | const previewState = new ListState(); |
| 211 | 219 | /** Which list the cursor is in: production's deploys, or the previews under them. */ |
| 212 | 220 | let inPreviews = false; |
| 213 | let production: Production = { state: "", redeploy: false, stop: false }; | |
| 221 | let production: Production = { state: "", redeploy: false, stop: false, alwaysOn: false }; | |
| 214 | 222 | let members = false; |
| 215 | 223 | let prompts = false; |
| 216 | 224 | let stoppable = false; |
| @@ -438,7 +446,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 438 | 446 | head!.x, |
| 439 | 447 | head!.y, |
| 440 | 448 | truncate( |
| 441 | `${repo.ownerName}/${repo.name} · production deploys${production.state === "" ? "" : ` · ${production.state}`}`, | |
| 449 | `${repo.ownerName}/${repo.name} · production deploys${production.state === "" ? "" : ` · ${production.state}`}${production.alwaysOn ? " · never sleeps (app.always_on)" : ""}`, | |
| 442 | 450 | head!.w, |
| 443 | 451 | ), |
| 444 | 452 | style(c.mutedFg, c.bg), |
src/web/ui.ts +7
| @@ -516,6 +516,13 @@ export function envFacts( | ||
| 516 | 516 | )} |
| 517 | 517 | ${when(env.note, (note) => html`<p class="muted small">${note}</p>`)} |
| 518 | 518 | ${when(dormantNote(env), (note) => html`<p class="muted small" data-dormant>${note}</p>`)} |
| 519 | ${when( | |
Principles 1, 7 and 8: a prose paragraph that restates the rule. Make it a dl row ('Sleeps — never, app.always_on') like the other facts, plain words. |
||
| 520 | env.alwaysOn === true, | |
| 521 | () => | |
| 522 | html`<dl class="row-facts" data-always-on> | |
| 523 | <div><dt>Sleeps</dt><dd>never (app.always_on)</dd></div> | |
| 524 | </dl>`, | |
| 525 | )} | |
| 519 | 526 | ${when( |
| 520 | 527 | env.busy, |
| 521 | 528 | (busy) => |
test/apps-sleep-slider.test.ts added +145
| @@ -0,0 +1,145 @@ | ||
| 1 | /** | |
| 2 | * The sweep reads its idle windows from the profit slider's position on | |
| 3 | * every pass: moving the slider changes when production sleeps with no | |
| 4 | * restart, and `app.always_on` keeps production awake even at 0. The | |
| 5 | * sweep's clock runs ahead so a window of minutes takes milliseconds. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 13 | import { newApp } from "../src/actions/apps.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, type Run } 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 | import { readRunLog } from "../src/runs/log.ts"; | |
| 21 | import { setEconomics } from "../src/settings/economics.ts"; | |
| 22 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 23 | import { deploysScreen } from "../src/tui/screens/deploys.ts"; | |
| 24 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 25 | import { settleApp } from "./helpers/tui.ts"; | |
| 26 | ||
| 27 | describe("sleep windows follow the slider", () => { | |
| 28 | let dataDir: string; | |
| 29 | let app: App; | |
| 30 | let servers: Servers; | |
| 31 | let runtime: AppsRuntime; | |
| 32 | let alice: User; | |
| 33 | let repo: Repo; | |
| 34 | let record: AppRecord; | |
| 35 | const DOMAIN = "apps.test"; | |
| 36 | const ELEVEN_MINUTES = 11 * 60_000; | |
| 37 | let ahead = 0; | |
| 38 | ||
| 39 | const viaHost = (host: string) => | |
| 40 | fetch(`${servers.url}/`, { headers: { host }, redirect: "manual" }); | |
| 41 | const production = (): EnvStatus | undefined => | |
| 42 | runtime.status(record).find((e) => e.env === "production"); | |
| 43 | const position = (n: number) => setEconomics(app.db, { position: n, marginPct: 0 }, alice.id); | |
| 44 | ||
| 45 | async function until<T>(read: () => T | undefined, ms = 60_000): Promise<T> { | |
| 46 | const deadline = Date.now() + ms; | |
| 47 | while (Date.now() < deadline) { | |
| 48 | const value = read(); | |
| 49 | if (value !== undefined) return value; | |
| 50 | await Bun.sleep(50); | |
| 51 | } | |
| 52 | throw new Error("did not happen in time"); | |
| 53 | } | |
| 54 | const settled = (where: (run: Run) => boolean) => | |
| 55 | until(() => | |
| 56 | listRuns(app.db, repo.id, { kind: "deploy", limit: 50 }).find( | |
| 57 | (r) => where(r) && (r.state === "ok" || r.state === "failed"), | |
| 58 | ), | |
| 59 | ); | |
| 60 | const log = (runId: string): string => | |
| 61 | readRunLog(dataDir, runId, 64 * 1024).lines.join("\n"); | |
| 62 | ||
| 63 | beforeAll(async () => { | |
| 64 | dataDir = await mkdtemp(join(tmpdir(), "forge-sleep-slider-")); | |
| 65 | app = createApp({ dataDir, port: 0 }); | |
| 66 | servers = await serve(app); | |
| 67 | runtime = new AppsRuntime(app, { | |
| 68 | domain: DOMAIN, | |
| 69 | scheme: "http", | |
| 70 | publicPort: servers.port, | |
| 71 | forgeUrl: servers.url, | |
| 72 | sweepEveryMs: 100, | |
| 73 | clock: () => Date.now() + ahead, | |
| 74 | }); | |
| 75 | await runtime.start(); | |
| 76 | const created = await createUser(app.db, { origin: "seed", trusted: true, | |
| 77 | username: "alice", | |
| 78 | email: "[email protected]", | |
| 79 | password: "a-good-passphrase", | |
| 80 | adminIfFirst: true, | |
| 81 | }); | |
| 82 | if (!created.ok) throw new Error(created.error); | |
| 83 | alice = created.value; | |
| 84 | const made = await newApp(app, alice, { name: "hello", description: "Notes", visibility: "public" }); | |
| 85 | if (!made.ok) throw new Error(String(made.error)); | |
| 86 | repo = made.value.repo; | |
| 87 | record = made.value.app; | |
| 88 | expect((await settled((r) => r.env === "production")).state).toBe("ok"); | |
| 89 | }, 120_000); | |
| 90 | ||
| 91 | afterAll(async () => { | |
| 92 | await runtime.stop(); | |
| 93 | servers.stop(); | |
| 94 | app.close(); | |
| 95 | await rm(dataDir, { recursive: true, force: true }); | |
| 96 | }); | |
| 97 | ||
| 98 | test("at 100 production idle past the 0-anchor window stays up; moved to 0, the next sweep puts it to sleep", async () => { | |
| 99 | position(100); | |
| 100 | ahead = ELEVEN_MINUTES; | |
| 101 | await Bun.sleep(600); | |
| 102 | expect(production()?.state).toBe("running"); | |
| 103 | position(0); | |
| 104 | await until(() => (production()?.state === "asleep" ? true : undefined), 5_000); | |
| 105 | expect(log(production()!.runId!)).toContain( | |
| 106 | "asleep: nothing asked for it in 10 min (economy 0); the next request wakes it", | |
| 107 | ); | |
| 108 | }, 30_000); | |
| 109 | ||
| 110 | test("always_on wins over 0, and the status says so", async () => { | |
| 111 | ahead = 0; | |
| 112 | expect((await viaHost(`hello.${DOMAIN}`)).status).toBe(200); | |
| 113 | const before = new Set(listRuns(app.db, repo.id, { kind: "deploy", limit: 50 }).map((r) => r.id)); | |
| 114 | const pinned = await commitToBranch(app, alice, repo, { | |
| 115 | branch: "main", | |
| 116 | message: "Always on", | |
| 117 | files: [{ | |
| 118 | path: "forge.json", | |
| 119 | content: JSON.stringify({ app: { run: "bun run src/server.ts", health: "/healthz", always_on: true } }), | |
| 120 | }], | |
| 121 | }); | |
| 122 | if (!pinned.ok) throw new Error(JSON.stringify(pinned.error)); | |
| 123 | const run = await settled((r) => r.env === "production" && !before.has(r.id)); | |
| 124 | expect(run.state).toBe("ok"); | |
| 125 | await until(() => (production()?.alwaysOn === true ? true : undefined), 10_000); | |
| 126 | position(0); | |
| 127 | ahead = ELEVEN_MINUTES; | |
| 128 | await Bun.sleep(600); | |
| 129 | expect(production()?.state).toBe("running"); | |
| 130 | expect(production()?.alwaysOn).toBe(true); | |
| 131 | // The terminal's deploys screen says the same fact as the page. | |
| 132 | const tui = createForgeApp( | |
| 133 | app, | |
| 134 | { sshUsername: alice.username, remoteAddress: "127.0.0.1", user: alice }, | |
| 135 | { terminal: headlessTerminal(160, 30), exitHooks: false }, | |
| 136 | ); | |
| 137 | tui.app.start(); | |
| 138 | await settleApp(tui.app); | |
| 139 | tui.app.push(deploysScreen(tui.session, tui.app.ctx, repo)); | |
| 140 | await settleApp(tui.app); | |
| 141 | const text = tui.app.screen.toText(); | |
| 142 | tui.app.stop(); | |
| 143 | expect(text).toContain("never sleeps (app.always_on)"); | |
| 144 | }, 120_000); | |
| 145 | }); |
test/preview-idle-slider.test.ts added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | /** | |
| 2 | * A quiet pull's preview age: `FORGE_PREVIEW_IDLE_DAYS` unset follows the | |
| 3 | * profit slider, read each sweep; set pins it whatever the slider says. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { AppsRuntime, IDLE_PULL_DAYS } from "../src/apps/runtime.ts"; | |
| 11 | import { previewIdleMs } from "../src/apps/sleep_window.ts"; | |
| 12 | import { createUser, type User } from "../src/core/users.ts"; | |
| 13 | import { setEconomics } from "../src/settings/economics.ts"; | |
| 14 | ||
| 15 | const DAY = 86_400_000; | |
| 16 | ||
| 17 | describe("FORGE_PREVIEW_IDLE_DAYS", () => { | |
| 18 | test("unset follows the slider; set pins the age; nonsense pins the default", () => { | |
| 19 | expect(previewIdleMs(undefined, IDLE_PULL_DAYS)).toBeUndefined(); | |
| 20 | expect(previewIdleMs("2", IDLE_PULL_DAYS)).toBe(2 * DAY); | |
| 21 | expect(previewIdleMs("0", IDLE_PULL_DAYS)).toBe(0); | |
| 22 | expect(previewIdleMs("-1", IDLE_PULL_DAYS)).toBe(IDLE_PULL_DAYS * DAY); | |
| 23 | expect(previewIdleMs("soon", IDLE_PULL_DAYS)).toBe(IDLE_PULL_DAYS * DAY); | |
| 24 | }); | |
| 25 | }); | |
| 26 | ||
| 27 | describe("the runtime's idle-pull age", () => { | |
| 28 | let dataDir: string; | |
| 29 | let app: App; | |
| 30 | let alice: User; | |
| 31 | const runtime = (idlePullMs: number | undefined) => | |
| 32 | new AppsRuntime(app, { domain: "apps.test", scheme: "http", forgeUrl: "http://forge.test", idlePullMs }); | |
| 33 | const position = (n: number) => setEconomics(app.db, { position: n, marginPct: 0 }, alice.id); | |
| 34 | ||
| 35 | beforeAll(async () => { | |
| 36 | dataDir = await mkdtemp(join(tmpdir(), "forge-preview-idle-")); | |
| 37 | app = createApp({ dataDir, port: 0 }); | |
| 38 | const made = await createUser(app.db, { origin: "seed", trusted: true, | |
| 39 | username: "alice", | |
| 40 | email: "[email protected]", | |
| 41 | password: "a-good-passphrase", | |
| 42 | adminIfFirst: true, | |
| 43 | }); | |
| 44 | if (!made.ok) throw new Error(made.error); | |
| 45 | alice = made.value; | |
| 46 | }); | |
| 47 | afterAll(async () => { | |
| 48 | app.close(); | |
| 49 | await rm(dataDir, { recursive: true, force: true }); | |
| 50 | }); | |
| 51 | ||
| 52 | test("unset reads the slider as it stands, with no restart", () => { | |
| 53 | const r = runtime(previewIdleMs(undefined, IDLE_PULL_DAYS)); | |
| 54 | position(0); | |
| 55 | expect(r.idlePullAge()).toBe(DAY); | |
| 56 | position(50); | |
| 57 | expect(r.idlePullAge()).toBe(IDLE_PULL_DAYS * DAY); | |
| 58 | position(100); | |
| 59 | expect(r.idlePullAge()).toBeNull(); | |
| 60 | }); | |
| 61 | ||
| 62 | test("set pins it at any position, and 0 keeps every preview warm", () => { | |
| 63 | const pinned = runtime(previewIdleMs("2", IDLE_PULL_DAYS)); | |
| 64 | const warm = runtime(previewIdleMs("0", IDLE_PULL_DAYS)); | |
| 65 | for (const n of [0, 100]) { | |
| 66 | position(n); | |
| 67 | expect(pinned.idlePullAge()).toBe(2 * DAY); | |
| 68 | expect(warm.idlePullAge()).toBeNull(); | |
| 69 | } | |
| 70 | }); | |
| 71 | }); |
test/sleep-window.test.ts added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | /** | |
| 2 | * The idle window an environment sleeps after comes from the slider's | |
| 3 | * position, and `app.always_on` is the one per-app exception. | |
| 4 | */ | |
| 5 | import { describe, expect, test } from "bun:test"; | |
| 6 | import { asleepWords, sleepWindow } from "../src/apps/sleep_window.ts"; | |
| 7 | import { parseManifest } from "../src/apps/manifest.ts"; | |
| 8 | import { envFacts } from "../src/web/ui.ts"; | |
| 9 | ||
| 10 | const at = (position: number) => ({ kind: "slider" as const, position }); | |
| 11 | ||
| 12 | describe("sleep windows", () => { | |
| 13 | test("production follows the slider: 10 min at 0, never at 100", () => { | |
| 14 | expect(sleepWindow(at(0), true, false)).toEqual({ ms: 600_000, why: "economy 0" }); | |
| 15 | expect(sleepWindow(at(100), true, false).ms).toBeNull(); | |
| 16 | expect(sleepWindow(at(50), true, false).ms).toBe(86_400_000); | |
| 17 | expect(sleepWindow(at(0), false, false).ms).toBe(300_000); | |
| 18 | }); | |
| 19 | ||
| 20 | test("always_on wins over 0 for production, and previews still follow the forge", () => { | |
| 21 | expect(sleepWindow(at(0), true, true)).toEqual({ ms: null, why: "always_on" }); | |
| 22 | expect(sleepWindow(at(0), false, true).ms).toBe(300_000); | |
| 23 | }); | |
| 24 | ||
| 25 | test("a fixed setting is used as given", () => { | |
| 26 | const fixed = { kind: "fixed" as const, sleepAfterMs: { production: 700, preview: 900 } }; | |
| 27 | expect(sleepWindow(fixed, true, false).ms).toBe(700); | |
| 28 | expect(sleepWindow(fixed, false, false).ms).toBe(900); | |
| 29 | }); | |
| 30 | ||
| 31 | test("the asleep line names the window and the slider", () => { | |
| 32 | expect(asleepWords({ ms: 600_000, why: "economy 20" })).toBe( | |
| 33 | "asleep: nothing asked for it in 10 min (economy 20); the next request wakes it", | |
| 34 | ); | |
| 35 | }); | |
| 36 | }); | |
| 37 | ||
| 38 | describe("app.always_on in forge.json", () => { | |
| 39 | const manifest = (app: Record<string, unknown>) => | |
| 40 | parseManifest(JSON.stringify({ app: { run: "bun run s.ts", ...app } }), undefined); | |
| 41 | ||
| 42 | test("true is kept, false and unset follow the forge, anything else is refused", () => { | |
| 43 | const on = manifest({ always_on: true }); | |
| 44 | expect(on.ok && on.value.alwaysOn).toBe(true); | |
| 45 | const off = manifest({ always_on: false }); | |
| 46 | expect(off.ok && off.value.alwaysOn).toBeUndefined(); | |
| 47 | const unset = manifest({}); | |
| 48 | expect(unset.ok && unset.value.alwaysOn).toBeUndefined(); | |
| 49 | const bad = manifest({ always_on: "yes" }); | |
| 50 | expect(bad.ok).toBe(false); | |
| 51 | if (!bad.ok) expect(JSON.stringify(bad.error)).toContain("app.always_on must be true or false"); | |
| 52 | }); | |
| 53 | ||
| 54 | test("the app page says production is always on, and says nothing otherwise", () => { | |
| 55 | const env = { env: "production" as const, state: "running" as const, commitOid: "abc1234", url: "http://hello.apps.test" }; | |
| 56 | const on = String(envFacts("/alice/hello", { ...env, alwaysOn: true })); | |
| 57 | expect(on).toContain("data-always-on"); | |
| 58 | expect(on).toMatch(/<dt>Sleeps<\/dt><dd>never \(app\.always_on\)<\/dd>/); | |
| 59 | expect(on).not.toContain("profit slider"); | |
| 60 | expect(String(envFacts("/alice/hello", env))).not.toContain("data-always-on"); | |
| 61 | }); | |
| 62 | }); |
idlePullAge (and the FORGE_PREVIEW_IDLE_DAYS unset/set switch in main.ts) has no test; add one that fails if the slider is not read.