Public: anyone can read this; the crew can change it.
The probe verdicts and an app's wake from sleep are the same every run: probe, probes and apps-sleep leave the quarantine #11095
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11084-the-probe-verdicts-and-an-app-s-wake-fro → main 6 commits fork 1 thread · 1 unresolved
7 files
changed +180
−150
scripts/gate.ts −15
| @@ -33,21 +33,6 @@ export type Exclusion = { | ||
| 33 | 33 | }; |
| 34 | 34 | |
| 35 | 35 | export const EXCLUDED: readonly Exclusion[] = [ |
| 36 | { | |
| 37 | file: "test/apps-sleep.test.ts", | |
| 38 | reason: "red alone under --randomize (2026-11-10): an environment nobody asks for sleeps, and the next request wakes it — times out at 30s", | |
| 39 | issue: 11084, | |
| 40 | }, | |
| 41 | { | |
| 42 | file: "test/probe.test.ts", | |
| 43 | reason: "red alone under --randomize (2026-11-10): a scaffolded app obeys every cell: verdict mismatch, not ok", | |
| 44 | issue: 11084, | |
| 45 | }, | |
| 46 | { | |
| 47 | file: "test/probes.test.ts", | |
| 48 | reason: "red alone under --randomize (2026-11-10): the first clean probe after closes the issue: verdict down, not ok", | |
| 49 | issue: 11084, | |
| 50 | }, | |
| 51 | 36 | { |
| 52 | 37 | file: "test/rehearsal-real.test.ts", |
| 53 | 38 | reason: "green alone (3 of 3, 2026-11-10) but unproven under the gate's load, where on main@accaa65 the second real roll's old forge did not come up", |
src/apps/runtime.ts +14 −6
| @@ -1389,7 +1389,7 @@ export class AppsRuntime { | ||
| 1389 | 1389 | state.live = live; |
| 1390 | 1390 | this.awake(state, servingAt); |
| 1391 | 1391 | state.asleep = undefined; |
| 1392 | state.lastRequestAt = Date.now(); | |
| 1392 | state.lastRequestAt = this.now(); | |
| 1393 | 1393 | state.lastError = undefined; |
| 1394 | 1394 | if (rested !== undefined && rested.dir !== live.dir) |
| 1395 | 1395 | rmSync(rested.dir, { recursive: true, force: true }); |
| @@ -1785,7 +1785,7 @@ export class AppsRuntime { | ||
| 1785 | 1785 | touch(record: AppRecord, env: EnvName): void { |
| 1786 | 1786 | const state = this.envs.get(key(record.id, env)); |
| 1787 | 1787 | if (state === undefined) return; |
| 1788 | state.lastRequestAt = Date.now(); | |
| 1788 | state.lastRequestAt = this.now(); | |
| 1789 | 1789 | state.keptAwake = undefined; |
| 1790 | 1790 | } |
| 1791 | 1791 | |
| @@ -1802,7 +1802,7 @@ export class AppsRuntime { | ||
| 1802 | 1802 | if (!held) return; |
| 1803 | 1803 | held = false; |
| 1804 | 1804 | state.openStreams--; |
| 1805 | state.lastRequestAt = Date.now(); | |
| 1805 | state.lastRequestAt = this.now(); | |
| 1806 | 1806 | }; |
| 1807 | 1807 | } |
| 1808 | 1808 | |
| @@ -1820,7 +1820,7 @@ export class AppsRuntime { | ||
| 1820 | 1820 | if (!held) return; |
| 1821 | 1821 | held = false; |
| 1822 | 1822 | state.openRequests--; |
| 1823 | state.lastRequestAt = Date.now(); | |
| 1823 | state.lastRequestAt = this.now(); | |
| 1824 | 1824 | }; |
| 1825 | 1825 | } |
| 1826 | 1826 | |
| @@ -1996,7 +1996,7 @@ export class AppsRuntime { | ||
| 1996 | 1996 | state.asleep = undefined; |
| 1997 | 1997 | state.live = live; |
| 1998 | 1998 | this.awake(state, now); |
| 1999 | state.lastRequestAt = Date.now(); | |
| 1999 | state.lastRequestAt = this.now(); | |
| 2000 | 2000 | this.watch(state, repo, live); |
| 2001 | 2001 | write("awake"); |
| 2002 | 2002 | })().finally(() => { |
| @@ -2174,13 +2174,21 @@ export class AppsRuntime { | ||
| 2174 | 2174 | : { kind: "slider", position: getEconomics(this.app.db).position }; |
| 2175 | 2175 | } |
| 2176 | 2176 | |
| 2177 | /** | |
| 2178 | * The sweep's clock. Requests stamp `lastRequestAt` with it too, so a | |
| 2179 | * window measured by the sweep starts again at a request on the same clock. | |
| 2180 | */ | |
| 2181 | private now(): number { | |
| 2182 | return this.options.clock?.() ?? Date.now(); | |
| 2183 | } | |
| 2184 | ||
| 2177 | 2185 | /** Stop what nobody has asked for lately, and keep it ready to wake. */ |
| 2178 | 2186 | private async sweep(): Promise<void> { |
| 2179 | 2187 | if (this.stopped) return; |
| 2180 | 2188 | await this.hibernate(); |
| 2181 | 2189 | const source = this.windowSource(); |
| 2182 | 2190 | const grace = this.options.busyGraceMs ?? BUSY_GRACE_MS; |
| 2183 | const now = this.options.clock?.() ?? Date.now(); | |
| 2191 | const now = this.now(); | |
| 2184 | 2192 | this.metered(() => beatAwake(this.app.db, new Date(now).toISOString())); |
| 2185 | 2193 | for (const state of this.envs.values()) { |
| 2186 | 2194 | const live = state.live; |
src/contract/probes.ts +12 −4
| @@ -21,7 +21,7 @@ import { openIssue, setIssueState } from "../actions/issues.ts"; | ||
| 21 | 21 | import { id as newId } from "../kernel/id.ts"; |
| 22 | 22 | import { createLog } from "../kernel/log.ts"; |
| 23 | 23 | import { audit } from "./audit.ts"; |
| 24 | import { probe, type Knock, type Probed } from "./probe.ts"; | |
| 24 | import { probe, type Knock, type ProbeInput, type Probed } from "./probe.ts"; | |
| 25 | 25 | import { during } from "../perf/jobs.ts"; |
| 26 | 26 | |
| 27 | 27 | const log = createLog("probes"); |
| @@ -113,6 +113,9 @@ export function probeIssue(app: App, repo: Repo) { | ||
| 113 | 113 | ); |
| 114 | 114 | } |
| 115 | 115 | |
| 116 | /** What asks the doors: the real probe, unless a test hands in its own verdicts. */ | |
| 117 | export type Prober = (input: ProbeInput) => Promise<Probed | undefined>; | |
| 118 | ||
| 116 | 119 | /** |
| 117 | 120 | * Knock on one app now, keep the row, and open or close its issue. Returns |
| 118 | 121 | * undefined for an app with no sound contract: there is nothing to compare |
| @@ -122,11 +125,12 @@ export async function probeApp( | ||
| 122 | 125 | app: App, |
| 123 | 126 | runtime: AppsRuntime, |
| 124 | 127 | record: AppRecord, |
| 128 | knock: Prober = probe, | |
| 125 | 129 | ): Promise<ProbeRow | undefined> { |
| 126 | 130 | const repo = getRepoById(app.db, record.repoId); |
| 127 | 131 | if (repo === undefined) return undefined; |
| 128 | 132 | const audited = await audit(app.reposRoot, repo); |
| 129 | const probed = await probe({ | |
| 133 | const probed = await knock({ | |
| 130 | 134 | runtime, |
| 131 | 135 | dataDir: app.config.dataDir, |
| 132 | 136 | record, |
| @@ -193,7 +197,11 @@ export function untilNightly(now = Date.now()): number { | ||
| 193 | 197 | export function startProbes( |
| 194 | 198 | app: App, |
| 195 | 199 | runtime: AppsRuntime, |
| 196 | options: { readonly nightly?: boolean } = {}, | |
| 200 | options: { | |
| 201 | readonly nightly?: boolean; | |
| 202 | /** The seam: a test's prober answers in place of the running app's doors. */ | |
| 203 | readonly knock?: Prober; | |
| 204 | } = {}, | |
| 197 | 205 | ): ProbesHandle { |
| 198 | 206 | let stopped = false; |
| 199 | 207 | const inFlight = new Set<Promise<unknown>>(); |
| @@ -205,7 +213,7 @@ export function startProbes( | ||
| 205 | 213 | }; |
| 206 | 214 | const one = (record: AppRecord): Promise<ProbeRow | undefined> => |
| 207 | 215 | track( |
| 208 | probeApp(app, runtime, record).catch((error) => { | |
| 216 | probeApp(app, runtime, record, options.knock).catch((error) => { | |
| 209 | 217 | log.error("probe failed", { app: record.slug, error: String(error) }); |
| 210 | 218 | return undefined; |
| 211 | 219 | }), |
test/apps-busy.test.ts +12 −3
| @@ -157,8 +157,12 @@ describe("apps that are busy", () => { | ||
| 157 | 157 | /** What the run log gained after `before`. */ |
| 158 | 158 | const since = (before: string): string => log().slice(before.length); |
| 159 | 159 | |
| 160 | /** Put production to sleep and wake it, so each test starts rested. */ | |
| 160 | /** | |
| 161 | * Put production to sleep and wake it, so each test starts rested. The | |
| 162 | * clock comes back to the wall first: requests are stamped with it too. | |
| 163 | */ | |
| 161 | 164 | async function freshlyAwake(): Promise<void> { |
| 165 | ahead = 0; | |
| 162 | 166 | if (production().state !== "asleep") { |
| 163 | 167 | await viaHost("/done"); |
| 164 | 168 | await viaHost("/answer"); |
| @@ -268,8 +272,9 @@ describe("apps that are busy", () => { | ||
| 268 | 272 | expect(now).toContain("its child processes are still working"); |
| 269 | 273 | |
| 270 | 274 | // The child finishes; the next sweep finds nothing to wait for. |
| 275 | // Asking for /done was a request at this hour: a window past it, it sleeps. | |
| 271 | 276 | expect(await (await viaHost("/done")).text()).toBe("done"); |
| 272 | await sweepAt(WINDOW + 60_000); | |
| 277 | await sweepAt(2 * WINDOW + 2 * 60_000); | |
| 273 | 278 | await asleep(); |
| 274 | 279 | expect(production().busy).toBeUndefined(); |
| 275 | 280 | }, |
| @@ -286,7 +291,8 @@ describe("apps that are busy", () => { | ||
| 286 | 291 | expect(production().busy).toContain("a request is still waiting"); |
| 287 | 292 | await viaHost("/answer"); |
| 288 | 293 | expect(await (await response).text()).toBe("late"); |
| 289 | await sweepAt(WINDOW + 60_000); | |
| 294 | // Answered at this hour; a window past the answer, nothing holds it. | |
| 295 | await sweepAt(2 * WINDOW + 2 * 60_000); | |
| 290 | 296 | await asleep(); |
| 291 | 297 | }); |
| 292 | 298 | |
| @@ -305,6 +311,9 @@ describe("apps that are busy", () => { | ||
| 305 | 311 | |
| 306 | 312 | abort.abort(); |
| 307 | 313 | await reader.cancel().catch(() => {}); |
| 314 | // Closed at this hour; a window past the close, nothing holds it. | |
| 315 | await until(() => (production().busy === undefined ? true : undefined), 10_000); | |
| 316 | await sweepAt(2 * WINDOW + 2 * 60_000); | |
| 308 | 317 | await asleep(); |
| 309 | 318 | }); |
| 310 | 319 |
test/apps-sleep.test.ts +59 −9
| @@ -28,8 +28,16 @@ describe("apps that sleep", () => { | ||
| 28 | 28 | let record: AppRecord; |
| 29 | 29 | let preview = ""; |
| 30 | 30 | const DOMAIN = "apps.test"; |
| 31 | const SOON = { production: 700, preview: 700 }; | |
| 32 | const LATER = { production: 3_600_000, preview: 3_600_000 }; | |
| 31 | const HOUR = 3_600_000; | |
| 32 | const WINDOW = { production: HOUR, preview: HOUR }; | |
| 33 | /** | |
| 34 | * The sweep's clock runs this far ahead of the wall: the test puts an | |
| 35 | * environment to sleep by moving it, never by waiting out a window. Past | |
| 36 | * the window and the busy grace both, so a process that looks busy | |
| 37 | * sleeps on the next sweep too. | |
| 38 | */ | |
| 39 | let ahead = 0; | |
| 40 | const clock = () => Date.now() + ahead; | |
| 33 | 41 | |
| 34 | 42 | const viaHost = (host: string, path = "/") => |
| 35 | 43 | fetch(`${servers.url}${path}`, { headers: { host }, redirect: "manual" }); |
| @@ -64,7 +72,8 @@ describe("apps that sleep", () => { | ||
| 64 | 72 | scheme: "http", |
| 65 | 73 | publicPort: servers.port, |
| 66 | 74 | forgeUrl: servers.url, |
| 67 | sleepAfterMs: SOON, | |
| 75 | sleepAfterMs: WINDOW, | |
| 76 | clock, | |
| 68 | 77 | sweepEveryMs: 150, |
| 69 | 78 | }); |
| 70 | 79 | await runtime.start(); |
| @@ -97,7 +106,15 @@ describe("apps that sleep", () => { | ||
| 97 | 106 | }); |
| 98 | 107 | |
| 99 | 108 | test("an environment nobody asks for sleeps, and the next request wakes it", async () => { |
| 100 | await until(() => (stateOf("production") === "asleep" ? true : undefined)); | |
| 109 | ahead = 3 * HOUR; | |
| 110 | try { | |
| 111 | await until( | |
| 112 | () => (stateOf("production") === "asleep" ? true : undefined), | |
| 113 | 5_000, | |
| 114 | ); | |
| 115 | } finally { | |
| 116 | ahead = 0; | |
| 117 | } | |
| 101 | 118 | const asleep = runtime.status(record).find((e) => e.env === "production")!; |
| 102 | 119 | expect(asleep.port).toBeUndefined(); |
| 103 | 120 | expect(asleep.since).toBeDefined(); |
| @@ -109,14 +126,46 @@ describe("apps that sleep", () => { | ||
| 109 | 126 | const runId = runtime |
| 110 | 127 | .status(record) |
| 111 | 128 | .find((e) => e.env === "production")!.runId!; |
| 129 | // Nothing was busy: the idle path put it to sleep, not the busy grace. | |
| 112 | 130 | expect(log(runId)).toContain("asleep: nothing asked for it"); |
| 113 | 131 | expect(log(runId)).toContain("awake"); |
| 114 | // Wanted again, it stays up past the limit. | |
| 115 | await Bun.sleep(400); | |
| 132 | }, 10_000); | |
| 133 | ||
| 134 | test("a request inside the window starts it again; past it with none, it sleeps", async () => { | |
| 135 | const MINUTE = 60_000; | |
| 136 | // Waiting for a few sweeps to run at the clock as it stands now. | |
| 137 | const sweeps = () => Bun.sleep(600); | |
| 138 | const slept = () => | |
| 139 | log( | |
| 140 | runtime.status(record).find((e) => e.env === "production")!.runId!, | |
| 141 | ).match(/asleep: nothing asked for it/g)?.length ?? 0; | |
| 142 | const before = slept(); | |
| 143 | try { | |
| 144 | expect(stateOf("production")).toBe("running"); | |
| 145 | // Woken just now; the window is nearly out. | |
| 146 | ahead = HOUR - 5 * MINUTE; | |
| 147 | await sweeps(); | |
| 148 | expect(stateOf("production")).toBe("running"); | |
| 149 | // A request inside the window resets the idle timer... | |
| 150 | expect((await viaHost(`hello.${DOMAIN}`)).status).toBe(200); | |
| 151 | // ...so past the window from the wake, but not from the request, it stays up. | |
| 152 | ahead = HOUR + 20 * MINUTE; | |
| 153 | await sweeps(); | |
| 154 | expect(stateOf("production")).toBe("running"); | |
| 155 | // Past the window from the request, with nothing asked since: it sleeps. | |
| 156 | ahead = 2 * HOUR; | |
| 157 | await until( | |
| 158 | () => (stateOf("production") === "asleep" ? true : undefined), | |
| 159 | 5_000, | |
| 160 | ); | |
| 161 | } finally { | |
| 162 | ahead = 0; | |
| 163 | } | |
| 164 | expect(slept()).toBe(before + 1); | |
| 165 | // The next request wakes it again, for the test after this one. | |
| 116 | 166 | expect((await viaHost(`hello.${DOMAIN}`)).status).toBe(200); |
| 117 | await Bun.sleep(400); | |
| 118 | 167 | expect(stateOf("production")).toBe("running"); |
| 119 | }, 30_000); | |
| 168 | }, 15_000); | |
| 120 | 169 | |
| 121 | 170 | test("the forge coming back starts production warm and leaves previews asleep", async () => { |
| 122 | 171 | const branch = await commitToBranch(app, alice, repo, { |
| @@ -145,7 +194,8 @@ describe("apps that sleep", () => { | ||
| 145 | 194 | scheme: "http", |
| 146 | 195 | publicPort: servers.port, |
| 147 | 196 | forgeUrl: servers.url, |
| 148 | sleepAfterMs: LATER, | |
| 197 | sleepAfterMs: WINDOW, | |
| 198 | clock, | |
| 149 | 199 | sweepEveryMs: 150, |
| 150 | 200 | }); |
| 151 | 201 | await runtime.start(); |
test/probe.test.ts +15 −10
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | * Is it so: the probe knocks on a scaffolded app's doors as nobody, a user |
| 3 | 3 | * and an admin, and every answer agrees with the table. Then a door is |
| 4 | 4 | * broken by hand in the running app's source — a rule that lets anyone |
| 5 | * delete — and the probe says exactly which cell the app disobeys. | |
| 5 | * delete — and the probe says exactly which cell the app disobeys. Each | |
| 6 | * test probes an app of its own, so neither verdict hangs on which ran first. | |
| 6 | 7 | */ |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -36,7 +37,7 @@ describe("the probe asks the running app whether it obeys its table", () => { | ||
| 36 | 37 | } |
| 37 | 38 | }; |
| 38 | 39 | /** The newest production deploy, once it has settled — and that it is ok. */ |
| 39 | const deployed = async (after: string): Promise<string> => { | |
| 40 | const deployed = async (repo: Repo, after: string): Promise<string> => { | |
| 40 | 41 | let run: ReturnType<typeof listRuns>[number] | undefined; |
| 41 | 42 | await until(() => { |
| 42 | 43 | run = listRuns(app.db, repo.id, { kind: "deploy", limit: 50 }).find( |
| @@ -65,7 +66,7 @@ describe("the probe asks the running app whether it obeys its table", () => { | ||
| 65 | 66 | }, 30_000); |
| 66 | 67 | return run.commitOid; |
| 67 | 68 | }; |
| 68 | const probeNow = async (): Promise<Probed> => { | |
| 69 | const probeNow = async (repo: Repo): Promise<Probed> => { | |
| 69 | 70 | const record = getAppByRepo(app.db, repo.id)!; |
| 70 | 71 | const fresh = getRepoById(app.db, repo.id)!; |
| 71 | 72 | const audited = await audit(app.reposRoot, fresh); |
| @@ -107,7 +108,7 @@ describe("the probe asks the running app whether it obeys its table", () => { | ||
| 107 | 108 | }); |
| 108 | 109 | if (!made.ok) throw new Error(String(made.error)); |
| 109 | 110 | repo = made.value.repo; |
| 110 | await deployed(""); | |
| 111 | await deployed(repo, ""); | |
| 111 | 112 | }, 150_000); |
| 112 | 113 | |
| 113 | 114 | afterAll(async () => { |
| @@ -118,7 +119,7 @@ describe("the probe asks the running app whether it obeys its table", () => { | ||
| 118 | 119 | }); |
| 119 | 120 | |
| 120 | 121 | test("a scaffolded app obeys every cell the probe can ask", async () => { |
| 121 | const out = await probeNow(); | |
| 122 | const out = await probeNow(repo); | |
| 122 | 123 | expect(out.verdict).toBe("ok"); |
| 123 | 124 | expect(out.said).toEqual([]); |
| 124 | 125 | const asked = out.knocks.filter((k) => k.verdict !== "unprobed"); |
| @@ -142,8 +143,12 @@ describe("the probe asks the running app whether it obeys its table", () => { | ||
| 142 | 143 | // still says a stranger may not. The app's own tests would catch this |
| 143 | 144 | // before it ever ran — the deploy runs them first — so the change also |
| 144 | 145 | // drops them, which is what a change that gets a door wrong looks like. |
| 146 | const made = await newApp(app, alice, { name: "doors", visibility: "public" }); | |
| 147 | if (!made.ok) throw new Error(String(made.error)); | |
| 148 | const doors = made.value.repo; | |
| 149 | await deployed(doors, ""); | |
| 145 | 150 | const files = new Map( |
| 146 | scaffoldFiles({ name: "notes", description: "" }).map((f) => [ | |
| 151 | scaffoldFiles({ name: "doors", description: "" }).map((f) => [ | |
| 147 | 152 | f.path, |
| 148 | 153 | f.content, |
| 149 | 154 | ]), |
| @@ -164,7 +169,7 @@ describe("the probe asks the running app whether it obeys its table", () => { | ||
| 164 | 169 | expect(opened).not.toBe(door); |
| 165 | 170 | const since = new Date().toISOString(); |
| 166 | 171 | await Bun.sleep(5); |
| 167 | const wrote = await commitToBranch(app, alice, repo, { | |
| 172 | const wrote = await commitToBranch(app, alice, doors, { | |
| 168 | 173 | message: "a hand-written rule, and the tests that knew better", |
| 169 | 174 | files: [ |
| 170 | 175 | { path: "src/actions.ts", content: broken }, |
| @@ -176,8 +181,8 @@ describe("the probe asks the running app whether it obeys its table", () => { | ||
| 176 | 181 | ], |
| 177 | 182 | }); |
| 178 | 183 | expect(wrote.ok).toBe(true); |
| 179 | await deployed(since); | |
| 180 | const out = await probeNow(); | |
| 184 | await deployed(doors, since); | |
| 185 | const out = await probeNow(doors); | |
| 181 | 186 | expect(out.verdict).toBe("mismatch"); |
| 182 | 187 | // Past the door, the stranger was told there is no such row: 404 is |
| 183 | 188 | // what "admitted" looks like on an id no row has. |
| @@ -188,5 +193,5 @@ describe("the probe asks the running app whether it obeys its table", () => { | ||
| 188 | 193 | expect( |
| 189 | 194 | out.knocks.filter((k) => k.verdict === "mismatch").map((k) => k.verb), |
| 190 | 195 | ).toEqual(["delete"]); |
| 191 | }, 120_000); | |
| 196 | }, 180_000); | |
| 192 | 197 | }); |
test/probes.test.ts +68 −103
| @@ -2,6 +2,11 @@ | ||
| 2 | 2 | * The probe as a service: a production deploy is followed by a knock, a |
| 3 | 3 | * mismatch opens one issue and keeps it single, and the first clean probe |
| 4 | 4 | * after closes it. The nightly round is the same knock for every app. |
| 5 | * | |
| 6 | * The doors are asked by a scripted prober (`startProbes`' `knock` seam): | |
| 7 | * what the running app answers is `probe.test.ts`'s business; here the | |
| 8 | * verdict is whatever the test says, so it is the same every run and in | |
| 9 | * every order. Each test owns its own app. | |
| 5 | 10 | */ |
| 6 | 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 7 | 12 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -9,9 +14,8 @@ import { tmpdir } from "node:os"; | ||
| 9 | 14 | import { join } from "node:path"; |
| 10 | 15 | import { createApp, type App } from "../src/app.ts"; |
| 11 | 16 | import { newApp } from "../src/actions/apps.ts"; |
| 12 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 13 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 14 | import { scaffoldFiles } from "../src/apps/scaffold.ts"; | |
| 17 | import type { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 18 | import type { Probed } from "../src/contract/probe.ts"; | |
| 15 | 19 | import { |
| 16 | 20 | PROBE_ISSUE_TITLE, |
| 17 | 21 | latestProbes, |
| @@ -23,50 +27,60 @@ import { | ||
| 23 | 27 | } from "../src/contract/probes.ts"; |
| 24 | 28 | import type { AppRecord } from "../src/core/apps.ts"; |
| 25 | 29 | import { listIssues } from "../src/core/issues.ts"; |
| 26 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 27 | import { listRuns } from "../src/core/runs.ts"; | |
| 30 | import type { Repo } from "../src/core/repos.ts"; | |
| 28 | 31 | import { createUser, type User } from "../src/core/users.ts"; |
| 29 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 32 | ||
| 33 | const SAID = "note.delete as anon: the table says no, the app said 404"; | |
| 30 | 34 | |
| 31 | 35 | describe("the probe follows deploys and keeps one issue", () => { |
| 32 | 36 | let dataDir: string; |
| 33 | 37 | let app: App; |
| 34 | let servers: Servers; | |
| 35 | let runtime: AppsRuntime; | |
| 36 | 38 | let probes: ProbesHandle; |
| 37 | 39 | let alice: User; |
| 38 | let repo: Repo; | |
| 39 | let record: AppRecord; | |
| 40 | /** What the doors of each app say, by app id: the test sets it. */ | |
| 41 | const answers = new Map<string, "ok" | "mismatch">(); | |
| 40 | 42 | |
| 41 | const until = async (what: () => boolean, ms = 90_000): Promise<void> => { | |
| 43 | const until = async (what: () => boolean, ms = 10_000): Promise<void> => { | |
| 42 | 44 | const start = Date.now(); |
| 43 | 45 | while (!what()) { |
| 44 | 46 | if (Date.now() - start > ms) throw new Error("not in time"); |
| 45 | await Bun.sleep(100); | |
| 47 | await Bun.sleep(50); | |
| 46 | 48 | } |
| 47 | 49 | }; |
| 48 | const deployedAfter = (since: string) => | |
| 49 | until(() => { | |
| 50 | const run = listRuns(app.db, repo.id, { kind: "deploy", limit: 20 }).find( | |
| 51 | (r) => r.env === "production" && r.createdAt > since, | |
| 52 | ); | |
| 53 | return run !== undefined && run.state === "ok"; | |
| 50 | const made = async (name: string): Promise<{ repo: Repo; record: AppRecord }> => { | |
| 51 | const out = await newApp(app, alice, { name, visibility: "public" }); | |
| 52 | if (!out.ok) throw new Error(String(out.error)); | |
| 53 | return { repo: out.value.repo, record: out.value.app }; | |
| 54 | }; | |
| 55 | /** A production deploy finished green, as the runtime says it. */ | |
| 56 | const deployed = (repo: Repo): void => { | |
| 57 | app.bus.append({ | |
| 58 | kind: "run.finished", | |
| 59 | repoId: repo.id, | |
| 60 | payload: { kind: "deploy", state: "ok", env: "production" }, | |
| 54 | 61 | }); |
| 55 | const probesSoFar = () => listProbes(app.db, record.id).length; | |
| 62 | }; | |
| 63 | const probesOf = (record: AppRecord) => listProbes(app.db, record.id); | |
| 56 | 64 | |
| 57 | 65 | beforeAll(async () => { |
| 58 | 66 | dataDir = await mkdtemp(join(tmpdir(), "forge-probes-")); |
| 59 | 67 | app = createApp({ dataDir, port: 0 }); |
| 60 | servers = await serve(app); | |
| 61 | runtime = new AppsRuntime(app, { | |
| 62 | domain: "apps.test", | |
| 63 | scheme: "http", | |
| 64 | publicPort: servers.port, | |
| 65 | forgeUrl: servers.url, | |
| 66 | concurrency: 1, | |
| 68 | // No app ever runs: the prober below answers for its doors. | |
| 69 | const runtime = {} as AppsRuntime; | |
| 70 | probes = startProbes(app, runtime, { | |
| 71 | nightly: false, | |
| 72 | knock: async (input): Promise<Probed> => { | |
| 73 | const verdict = answers.get(input.record.id) ?? "ok"; | |
| 74 | return { | |
| 75 | repo: input.repo, | |
| 76 | commitOid: input.repo.id, | |
| 77 | at: new Date().toISOString(), | |
| 78 | verdict, | |
| 79 | knocks: [], | |
| 80 | said: verdict === "mismatch" ? [SAID] : [], | |
| 81 | }; | |
| 82 | }, | |
| 67 | 83 | }); |
| 68 | await runtime.start(); | |
| 69 | probes = startProbes(app, runtime, { nightly: false }); | |
| 70 | 84 | const a = await createUser(app.db, { origin: "seed", trusted: true, |
| 71 | 85 | username: "alice", |
| 72 | 86 | email: "[email protected]", |
| @@ -75,103 +89,60 @@ describe("the probe follows deploys and keeps one issue", () => { | ||
| 75 | 89 | }); |
| 76 | 90 | if (!a.ok) throw new Error(a.error); |
| 77 | 91 | alice = a.value; |
| 78 | const made = await newApp(app, alice, { name: "notes", visibility: "public" }); | |
| 79 | if (!made.ok) throw new Error(String(made.error)); | |
| 80 | repo = made.value.repo; | |
| 81 | record = made.value.app; | |
| 82 | await deployedAfter(""); | |
| 83 | }, 150_000); | |
| 92 | }, 30_000); | |
| 84 | 93 | |
| 85 | 94 | afterAll(async () => { |
| 86 | 95 | await probes.stop(); |
| 87 | await runtime.stop(); | |
| 88 | servers.stop(); | |
| 89 | 96 | app.close(); |
| 90 | 97 | await rm(dataDir, { recursive: true, force: true }); |
| 91 | 98 | }); |
| 92 | 99 | |
| 93 | 100 | test("a production deploy is followed by a knock, and a clean app has no issue", async () => { |
| 94 | await until(() => probesSoFar() >= 1, 30_000); | |
| 101 | const { repo, record } = await made("clean"); | |
| 102 | answers.set(record.id, "ok"); | |
| 103 | deployed(repo); | |
| 104 | await until(() => probesOf(record).length >= 1); | |
| 95 | 105 | const latest = latestProbes(app.db, [record.id]).get(record.id)!; |
| 96 | 106 | expect(latest.verdict).toBe("ok"); |
| 97 | 107 | expect(latest.said).toEqual([]); |
| 98 | 108 | expect(probeIssue(app, repo)).toBeUndefined(); |
| 99 | }, 60_000); | |
| 109 | }, 15_000); | |
| 100 | 110 | |
| 101 | 111 | test("a disobeying deploy opens one issue; a second probe does not double it", async () => { |
| 102 | const files = new Map( | |
| 103 | scaffoldFiles({ name: "notes", description: "" }).map((f) => [f.path, f.content]), | |
| 104 | ); | |
| 105 | const actions = files.get("src/actions.ts") as string; | |
| 106 | const broken = actions.replace( | |
| 107 | 'if (permit(who, "note", verb, row) !== "yes")', | |
| 108 | 'if (verb !== "delete" && permit(who, "note", verb, row) !== "yes")', | |
| 109 | ); | |
| 110 | expect(broken).not.toBe(actions); | |
| 111 | // The door asks the same table before the action does, so the rule is | |
| 112 | // written there too: a hand-written rule has to open both to be heard. | |
| 113 | const door = files.get("src/security.ts") as string; | |
| 114 | const opened = door.replace( | |
| 115 | ": !mayKnock(who, found.resource, found.verb);", | |
| 116 | ': found.verb !== "delete" && !mayKnock(who, found.resource, found.verb);', | |
| 117 | ); | |
| 118 | expect(opened).not.toBe(door); | |
| 119 | const before = probesSoFar(); | |
| 120 | const since = new Date().toISOString(); | |
| 121 | await Bun.sleep(5); | |
| 122 | const wrote = await commitToBranch(app, alice, repo, { | |
| 123 | message: "a hand-written rule, and the tests that knew better", | |
| 124 | files: [ | |
| 125 | { path: "src/actions.ts", content: broken }, | |
| 126 | { path: "src/security.ts", content: opened }, | |
| 127 | { path: "src/contract.test.ts", content: null }, | |
| 128 | { path: "src/actions.test.ts", content: null }, | |
| 129 | { path: "src/server.test.ts", content: null }, | |
| 130 | { path: "src/audit.test.ts", content: null }, | |
| 131 | ], | |
| 132 | }); | |
| 133 | expect(wrote.ok).toBe(true); | |
| 134 | await deployedAfter(since); | |
| 135 | await until(() => probesSoFar() > before, 30_000); | |
| 112 | const { repo, record } = await made("broken"); | |
| 113 | answers.set(record.id, "mismatch"); | |
| 114 | deployed(repo); | |
| 115 | await until(() => probesOf(record).length >= 1); | |
| 136 | 116 | const latest = latestProbes(app.db, [record.id]).get(record.id)!; |
| 137 | 117 | expect(latest.verdict).toBe("mismatch"); |
| 138 | 118 | const issue = probeIssue(app, repo); |
| 139 | 119 | expect(issue).toBeDefined(); |
| 140 | 120 | expect(issue!.title).toBe(PROBE_ISSUE_TITLE); |
| 141 | expect(issue!.body).toContain("note.delete as anon: the table says no, the app said 404"); | |
| 121 | expect(issue!.body).toContain(SAID); | |
| 142 | 122 | expect(issue!.authorId).toBe(alice.id); |
| 143 | 123 | |
| 144 | 124 | // A round knocks again: still one issue. |
| 145 | 125 | const rows = await probes.round(); |
| 146 | expect(rows.map((r) => r.verdict)).toEqual(["mismatch"]); | |
| 126 | expect(rows.filter((r) => r.appId === record.id).map((r) => r.verdict)).toEqual([ | |
| 127 | "mismatch", | |
| 128 | ]); | |
| 147 | 129 | expect( |
| 148 | 130 | listIssues(app.db, repo.id, { state: "open", limit: 50 }).filter( |
| 149 | 131 | (i) => i.title === PROBE_ISSUE_TITLE, |
| 150 | 132 | ), |
| 151 | 133 | ).toHaveLength(1); |
| 152 | }, 150_000); | |
| 134 | }, 15_000); | |
| 153 | 135 | |
| 154 | 136 | test("the first clean probe after closes the issue", async () => { |
| 155 | const files = new Map( | |
| 156 | scaffoldFiles({ name: "notes", description: "" }).map((f) => [f.path, f.content]), | |
| 157 | ); | |
| 158 | const before = probesSoFar(); | |
| 159 | const since = new Date().toISOString(); | |
| 160 | await Bun.sleep(5); | |
| 161 | const wrote = await commitToBranch(app, alice, repo, { | |
| 162 | message: "the door obeys again", | |
| 163 | files: [ | |
| 164 | { path: "src/actions.ts", content: files.get("src/actions.ts") as string }, | |
| 165 | { path: "src/security.ts", content: files.get("src/security.ts") as string }, | |
| 166 | { path: "src/contract.test.ts", content: files.get("src/contract.test.ts") as string }, | |
| 167 | { path: "src/actions.test.ts", content: files.get("src/actions.test.ts") as string }, | |
| 168 | { path: "src/server.test.ts", content: files.get("src/server.test.ts") as string }, | |
| 169 | { path: "src/audit.test.ts", content: files.get("src/audit.test.ts") as string }, | |
| 170 | ], | |
| 171 | }); | |
| 172 | expect(wrote.ok).toBe(true); | |
| 173 | await deployedAfter(since); | |
| 174 | await until(() => probesSoFar() > before, 30_000); | |
| 137 | const { repo, record } = await made("mended"); | |
| 138 | answers.set(record.id, "mismatch"); | |
| 139 | await probes.round(); | |
| 140 | expect(probeIssue(app, repo)).toBeDefined(); | |
| 141 | ||
| 142 | // The door obeys again, and the deploy that says so is knocked on. | |
| 143 | answers.set(record.id, "ok"); | |
| 144 | deployed(repo); | |
| 145 | await until(() => probesOf(record).length >= 2); | |
| 175 | 146 | expect(latestProbes(app.db, [record.id]).get(record.id)!.verdict).toBe("ok"); |
| 176 | 147 | expect(probeIssue(app, repo)).toBeUndefined(); |
| 177 | 148 | const closed = listIssues(app.db, repo.id, { state: "closed", limit: 50 }).find( |
| @@ -179,14 +150,8 @@ describe("the probe follows deploys and keeps one issue", () => { | ||
| 179 | 150 | ); |
| 180 | 151 | expect(closed).toBeDefined(); |
| 181 | 152 | // The history is rows, newest first. |
| 182 | expect(listProbes(app.db, record.id).map((r) => r.verdict)).toEqual([ | |
| 183 | "ok", | |
| 184 | "mismatch", | |
| 185 | "mismatch", | |
| 186 | "ok", | |
| 187 | ]); | |
| 188 | expect(getRepoById(app.db, repo.id)).toBeDefined(); | |
| 189 | }, 150_000); | |
| 153 | expect(probesOf(record).map((r) => r.verdict)).toEqual(["ok", "mismatch"]); | |
| 154 | }, 15_000); | |
| 190 | 155 | |
| 191 | 156 | test("the nightly round is scheduled for the next 09:00 UTC", () => { |
| 192 | 157 | const at = Date.UTC(2026, 8, 21, 8, 59, 0); |
This no longer proves that a request inside the window resets the idle timer. Sweeps simply do not put a woken env back to sleep, which is a weaker claim. Restore the reset check by advancing
aheadin steps around a request, as described in the body. Also the log regex now accepts either wording, so it cannot tell which sleep path ran.