@broberg/lens-daemon 0.1.37 → 0.1.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broberg/lens-daemon",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "description": "The Lens module of the cardmem daemon (flows, critic, coverage, capture sessions, the /lens/* routes and the cardmem-lens MCP) as a package. The daemon mounts createLensRouter(deps) + createLensMcpRouter(deps). Bun runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,8 +15,8 @@
15
15
  "sharp": "^0.35.5",
16
16
  "turndown": "^7.2.4",
17
17
  "zod": "^3.23.8",
18
- "@broberg/lens-engine": "0.14.1",
19
- "@broberg/lens-svg": "0.1.1"
18
+ "@broberg/lens-svg": "0.1.1",
19
+ "@broberg/lens-engine": "0.14.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@happy-dom/global-registrator": "^20.10.6",
package/src/browser.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // context + page are always closed in a finally, even on capture error.
12
12
 
13
13
  import { bindProfileChannel, claimProfile, killOwnBrowsersOn, profileDir, type ProfileChannel } from './profiles.ts';
14
+ import { launchWithRepair, type BrowserRepair } from './ensure-chromium.ts';
14
15
  import {
15
16
  chromium,
16
17
  webkit,
@@ -80,7 +81,15 @@ const CHROMIUM_ARGS = [
80
81
 
81
82
  async function launch(engine: LensEngine): Promise<Browser> {
82
83
  const args = engine === 'chromium' ? CHROMIUM_ARGS : [];
83
- return ENGINES[engine].launch({ headless: true, args });
84
+ // F010.1 — a deleted browser is installed and the launch retried, not a fleet-wide outage.
85
+ return launchWithRepair(engine, () => ENGINES[engine].launch({ headless: true, args }), (r) => lastRepairs.push(r));
86
+ }
87
+
88
+ /** F010.1 — repairs made since the last read; a run reads (and clears) them into
89
+ * its report. Read-and-clear, so one repair is reported once. */
90
+ const lastRepairs: BrowserRepair[] = [];
91
+ export function takeBrowserRepairs(): BrowserRepair[] {
92
+ return lastRepairs.splice(0, lastRepairs.length);
84
93
  }
85
94
 
86
95
  /** Get the warm browser for an engine, launching it on first use. Re-launches
@@ -353,7 +362,7 @@ async function launchWithExtension(
353
362
  if (profile) bindProfileChannel(profile, EXTENSION_CHANNEL);
354
363
  userDataDir = profile ? profileDir(profile) : await mkdtemp(join(tmpdir(), 'lens-ext-'));
355
364
  try {
356
- context = await chromium.launchPersistentContext(userDataDir, {
365
+ context = await launchWithRepair('chromium', () => chromium.launchPersistentContext(userDataDir, {
357
366
  ...contextOptions(opts),
358
367
  headless: true,
359
368
  channel: EXTENSION_CHANNEL,
@@ -364,7 +373,7 @@ async function launchWithExtension(
364
373
  '--disable-dev-shm-usage',
365
374
  '--no-sandbox',
366
375
  ],
367
- });
376
+ }), (r) => lastRepairs.push(r));
368
377
  } catch (err) {
369
378
  lastLaunchError = err instanceof Error ? err.message.split('\n')[0]! : String(err);
370
379
  if (!profile) await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
@@ -14,7 +14,7 @@ import { spawn } from 'node:child_process';
14
14
  import { existsSync } from 'node:fs';
15
15
  import { createRequire } from 'node:module';
16
16
  import { dirname, join } from 'node:path';
17
- import { chromium } from 'playwright';
17
+ import { chromium, firefox, webkit } from 'playwright';
18
18
 
19
19
  export interface ChromiumExpectation {
20
20
  installed: boolean;
@@ -116,3 +116,115 @@ export function ensureChromium(): Promise<{
116
116
 
117
117
  return inFlight;
118
118
  }
119
+
120
+ // ── F010.1 — repair at the moment of need, for every engine ────────────────────
121
+ //
122
+ // ensureChromium() runs once at daemon boot and installs the FULL Chromium only.
123
+ // On 25 Oct the browsers were deleted while the daemon was running, and the warm
124
+ // headless launch uses chromium-headless-shell, which boot never checked: every
125
+ // run fleet-wide failed until a hand reinstalled. Now a launch that fails because
126
+ // its binary is missing installs that engine's browsers (from the daemon's own
127
+ // pinned Playwright, same as boot) and is retried once.
128
+
129
+ export type BrowserEngine = 'chromium' | 'webkit' | 'firefox';
130
+
131
+ /** What `playwright install` must fetch for an engine to launch in every mode Lens uses. */
132
+ const INSTALL_NAMES: Record<BrowserEngine, string[]> = {
133
+ chromium: ['chromium', 'chromium-headless-shell', 'ffmpeg'],
134
+ webkit: ['webkit'],
135
+ firefox: ['firefox'],
136
+ };
137
+
138
+ export function isMissingBrowserError(err: unknown): boolean {
139
+ return /Executable doesn't exist/.test(err instanceof Error ? err.message : String(err));
140
+ }
141
+
142
+ type Installer = (names: string[]) => Promise<{ ok: boolean; error?: string }>;
143
+
144
+ const spawnInstaller: Installer = (names) =>
145
+ new Promise((resolve) => {
146
+ let cliPath: string;
147
+ try {
148
+ cliPath = resolvePlaywrightCli();
149
+ } catch (err) {
150
+ resolve({ ok: false, error: `cannot resolve playwright CLI: ${err instanceof Error ? err.message : String(err)}` });
151
+ return;
152
+ }
153
+ console.log(`[lens] browser missing — installing ${names.join(', ')} via ${cliPath} …`);
154
+ const child = spawn(process.execPath, [cliPath, 'install', ...names], { stdio: 'inherit', env: process.env });
155
+ child.on('error', (err) => resolve({ ok: false, error: err.message }));
156
+ child.on('exit', (code) => resolve(code === 0 ? { ok: true } : { ok: false, error: `playwright install exited ${code}` }));
157
+ });
158
+
159
+ let installer: Installer = spawnInstaller;
160
+ /** Test seam: replace the installer (null restores the real one). */
161
+ export function setBrowserInstaller(fn: Installer | null): void {
162
+ installer = fn ?? spawnInstaller;
163
+ }
164
+
165
+ // One install per engine at a time: two runs that hit the gap together share it.
166
+ const engineInFlight = new Map<BrowserEngine, Promise<{ ok: boolean; error?: string }>>();
167
+
168
+ export function installBrowsers(engine: BrowserEngine): Promise<{ ok: boolean; error?: string }> {
169
+ let p = engineInFlight.get(engine);
170
+ if (!p) {
171
+ p = installer(INSTALL_NAMES[engine]).finally(() => engineInFlight.delete(engine));
172
+ engineInFlight.set(engine, p);
173
+ }
174
+ return p;
175
+ }
176
+
177
+ export interface BrowserRepair {
178
+ engine: BrowserEngine;
179
+ ms: number;
180
+ }
181
+
182
+ /** Run a launch; if it fails ONLY because the browser binary is missing, install
183
+ * that engine's browsers and retry once. Any other error propagates untouched. */
184
+ export async function launchWithRepair<T>(
185
+ engine: BrowserEngine,
186
+ launch: () => Promise<T>,
187
+ onRepaired?: (r: BrowserRepair) => void,
188
+ ): Promise<T> {
189
+ try {
190
+ return await launch();
191
+ } catch (err) {
192
+ if (!isMissingBrowserError(err)) throw err;
193
+ const t0 = Date.now();
194
+ const inst = await installBrowsers(engine);
195
+ if (!inst.ok) {
196
+ throw new Error(`${engine} browser is missing and could not be installed: ${inst.error ?? 'unknown'} — original: ${(err as Error).message.split('\n')[0]}`);
197
+ }
198
+ const out = await launch();
199
+ onRepaired?.({ engine, ms: Date.now() - t0 });
200
+ return out;
201
+ }
202
+ }
203
+
204
+ /** For /health: does each engine's browser binary exist where this daemon looks?
205
+ * chromium is reported for both the full build and the headless shell, because
206
+ * the warm headless launch needs the shell (the 25 Oct gap boot never checked). */
207
+ export function browsersPresent(): Record<string, boolean> {
208
+ const has = (f: () => string) => {
209
+ try {
210
+ return existsSync(f());
211
+ } catch {
212
+ return false;
213
+ }
214
+ };
215
+ const full = (() => {
216
+ try {
217
+ return chromium.executablePath();
218
+ } catch {
219
+ return '';
220
+ }
221
+ })();
222
+ const m = /^(.*[\\/])chromium-(\d+)[\\/]/.exec(full);
223
+ const shellDir = m ? join(m[1]!, `chromium_headless_shell-${m[2]}`) : '';
224
+ return {
225
+ chromium: has(() => full),
226
+ 'chromium-headless-shell': shellDir ? existsSync(shellDir) : false,
227
+ webkit: has(() => webkit.executablePath()),
228
+ firefox: has(() => firefox.executablePath()),
229
+ };
230
+ }
package/src/flow.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  type AssertOutcome,
24
24
  type Target,
25
25
  } from '@broberg/lens-engine';
26
- import { withPage, type LensEngine, type RunBrowser } from './browser.ts';
26
+ import { takeBrowserRepairs, withPage, type LensEngine, type RunBrowser } from './browser.ts';
27
27
  import { collectNetworkShapes, type NetworkShapesOptions, type NetworkShapesReport } from './network-shapes.ts';
28
28
  import { deadline, defaultPopupOf, prepareExtension, seedExtensionStorage, SIMULATED_ACCESS_NOTE } from './extension-flow.ts';
29
29
  import { authPrepare, fetchMintJson, persistStorageState, persistTarget, type AuthConfig, type AuthEnv } from './auth/index.ts';
@@ -424,6 +424,8 @@ export interface FlowResult {
424
424
  /** F009.131 — set when auth.persist was asked for: whether the renewed login was
425
425
  * written back to statePath, and why not when it was not. Never a value. */
426
426
  auth_persisted?: { ok: boolean; reason?: string };
427
+ /** F010.1 — browsers the daemon reinstalled because their binary was missing. */
428
+ browser_repaired?: Array<{ engine: string; ms: number }>;
427
429
  /** F009.137 — mails this run consumed for a code, and how many were removed
428
430
  * after. Present only when a step read mail. Never a code or a body. */
429
431
  mail?: { consumed: number; cleaned: number; reason?: string };
@@ -3508,6 +3510,8 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3508
3510
  reduced_motion: opts.reducedMotion ?? 'reduce',
3509
3511
  ...(authPersisted ? { auth_persisted: authPersisted } : {}),
3510
3512
  ...(mailReport ? { mail: mailReport } : {}),
3513
+ // F010.1 — a missing browser this run had to reinstall, said out loud.
3514
+ ...((() => { const r = takeBrowserRepairs(); return r.length ? { browser_repaired: r } : {}; })()),
3511
3515
  ...(extensionInfo ? { extension: extensionInfo } : {}),
3512
3516
  ...(networkShapesReport ? { network_shapes: networkShapesReport } : {}),
3513
3517
  downloads,
package/src/http.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // all hit these. Artifact bytes are served here too, so an <img> in the
6
6
  // dashboard points straight at the daemon.
7
7
 
8
+ import { browsersPresent } from './ensure-chromium.ts';
8
9
  import { Hono } from 'hono';
9
10
  import { setSandboxResolver } from './sandbox.ts';
10
11
  import { registerDaemonRoutes } from './daemon-routes.ts';
@@ -79,6 +80,8 @@ export function createLensRouter(deps: LensDeps): Hono {
79
80
 
80
81
  // F276.1/F276.4 — the upload queue: what it holds, and one drain pass.
81
82
  r.get('/uploads', (c) => c.json({ ok: true, ...uploadStats(deps.db) }));
83
+ // F010.1 — does each engine's browser binary exist where this daemon looks?
84
+ r.get('/browsers', (c) => c.json({ ok: true, browsers: browsersPresent() }));
82
85
  r.post('/uploads/drain', handle(async (body) => {
83
86
  const b = (body ?? {}) as { batch?: number };
84
87
  return drainUploads(deps.db, deps.storage, deps.cloud?.() ?? null, { batch: b.batch });
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@ export { durabilityOf, type EvidenceDurability } from './durability.ts';
16
16
  export { setSandboxResolver, type SandboxResolver } from './sandbox.ts';
17
17
  // Lifecycle the daemon owns: provision Chromium at boot, drain the evidence
18
18
  // upload queue on its timer, close the warm browser on shutdown.
19
- export { ensureChromium } from './ensure-chromium.ts';
19
+ export { ensureChromium, browsersPresent } from './ensure-chromium.ts';
20
20
  export { drainUploads } from './artifact-uploads.ts';
21
21
  export { closeBrowser } from './browser.ts';
22
22