@broberg/lens-daemon 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/browser.ts ADDED
@@ -0,0 +1,440 @@
1
+ // F074.1 — Lens browser manager.
2
+ //
3
+ // Owns a single WARM headless Chromium for the daemon. The browser is
4
+ // launched lazily on first capture and reused across captures (launch is
5
+ // ~300ms; reusing it makes a capture-catalogue batch fast). An idle timer
6
+ // closes it after ~5 min of inactivity so a quiet daemon doesn't hold a
7
+ // Chromium process forever.
8
+ //
9
+ // Each capture gets its OWN browser context (isolated cookies/storage) so
10
+ // auth (F074.4) and concurrent captures never bleed into each other. The
11
+ // context + page are always closed in a finally, even on capture error.
12
+
13
+ import {
14
+ chromium,
15
+ webkit,
16
+ firefox,
17
+ type Browser,
18
+ type BrowserContext,
19
+ type BrowserType,
20
+ type Page,
21
+ } from 'playwright';
22
+ import { mkdtemp, rm } from 'node:fs/promises';
23
+ import { tmpdir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ import { READINESS_INIT_SCRIPT } from './readiness.ts';
26
+ import { applyStorageSeed, type StoragePayload } from './seed-storage.ts';
27
+
28
+ const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
29
+
30
+ // F098.9 — Lens can drive any of the three engines. Chromium is the default;
31
+ // webkit/firefox let Lens catch Safari-only / cross-engine bugs (backdrop-filter
32
+ // containing-block, -webkit quirks, 100vh/dvh, sticky) that chromium renders
33
+ // fine — the class of bug chromium-only Lens would falsely stamp GREEN.
34
+ export type LensEngine = 'chromium' | 'webkit' | 'firefox';
35
+ const ENGINES: Record<LensEngine, BrowserType> = { chromium, webkit, firefox };
36
+
37
+ // One warm browser PER engine, launched lazily + reused. Keyed by engine so a
38
+ // webkit capture doesn't relaunch chromium and vice-versa.
39
+ const browsers = new Map<LensEngine, Promise<Browser>>();
40
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
41
+
42
+ /**
43
+ * F346.46 — CHROMIUM GETS A SYNTHETIC MICROPHONE AND CAMERA.
44
+ *
45
+ * Without these, `getUserMedia` in a Lens run HANGS: headless Chromium has no
46
+ * capture device and no human to answer the permission prompt, so the promise
47
+ * never settles and the flow dies on a timeout that says nothing about why.
48
+ * Measured 16 Sep 2026 — an assert calling getUserMedia({audio:true}) timed the
49
+ * whole manuscript out.
50
+ *
51
+ * That made a whole class of feature UNTESTABLE by our own tool: anything that
52
+ * records. Scope's recorder is exactly that, and it shipped a defect (nothing
53
+ * was ever captured, and nothing said so) which an E2E run would have caught on
54
+ * the first try.
55
+ *
56
+ * --use-fake-device-for-media-stream a synthetic mic + camera, so tracks exist
57
+ * --use-fake-ui-for-media-stream auto-grant, so nothing waits on a human
58
+ * --autoplay-policy=no-user-gesture-required a captured stream can play back
59
+ *
60
+ * THE FAKE MIC EMITS A TONE, NOT SILENCE — Chromium's synthetic audio device is
61
+ * a beep. That matters for what a recording test can prove: the CHAIN (bytes
62
+ * produced, buffered, uploaded, stored) is real and fully exercised; the WORDS
63
+ * are not, because a tone transcribes to nothing. A run must therefore assert on
64
+ * chunk counts and bytes, never on transcript text, or it asserts something the
65
+ * fixture cannot deliver.
66
+ *
67
+ * Chromium only: webkit/firefox reject unknown args, and a rejected arg takes
68
+ * the whole launch down.
69
+ */
70
+ const CHROMIUM_ARGS = [
71
+ // Hardening for constrained, loopback-bound hosts.
72
+ '--disable-dev-shm-usage',
73
+ '--no-sandbox',
74
+ // Media capture, so a recording surface can actually be driven.
75
+ '--use-fake-device-for-media-stream',
76
+ '--use-fake-ui-for-media-stream',
77
+ '--autoplay-policy=no-user-gesture-required',
78
+ ];
79
+
80
+ async function launch(engine: LensEngine): Promise<Browser> {
81
+ const args = engine === 'chromium' ? CHROMIUM_ARGS : [];
82
+ return ENGINES[engine].launch({ headless: true, args });
83
+ }
84
+
85
+ /** Get the warm browser for an engine, launching it on first use. Re-launches
86
+ * if a previous instance crashed / disconnected. */
87
+ export async function getBrowser(engine: LensEngine = 'chromium'): Promise<Browser> {
88
+ let p = browsers.get(engine);
89
+ if (!p) {
90
+ p = launch(engine).catch((err) => {
91
+ browsers.delete(engine);
92
+ throw err;
93
+ });
94
+ browsers.set(engine, p);
95
+ }
96
+ const browser = await p;
97
+ if (!browser.isConnected()) {
98
+ browsers.delete(engine);
99
+ return getBrowser(engine);
100
+ }
101
+ return browser;
102
+ }
103
+
104
+ // Wedge watchdog (lens-gap, 2026-06-13 incident): a Chromium that is alive but
105
+ // unresponsive (isConnected() true, every CDP call hangs) used to hang EVERY
106
+ // withPage call until a daemon restart — the idle timer never fired because
107
+ // each retry re-armed it on entry. Guard the engine-touching awaits with a
108
+ // deadline; on timeout, discard the wedged browser and retry once fresh.
109
+ const WEDGE_TIMEOUT_MS = 20_000;
110
+
111
+ function withDeadline<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
112
+ return new Promise<T>((resolve, reject) => {
113
+ const t = setTimeout(
114
+ () => reject(new Error(`lens browser wedged: ${what} exceeded ${ms}ms`)),
115
+ ms,
116
+ );
117
+ t.unref?.();
118
+ p.then(
119
+ (v) => {
120
+ clearTimeout(t);
121
+ resolve(v);
122
+ },
123
+ (e) => {
124
+ clearTimeout(t);
125
+ reject(e);
126
+ },
127
+ );
128
+ });
129
+ }
130
+
131
+ /** Drop the cached browser immediately and force-close it in the background
132
+ * (close on a wedged process may itself hang — never await it). */
133
+ function discardBrowser(engine: LensEngine): void {
134
+ const p = browsers.get(engine);
135
+ browsers.delete(engine);
136
+ if (p) void p.then((b) => b.close()).catch(() => {});
137
+ }
138
+
139
+ function armIdleTimer(): void {
140
+ if (idleTimer) clearTimeout(idleTimer);
141
+ idleTimer = setTimeout(() => {
142
+ void closeBrowser();
143
+ }, IDLE_TIMEOUT_MS);
144
+ // Don't keep the daemon process alive just for the idle timer.
145
+ idleTimer.unref?.();
146
+ }
147
+
148
+ /** Close the warm browser + cancel the idle timer. Called by the idle
149
+ * timeout and on daemon shutdown. Safe to call when nothing is open. */
150
+ export async function closeBrowser(): Promise<void> {
151
+ if (idleTimer) {
152
+ clearTimeout(idleTimer);
153
+ idleTimer = null;
154
+ }
155
+ const all = [...browsers.values()];
156
+ browsers.clear();
157
+ await Promise.all(
158
+ all.map(async (p) => {
159
+ try {
160
+ const browser = await p;
161
+ if (browser.isConnected()) await browser.close();
162
+ } catch {
163
+ // already gone — nothing to close
164
+ }
165
+ }),
166
+ );
167
+ }
168
+
169
+ export interface WithPageOptions {
170
+ /** F098.9 — which engine to drive (default chromium). */
171
+ engine?: LensEngine;
172
+ viewport?: { width: number; height: number };
173
+ deviceScaleFactor?: number;
174
+ /** F125.2 — disable the page's Content-Security-Policy so injected scripts
175
+ * (Readability via addScriptTag) run on CSP-strict sites (Medium etc.).
176
+ * Scrape-only; Lens captures leave it false. */
177
+ bypassCSP?: boolean;
178
+ /** F125.2 — override the browser User-Agent. Must match the UA the captured
179
+ * cf_clearance cookie was minted with, or Cloudflare re-challenges. */
180
+ userAgent?: string;
181
+ /** F074.57 — BCP-47 locale for the browser context, e.g. "da-DK". Decides
182
+ * navigator.language, Accept-Language, and every Intl/toLocaleString result.
183
+ * UNSET means unset: no key reaches Playwright, so existing baselines cannot
184
+ * move for anyone who has not asked for this. */
185
+ locale?: string;
186
+ /** F074.57 — IANA timezone, e.g. "Europe/Copenhagen". Measured 2026-08-23: with
187
+ * this unset the context inherits the HOST machine's zone, so the same page
188
+ * renders different dates on the Mac and on Cloud Lens. Same unset rule. */
189
+ timezone?: string;
190
+ /** F074.59 — what this context reports for `prefers-reduced-motion`.
191
+ *
192
+ * DEFAULT 'reduce', which is exactly what every run did before this option
193
+ * existed (see the comment on reducedMotion in contextOptions for WHY that
194
+ * default is right and must stay). 'no-preference' is the opt-in that lets a
195
+ * run SEE motion — the animation blind spot F074.59 exists to close.
196
+ *
197
+ * Unlike locale/timezone this is NOT spread-if-present: "unset" here has a
198
+ * concrete meaning ('reduce'), not "pass nothing", because passing nothing
199
+ * would hand the fleet a different browser than it has been photographing. */
200
+ reducedMotion?: 'reduce' | 'no-preference';
201
+ /** F125.2 — mask the navigator.webdriver automation flag so anti-bot walls
202
+ * (Cloudflare) treat the page like a real browser. Scrape-only. */
203
+ stealth?: boolean;
204
+ /** F074.4 hook: run against the fresh context BEFORE the page is created
205
+ * (e.g. inject auth cookies / storageState). */
206
+ prepareContext?: (context: BrowserContext) => Promise<void>;
207
+ /** F074.97 — localStorage/sessionStorage to write BEFORE the first navigation,
208
+ * so the app's own first render already sees it. Built by storagePayload()
209
+ * from the run's target url, which is where the origin guard comes from.
210
+ * NULL/absent registers NO init script at all — that is the negative control
211
+ * that keeps every existing baseline in the fleet byte-identical. */
212
+ seedStorage?: StoragePayload | null;
213
+ /** F144.19 — record the whole session to a .webm in this dir. Playwright
214
+ * writes the file when the context closes; read the path via page.video()?.path()
215
+ * AFTER withPage resolves. Used for live-liveness PROOF captures (an LSD that
216
+ * updates in real time can't be shown by a single screenshot). */
217
+ recordVideoDir?: string;
218
+ /** F074.69 — load an UNPACKED browser extension and drive its own pages.
219
+ *
220
+ * Without this, no extension in the fleet has any visual gate: an extension
221
+ * popup lives at chrome-extension://<id>/popup.html, and that id only exists
222
+ * in a browser launched WITH the extension. Serving dist/popup.html over http
223
+ * is not a substitute — the popup calls chrome.storage on mount, throws with
224
+ * no chrome API, and photographs blank or as a stub that LOOKS like the
225
+ * product. That screenshot on a store listing would be fabricated.
226
+ *
227
+ * This is a SECOND launch path, not a flag on the warm browser: extensions
228
+ * need launchPersistentContext with a real user-data dir on disk. */
229
+ extension?: { path: string };
230
+ }
231
+
232
+ /** Exported for test: F074.57's central promise to everyone who did NOT ask for
233
+ * a locale is that an unset one passes NOTHING to Playwright. That is a claim
234
+ * about this object, so it is asserted on this object. */
235
+ export function contextOptions(opts: WithPageOptions) {
236
+ return {
237
+ bypassCSP: opts.bypassCSP ?? false,
238
+ ...(opts.userAgent ? { userAgent: opts.userAgent } : {}),
239
+ // F074.57 — spread-if-present, never `locale: opts.locale ?? undefined`. An
240
+ // explicit `undefined` key is still a key, and the point of "unset" here is
241
+ // that Playwright receives NOTHING and every existing baseline stays valid.
242
+ ...(opts.locale ? { locale: opts.locale } : {}),
243
+ ...(opts.timezone ? { timezoneId: opts.timezone } : {}),
244
+ viewport: opts.viewport ?? { width: 1280, height: 800 },
245
+ // Default scale factor 1 keeps screenshots deterministic across machines
246
+ // (a retina 2x shot diffs noisily against a 1x baseline).
247
+ deviceScaleFactor: opts.deviceScaleFactor ?? 1,
248
+ // Lens captures localhost dev-servers, which commonly serve HTTPS with a
249
+ // self-signed / mkcert cert (cms-admin :3010 must be HTTPS). Trust them —
250
+ // this is a local dev tool, not a public crawler.
251
+ ignoreHTTPSErrors: true,
252
+ // F098.8 — emulate prefers-reduced-motion: reduce. Two wins: (1) an app that
253
+ // gates a perpetual requestAnimationFrame loop (trail's constellation canvas)
254
+ // on this media query stops animating, freeing the headless main thread so
255
+ // the initial render + auth microtask (/api/auth/me) resolve instead of
256
+ // starving past the wait timeout → blank capture; (2) motion-stable baselines
257
+ // (a mid-animation frame otherwise yields a false pixel-diff). Apps SHOULD
258
+ // honour prefers-reduced-motion anyway — this is correct a11y, not a hack.
259
+ // F074.59 — per-run, defaulting to the F098.8 behaviour above. A caller that
260
+ // asks for 'no-preference' gets a browser that can actually SEE an animation;
261
+ // everyone else gets the byte-identical context they had yesterday, which is
262
+ // the promise that lets this ship without touching a single baseline.
263
+ reducedMotion: opts.reducedMotion ?? ('reduce' as const),
264
+ // F126.11 — grant clipboard access so a "copy" button can be proven on its
265
+ // SUCCESS path, not only on its failure path.
266
+ //
267
+ // xrt81 (#21085) closed a card with this stated openly rather than ticked:
268
+ // pressing their copy button in the automated browser produced "Couldn't
269
+ // copy — select the address and copy it yourself". The message is good and
270
+ // the fallback works, but the green confirmation had never been seen. Every
271
+ // copy button in the fleet was in that position — verifiable only when it
272
+ // fails.
273
+ //
274
+ // That is the day's theme arriving from the opposite end. Everywhere else we
275
+ // found green without coverage; here the coverage was impossible, so the card
276
+ // had to say so. It should not have to: Chromium simply denies the permission
277
+ // by default and Playwright can grant it per context.
278
+ //
279
+ // Chromium-only — webkit and firefox reject unknown permission names and
280
+ // would fail every capture on those engines. Wrapped so an engine that does
281
+ // not know the name is unaffected rather than broken.
282
+ ...(opts.engine === undefined || opts.engine === 'chromium'
283
+ ? {
284
+ // F346.46 — MICROPHONE AND CAMERA GRANTED TOO. The launch flags give
285
+ // Chromium a synthetic device; this grants the PAGE permission to open
286
+ // it. Both halves are needed: without the flag there is no device and
287
+ // getUserMedia rejects; without the grant it waits for a human who is
288
+ // not there, and the flow dies on a timeout that names nothing.
289
+ permissions: ['clipboard-read', 'clipboard-write', 'microphone', 'camera'],
290
+ }
291
+ : {}),
292
+ // F144.19 — optional session video recording (live-liveness proof).
293
+ ...(opts.recordVideoDir
294
+ ? { recordVideo: { dir: opts.recordVideoDir, size: opts.viewport ?? { width: 1280, height: 800 } } }
295
+ : {}),
296
+ };
297
+ }
298
+
299
+ /** Run `fn` with a fresh isolated context + page, then tear both down.
300
+ * Resets the idle timer on entry so an active daemon keeps its browser warm. */
301
+ /** F074.69 — the id Chromium generated for the loaded extension, handed to the
302
+ * caller. It is derived at LOAD time, so it is read back from the extension's
303
+ * service-worker URL rather than guessed — a manuscript cannot hardcode it. */
304
+ export interface LoadedExtension {
305
+ id: string;
306
+ path: string;
307
+ }
308
+
309
+ /** F074.69 — MEASURED before the design depended on the answer (probe, 2026-08-29):
310
+ *
311
+ * bundled chromium, headless serviceWorker=false id=null NOT LOADED
312
+ * bundled chromium, --headless=new serviceWorker=false id=null NOT LOADED
313
+ * bundled chromium, headed serviceWorker=true id=cacc… works
314
+ * channel:'chromium', headless serviceWorker=true id=cacc… works
315
+ *
316
+ * So the answer to the plan's open question — "does it need a head?" — is NO,
317
+ * but only off the CHANNEL build. Playwright's bundled headless is the
318
+ * chromium-headless-shell, which has no extension support at all; the full
319
+ * Chromium binary does, in the new headless mode. That distinction is the whole
320
+ * reason this can run on a headless host (and therefore on Cloud Lens) instead
321
+ * of needing a screen. */
322
+ const EXTENSION_CHANNEL = 'chromium';
323
+
324
+ async function launchWithExtension(
325
+ opts: WithPageOptions,
326
+ extPath: string,
327
+ ): Promise<{ context: BrowserContext; extension: LoadedExtension; userDataDir: string }> {
328
+ const userDataDir = await mkdtemp(join(tmpdir(), 'lens-ext-'));
329
+ const context = await chromium.launchPersistentContext(userDataDir, {
330
+ ...contextOptions(opts),
331
+ headless: true,
332
+ channel: EXTENSION_CHANNEL,
333
+ args: [
334
+ `--disable-extensions-except=${extPath}`,
335
+ `--load-extension=${extPath}`,
336
+ '--disable-dev-shm-usage',
337
+ '--no-sandbox',
338
+ ],
339
+ });
340
+ // MV3 registers a service worker; MV2 a background page. Wait for whichever
341
+ // appears — the id lives in its URL host either way.
342
+ let worker = context.serviceWorkers()[0];
343
+ if (!worker) {
344
+ worker = await context
345
+ .waitForEvent('serviceworker', { timeout: 15_000 })
346
+ .catch(() => undefined as never);
347
+ }
348
+ const bg = worker?.url() ?? context.backgroundPages()[0]?.url();
349
+ if (!bg) {
350
+ await context.close().catch(() => {});
351
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
352
+ throw new Error(
353
+ `extension at ${extPath} loaded no service worker or background page within 15s — ` +
354
+ 'its id cannot be resolved, so nothing can be captured from it. Check that the path is ' +
355
+ 'an UNPACKED build directory containing manifest.json (not a .zip, not the source dir).',
356
+ );
357
+ }
358
+ return { context, extension: { id: new URL(bg).host, path: extPath }, userDataDir };
359
+ }
360
+
361
+ export async function withPage<T>(
362
+ opts: WithPageOptions,
363
+ fn: (page: Page, context: BrowserContext, extension?: LoadedExtension) => Promise<T>,
364
+ ): Promise<T> {
365
+ armIdleTimer();
366
+ const engine = opts.engine ?? 'chromium';
367
+
368
+ // F074.69 — the extension path is its own launch, its own lifecycle, its own
369
+ // cleanup. It deliberately does NOT reuse the warm browser.
370
+ if (opts.extension) {
371
+ if (engine !== 'chromium') {
372
+ // Refused with a reason rather than failing oddly further in — the same
373
+ // posture lens_export takes for PDF. The planned Safari extension is a
374
+ // different problem and is not covered by pretending this works.
375
+ throw new Error(
376
+ `extensions are chromium-only; engine "${engine}" cannot load one. ` +
377
+ 'Drop the engine option or pass engine:"chromium".',
378
+ );
379
+ }
380
+ const { context, extension, userDataDir } = await launchWithExtension(opts, opts.extension.path);
381
+ try {
382
+ await context.addInitScript(READINESS_INIT_SCRIPT);
383
+ if (opts.prepareContext) await opts.prepareContext(context);
384
+ // F074.97 — AFTER prepareContext on purpose: init scripts run in
385
+ // registration order, so a per-run seed outranks the same key inside an
386
+ // auth storageState blob. A per-call instruction is the more specific one.
387
+ await applyStorageSeed(context, opts.seedStorage ?? null);
388
+ // A persistent context opens with a page already; reuse it rather than
389
+ // leaving a blank one behind.
390
+ const page = context.pages()[0] ?? (await context.newPage());
391
+ if (opts.viewport) await page.setViewportSize(opts.viewport);
392
+ return await fn(page, context, extension);
393
+ } finally {
394
+ await withDeadline(context.close(), 5_000, 'extension context close').catch(() => {});
395
+ // The user-data dir is ours and nobody else's; leaving it leaks a real
396
+ // directory per capture.
397
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
398
+ }
399
+ }
400
+
401
+ const makeContext = async (): Promise<BrowserContext> => {
402
+ const browser = await withDeadline(getBrowser(engine), WEDGE_TIMEOUT_MS, 'browser launch');
403
+ return withDeadline(
404
+ browser.newContext(contextOptions(opts)),
405
+ WEDGE_TIMEOUT_MS,
406
+ 'newContext',
407
+ );
408
+ };
409
+ let context: BrowserContext;
410
+ try {
411
+ context = await makeContext();
412
+ } catch (err) {
413
+ if (!(err instanceof Error && err.message.includes('lens browser wedged'))) throw err;
414
+ discardBrowser(engine);
415
+ // One retry on a fresh launch; a second wedge propagates to the caller.
416
+ context = await makeContext();
417
+ }
418
+ try {
419
+ if (opts.stealth) {
420
+ // Mask the most-checked automation signal before any page script runs.
421
+ await context.addInitScript(() => {
422
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
423
+ });
424
+ }
425
+ // F213.8 — observe listener registration before ANY page script runs, so a
426
+ // flow can later ask whether the page is actually listening rather than
427
+ // whether it merely painted. Unconditional on purpose: it must be installed
428
+ // before we know whether this run will ask, and it costs one wrapped
429
+ // function call per addEventListener. It only records; it never blocks.
430
+ await context.addInitScript(READINESS_INIT_SCRIPT);
431
+ if (opts.prepareContext) await opts.prepareContext(context);
432
+ // F074.97 — see the extension branch above: seed AFTER auth, before any page.
433
+ await applyStorageSeed(context, opts.seedStorage ?? null);
434
+ const page = await context.newPage();
435
+ return await fn(page, context);
436
+ } finally {
437
+ // close() on a wedged browser can hang too — bounded, then best-effort.
438
+ await withDeadline(context.close(), 5_000, 'context close').catch(() => {});
439
+ }
440
+ }
@@ -0,0 +1,197 @@
1
+ // F255.7 — wait for the deployed build to actually be serving before a flow verifies it.
2
+ //
3
+ // The failure this exists to kill: GitHub Actions reports a deploy as `success` while the
4
+ // machines are still rolling, Lens runs against the PREVIOUS build, and everything passes.
5
+ // That green run is worse than a red one — it verifies real behaviour, just of the code you
6
+ // replaced, so nothing in the output looks suspicious (fd-sundhed, 2026-07-30).
7
+ //
8
+ // The contract: every cardmem-enrolled app reports the git sha of the RUNNING build in
9
+ // `build` at GET /api/health. A convention, not per-flow config — per-flow means each repo
10
+ // has to remember to wire it, and the repo that forgets is exactly the one that gets the
11
+ // false green. A contract you can skip only protects the people who did not need it.
12
+ //
13
+ // Four rules this module exists to enforce, three of them learned the hard way:
14
+ // 1. Wait for a KNOWN sha when the caller has one. "Wait until it CHANGES" hangs when the
15
+ // flow starts mid-rollout with the new value already live — which is precisely when
16
+ // someone is impatient and runs the verification early.
17
+ // 2. Require N CONSECUTIVE matching polls. A fleet mid-roll answers old/new/old, so a
18
+ // single hit on the one machine that finished is not proof the rollout is done.
19
+ // 3. Fetch from HERE, never through the page. A service worker serves a cached build, so
20
+ // a page-side poll happily reports the new marker on a page running the old code.
21
+ // 4. A missing marker FAILS. Skipping the wait would restore the exact false confidence
22
+ // the contract removes — only now with a green tick on it.
23
+
24
+ /** Default path of the marker. The contract path, not a per-flow knob. */
25
+ export const BUILD_MARKER_PATH = '/api/health';
26
+
27
+ /** Shortest sha prefix accepted as `expected` — git's own default abbreviation. */
28
+ export const MIN_SHA_PREFIX = 7;
29
+ const HEX = /^[0-9a-f]+$/;
30
+
31
+ export interface WaitForBuildOptions {
32
+ /** The sha to wait FOR. Omit to fall back to "changed from the value at start". */
33
+ expected?: string | null;
34
+ /** Give up after this long (default 180s — a Fly rolling deploy of a few machines). */
35
+ timeoutMs?: number;
36
+ /** Gap between polls (default 2s). */
37
+ intervalMs?: number;
38
+ /** Consecutive matching reads required before we call it live (default 3). */
39
+ consecutive?: number;
40
+ /** Injected for tests. */
41
+ fetchImpl?: typeof fetch;
42
+ now?: () => number;
43
+ sleep?: (ms: number) => Promise<void>;
44
+ }
45
+
46
+ export interface WaitForBuildResult {
47
+ build: string;
48
+ polls: number;
49
+ waited_ms: number;
50
+ /** How the target was decided — useful in the step detail. */
51
+ mode: 'expected-sha' | 'changed-from-start';
52
+ }
53
+
54
+ export class BuildMarkerError extends Error {}
55
+
56
+ /** Read the marker once, straight from the runner. Never through the page: see rule 3. */
57
+ export async function readBuildMarker(
58
+ baseUrl: string,
59
+ attempt: number,
60
+ fetchImpl: typeof fetch = fetch,
61
+ ): Promise<string> {
62
+ const url = new URL(BUILD_MARKER_PATH, baseUrl);
63
+ // Cache-bust on BOTH axes: a query the CDN has never seen, plus no-store, so neither an
64
+ // edge cache nor the local HTTP cache can hand back a stale marker.
65
+ url.searchParams.set('_lens_build', String(attempt));
66
+ let res: Response;
67
+ try {
68
+ res = await fetchImpl(url.toString(), {
69
+ cache: 'no-store',
70
+ headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache', Accept: 'application/json' },
71
+ });
72
+ } catch (err) {
73
+ throw new BuildMarkerError(`build marker unreachable at ${url.pathname}: ${(err as Error).message}`);
74
+ }
75
+ if (!res.ok) {
76
+ throw new BuildMarkerError(`build marker returned HTTP ${res.status} at ${url.pathname}`);
77
+ }
78
+ let body: unknown;
79
+ try {
80
+ body = await res.json();
81
+ } catch {
82
+ // The classic shape of this failure: /api/* falls through to the SPA and answers HTML.
83
+ throw new BuildMarkerError(
84
+ `build marker at ${url.pathname} did not return JSON — the route probably falls through to the SPA`,
85
+ );
86
+ }
87
+ const build = (body as { build?: unknown })?.build;
88
+ if (typeof build !== 'string' || build.trim() === '') {
89
+ throw new BuildMarkerError(
90
+ `no build marker: ${url.pathname} has no "build" field, so a new build cannot be told from an old one. ` +
91
+ 'Add build = the running git sha (F255.7 contract) before gating a run on it.',
92
+ );
93
+ }
94
+ return build.trim();
95
+ }
96
+
97
+ /** Block until the app is demonstrably serving the build we are about to verify. */
98
+ export async function waitForBuild(baseUrl: string, opts: WaitForBuildOptions = {}): Promise<WaitForBuildResult> {
99
+ const fetchImpl = opts.fetchImpl ?? fetch;
100
+ const now = opts.now ?? Date.now;
101
+ const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
102
+ const timeoutMs = opts.timeoutMs ?? 180_000;
103
+ const intervalMs = opts.intervalMs ?? 2_000;
104
+ const needed = Math.max(1, opts.consecutive ?? 3);
105
+ const expected = opts.expected?.trim().toLowerCase() || null;
106
+ // F255.7.1 — a short sha is what agents actually have (`git log --oneline`, handoffs), so
107
+ // a hex expectation matches as a PREFIX of the served sha. Below git's 7-char default a
108
+ // prefix stops being a fingerprint, so it is refused before we wait 180s to say so.
109
+ const expectedHex = expected !== null && HEX.test(expected);
110
+ if (expectedHex && expected.length < MIN_SHA_PREFIX) {
111
+ throw new BuildMarkerError(
112
+ `expected sha "${expected}" is too short — pass at least ${MIN_SHA_PREFIX} hex characters (or the full sha).`,
113
+ );
114
+ }
115
+
116
+ const startedAt = now();
117
+ let polls = 0;
118
+
119
+ // The first read establishes the baseline for the changed-from-start mode. A missing
120
+ // marker throws here, before any waiting — the loud failure of rule 4.
121
+ polls += 1;
122
+ const first = await readBuildMarker(baseUrl, polls, fetchImpl);
123
+ const matches = (v: string) => {
124
+ if (!expected) return v !== first;
125
+ const served = v.toLowerCase();
126
+ // A non-hex marker (a version string, a date) keeps exact equality: prefix-matching free
127
+ // text would turn "v1" into a match for "v1.2" — a false green.
128
+ return expectedHex && HEX.test(served) ? served.startsWith(expected) : served === expected;
129
+ };
130
+
131
+ // Rule 1: with an explicit sha that is ALREADY live we must not sit waiting for a change
132
+ // that will never come — so `first` is allowed to count toward the streak.
133
+ let streak = matches(first) ? 1 : 0;
134
+ let last = first;
135
+
136
+ while (streak < needed) {
137
+ if (now() - startedAt >= timeoutMs) {
138
+ const target = expected ? `sha ${expected}` : `any build other than ${first}`;
139
+ throw new BuildMarkerError(
140
+ `timed out after ${Math.round((now() - startedAt) / 1000)}s waiting for ${target} — ` +
141
+ `still serving ${last} after ${polls} polls. The deploy may have failed, or the app is not reporting the new sha.`,
142
+ );
143
+ }
144
+ await sleep(intervalMs);
145
+ polls += 1;
146
+ last = await readBuildMarker(baseUrl, polls, fetchImpl);
147
+ // Rule 2: a non-matching read RESETS the streak. Mid-rollout the fleet answers
148
+ // old/new/old, and only an unbroken run of matches means every machine has flipped.
149
+ streak = matches(last) ? streak + 1 : 0;
150
+ }
151
+
152
+ return {
153
+ build: last,
154
+ polls,
155
+ waited_ms: now() - startedAt,
156
+ mode: expected ? 'expected-sha' : 'changed-from-start',
157
+ };
158
+ }
159
+
160
+ /** How long a best-effort marker read may take. Short on purpose: this runs on
161
+ * EVERY capture, and a target with no marker (a plain dev server) must cost
162
+ * almost nothing. It is a measurement we would like, never one we require. */
163
+ export const OBSERVE_TIMEOUT_MS = 2_000;
164
+
165
+ /**
166
+ * F074.30 — read the build a run is about to look at, best-effort.
167
+ *
168
+ * Returns the sha, or NULL when the target exposes no marker, is unreachable, or
169
+ * is too slow. NULL means "not measured" and must never be read as "same build
170
+ * as the other run": a comparison spanning a null has to refuse rather than
171
+ * reassure. That is the whole reason this returns null instead of a placeholder
172
+ * — an empty string or a "unknown" literal would compare equal to itself and
173
+ * manufacture exactly the false "no regression" this exists to prevent.
174
+ *
175
+ * Unlike readBuildMarker it NEVER throws. A capture that would otherwise pass
176
+ * must not fail because an optional measurement was unavailable.
177
+ */
178
+ export async function observeBuild(
179
+ baseUrl: string,
180
+ opts: { fetchImpl?: typeof fetch; timeoutMs?: number } = {},
181
+ ): Promise<string | null> {
182
+ const timeoutMs = opts.timeoutMs ?? OBSERVE_TIMEOUT_MS;
183
+ const ctrl = new AbortController();
184
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
185
+ const base = opts.fetchImpl ?? fetch;
186
+ // Cast: readBuildMarker only ever CALLS this, so Bun's extra `fetch.preconnect`
187
+ // property on the nominal type is irrelevant here.
188
+ const withSignal = ((input: Parameters<typeof fetch>[0], init?: RequestInit) =>
189
+ base(input, { ...init, signal: ctrl.signal })) as unknown as typeof fetch;
190
+ try {
191
+ return await readBuildMarker(baseUrl, 0, withSignal);
192
+ } catch {
193
+ return null;
194
+ } finally {
195
+ clearTimeout(timer);
196
+ }
197
+ }