@agent-flow-board/bridge 0.2.0 → 0.3.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.
Files changed (51) hide show
  1. package/README.md +16 -0
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +213 -0
  4. package/dist/checks/audit.js +3 -2
  5. package/dist/checks/bind.js +3 -2
  6. package/dist/checks/broker.js +439 -0
  7. package/dist/checks/browser.js +137 -32
  8. package/dist/checks/browserpool.js +221 -0
  9. package/dist/checks/cache.js +710 -0
  10. package/dist/checks/command.js +75 -88
  11. package/dist/checks/commands.js +219 -0
  12. package/dist/checks/connections.js +4 -3
  13. package/dist/checks/copy.js +320 -0
  14. package/dist/checks/dbpool.js +710 -0
  15. package/dist/checks/doctor.js +649 -0
  16. package/dist/checks/envPolicy.js +50 -1
  17. package/dist/checks/envrules.js +322 -0
  18. package/dist/checks/evidence.js +114 -0
  19. package/dist/checks/fsretry.js +29 -0
  20. package/dist/checks/importlegacy.js +300 -0
  21. package/dist/checks/install.js +150 -0
  22. package/dist/checks/live.js +76 -0
  23. package/dist/checks/lock.js +28 -4
  24. package/dist/checks/login.js +203 -0
  25. package/dist/checks/mailguard-hook.cjs +216 -0
  26. package/dist/checks/mailguard.js +98 -0
  27. package/dist/checks/paths.js +24 -0
  28. package/dist/checks/preview.js +418 -191
  29. package/dist/checks/profiles.js +110 -0
  30. package/dist/checks/provision.js +1 -1
  31. package/dist/checks/recipe.js +19 -5
  32. package/dist/checks/registry.js +548 -0
  33. package/dist/checks/repoenv.js +162 -0
  34. package/dist/checks/roots.js +144 -0
  35. package/dist/checks/routing.js +68 -0
  36. package/dist/checks/run.js +243 -53
  37. package/dist/checks/sandbox.js +32 -10
  38. package/dist/checks/scheduler.js +383 -0
  39. package/dist/checks/scope.js +119 -30
  40. package/dist/checks/sealedNames.js +39 -0
  41. package/dist/checks/secrets.js +452 -0
  42. package/dist/checks/secretscmd.js +80 -0
  43. package/dist/checks/service.js +589 -0
  44. package/dist/checks/signin.js +7 -1
  45. package/dist/checks/singleflight.js +307 -0
  46. package/dist/checks/store.js +92 -0
  47. package/dist/checks/surface.js +124 -0
  48. package/dist/checks/warm.js +342 -0
  49. package/dist/checks/watch.js +190 -63
  50. package/dist/checks/wire.js +87 -0
  51. package/package.json +1 -1
@@ -0,0 +1,439 @@
1
+ // THE MACHINE BROKER: HOW MANY RUNS THIS MACHINE TAKES AT ONCE, ACROSS EVERY `check run` AND `check watch` PROCESS.
2
+ // A slot is a small file in brokerDir() (slots/slot-N.json) holding the process id, the start time and a heartbeat. A slot whose process is gone, or whose heartbeat
3
+ // stopped for the stale window, is reclaimed by the next process that looks. A process that waits for a slot writes a waiter file, so every process sees the same
4
+ // queue: the head of the queue is the waiter whose registration runs the fewest slots now (ties by arrival), and only the head may take a slot. The machine check
5
+ // (free disk, free memory per run that started in the last 90 s) is part of taking a slot and WAITS instead of failing. Nothing here blocks the event loop for
6
+ // longer than a few small file reads.
7
+ // -- DEP-MODULES -- //
8
+ import { randomBytes } from 'node:crypto';
9
+ import { linkSync, readFileSync, readdirSync, renameSync, rmSync, statSync, statfsSync, writeFileSync } from 'node:fs';
10
+ import { cpus, freemem } from 'node:os';
11
+ import { join } from 'node:path';
12
+ // -- MODULES -- //
13
+ import { pidAlive } from './lock.js';
14
+ import { brokerDir, checksHome, ensureDir, poolDir } from './paths.js';
15
+ // -- CONSTANTS -- //
16
+ const GB = 1024 ** 3;
17
+ const MB = 1024 ** 2;
18
+ /** A run that started this recently has not used its memory yet (browser, preview and database are still starting), so it is counted again. */
19
+ export const RECENT_START_MS = 90_000;
20
+ const DEFAULT_STALE_MS = 45_000;
21
+ const DEFAULT_POLL_MS = 250;
22
+ /** A file that cannot be read is only treated as dead after this long (files are created whole, so this means damage). */
23
+ const UNREADABLE_GRACE_MS = 2_000;
24
+ const num = (name, fallback) => {
25
+ const v = Number(process.env[name]);
26
+ return Number.isFinite(v) && v > 0 ? v : fallback;
27
+ };
28
+ // -- FUNCTIONS: SETTINGS -- //
29
+ /** Free disk a new run needs where the tool keeps its runs, in gigabytes. */
30
+ export const minFreeDiskGb = () => num('AFB_CHECKS_MIN_FREE_GB', 4);
31
+ /** Free memory another run needs, in megabytes. */
32
+ export const perRunMemoryMb = () => num('AFB_CHECKS_RUN_MEMORY_MB', 1200);
33
+ /** Slots when nothing is set: as many as the machine has processors, at most 8. The memory and disk preflight still holds a run back when the machine is full. */
34
+ export const defaultSlots = () => Math.max(1, Math.min(cpus().length, 8));
35
+ /** The most runs this machine takes at once: AFB_CHECKS_MAX_PARALLEL, else the configured number, else min(processors, 8); never above the number of processors. */
36
+ export function maxSlots(configured) {
37
+ const raw = process.env.AFB_CHECKS_MAX_PARALLEL ?? configured;
38
+ const wanted = raw === undefined || raw === null || raw === '' ? NaN : Math.floor(Number(raw));
39
+ const cap = Math.max(1, cpus().length);
40
+ return Math.min(cap, Math.max(1, Number.isFinite(wanted) ? wanted : defaultSlots()));
41
+ }
42
+ function freeDisk() {
43
+ try {
44
+ const s = statfsSync(ensureDir(checksHome()));
45
+ return Number(s.bavail) * Number(s.bsize);
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ const realDeps = () => ({
52
+ now: () => Date.now(),
53
+ pidAlive,
54
+ freeDiskBytes: freeDisk,
55
+ freeMemBytes: () => freemem()
56
+ });
57
+ const withDeps = (d) => ({ ...realDeps(), ...d });
58
+ /**
59
+ * Whether another run may start. `busy` is how many runs hold a slot, `recent` how many of them started in the last 90 s. The first run on a machine is never held
60
+ * back for memory (an unreadable or low reading must not stop all work).
61
+ */
62
+ export function preflight(i) {
63
+ const need = minFreeDiskGb();
64
+ if (i.freeDiskBytes !== null && i.freeDiskBytes < need * GB)
65
+ return {
66
+ ok: false,
67
+ code: 'DISK_LOW',
68
+ why: `Only ${(i.freeDiskBytes / GB).toFixed(1)} GB is free where the tool keeps its runs and a run needs ${need} GB. Free some space (agent-flow-board check clear), then it takes runs again.`
69
+ };
70
+ const mem = perRunMemoryMb() * (1 + i.recent);
71
+ if (i.busy > 0 && i.freeMemBytes < mem * MB)
72
+ return {
73
+ ok: false,
74
+ code: 'MEMORY_LOW',
75
+ why: `Only ${Math.round(i.freeMemBytes / MB)} MB of memory is free and another run needs ${mem} MB, so it waits for one of the ${i.busy} running to finish.`
76
+ };
77
+ return { ok: true };
78
+ }
79
+ // -- FUNCTIONS: FILES -- //
80
+ const slotsDir = () => join(brokerDir(), 'slots');
81
+ const waitersDir = () => join(brokerDir(), 'waiters');
82
+ const slotFile = (index) => join(slotsDir(), `slot-${index}.json`);
83
+ function readText(file) {
84
+ try {
85
+ return readFileSync(file, 'utf8');
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ function parse(text) {
92
+ if (text === null)
93
+ return null;
94
+ try {
95
+ const v = JSON.parse(text);
96
+ return v && typeof v === 'object' && v.v === 1 ? v : null;
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ /** Creates a file with its whole content at once (a temporary file linked into place). False when it exists. */
103
+ function createWhole(file, text) {
104
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
105
+ writeFileSync(tmp, text, { mode: 0o600 });
106
+ try {
107
+ linkSync(tmp, file);
108
+ return true;
109
+ }
110
+ catch (e) {
111
+ if (e.code === 'EEXIST')
112
+ return false;
113
+ throw e;
114
+ }
115
+ finally {
116
+ rmSync(tmp, { force: true });
117
+ }
118
+ }
119
+ /** Replaces a file whole (a temporary file renamed over it). A failure is ignored: the next beat tries again. */
120
+ function replaceWhole(file, text) {
121
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
122
+ try {
123
+ writeFileSync(tmp, text, { mode: 0o600 });
124
+ renameSync(tmp, file);
125
+ }
126
+ catch {
127
+ rmSync(tmp, { force: true });
128
+ }
129
+ }
130
+ /**
131
+ * Removes a dead file the careful way: moved aside under a name of its own, compared with what was seen, and linked back when it turns out to be somebody's fresh
132
+ * file (a dead file that is unlinked and a new one made in between would lose the new one).
133
+ */
134
+ function reap(file, seen, stillDead) {
135
+ const aside = `${file}.aside.${process.pid}.${randomBytes(4).toString('hex')}`;
136
+ try {
137
+ renameSync(file, aside);
138
+ }
139
+ catch {
140
+ return;
141
+ }
142
+ const moved = readText(aside);
143
+ if (moved !== null && moved !== seen && !stillDead(moved)) {
144
+ try {
145
+ linkSync(aside, file);
146
+ }
147
+ catch {
148
+ /* A newer file is already there */
149
+ }
150
+ }
151
+ rmSync(aside, { force: true });
152
+ }
153
+ function listJson(dir) {
154
+ try {
155
+ return readdirSync(dir)
156
+ .filter((f) => f.endsWith('.json'))
157
+ .map((f) => join(dir, f));
158
+ }
159
+ catch {
160
+ return [];
161
+ }
162
+ }
163
+ function unreadableAndOld(file, now) {
164
+ try {
165
+ return now - statSync(file).mtimeMs > UNREADABLE_GRACE_MS;
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ }
171
+ /** Reads the slots and waiters that are alive, and removes the dead ones. */
172
+ function readView(deps, staleMs) {
173
+ const now = deps.now();
174
+ const slots = [];
175
+ for (const file of listJson(slotsDir())) {
176
+ const text = readText(file);
177
+ const body = parse(text);
178
+ const dead = (b, t) => b
179
+ ? !deps.pidAlive(b.pid) || now - b.beat > staleMs
180
+ : t === null
181
+ ? false
182
+ : unreadableAndOld(file, now);
183
+ if (dead(body, text)) {
184
+ reap(file, text, (moved) => dead(parse(moved), moved));
185
+ continue;
186
+ }
187
+ if (body)
188
+ slots.push(body);
189
+ }
190
+ const waiters = [];
191
+ for (const file of listJson(waitersDir())) {
192
+ const text = readText(file);
193
+ const body = parse(text);
194
+ const dead = (b, t) => b
195
+ ? !deps.pidAlive(b.pid) || now - b.beat > staleMs
196
+ : t === null
197
+ ? false
198
+ : unreadableAndOld(file, now);
199
+ if (dead(body, text)) {
200
+ reap(file, text, (moved) => dead(parse(moved), moved));
201
+ continue;
202
+ }
203
+ if (body)
204
+ waiters.push(body);
205
+ }
206
+ return { slots, waiters };
207
+ }
208
+ /** The waiter that goes first: the registration with the fewest running slots, then the earliest arrival. */
209
+ function head(view) {
210
+ const running = new Map();
211
+ for (const s of view.slots)
212
+ running.set(s.registration, (running.get(s.registration) ?? 0) + 1);
213
+ const sorted = [...view.waiters].sort((a, b) => (running.get(a.registration) ?? 0) - (running.get(b.registration) ?? 0) ||
214
+ a.arrivedAt - b.arrivedAt ||
215
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
216
+ return sorted[0] ?? null;
217
+ }
218
+ // -- FUNCTIONS: STATUS -- //
219
+ const inProcessWaiting = new Map();
220
+ /** The runs of this process that wait for a slot right now, so the caller can keep the board told that they are alive. */
221
+ export const waiting = () => [...inProcessWaiting.values()];
222
+ /** How many slots there are and how many are held right now by every process on this machine. */
223
+ export function machineTotals(o = {}) {
224
+ const slots = o.slots ?? maxSlots();
225
+ const view = readView(withDeps(o.deps), o.staleMs ?? DEFAULT_STALE_MS);
226
+ return { slots, busy: Math.min(slots, view.slots.length) };
227
+ }
228
+ /** The value of the x-afb-tool-slots header. */
229
+ export const slotsHeader = (o = {}) => {
230
+ const t = machineTotals(o);
231
+ return `${t.slots}:${t.busy}`;
232
+ };
233
+ /** Whether the machine could take another run now, without waiting: for a scheduler that leaves a run unclaimed when the answer is no. */
234
+ export function canTake(o = {}) {
235
+ const deps = withDeps(o.deps);
236
+ const view = readView(deps, o.staleMs ?? DEFAULT_STALE_MS);
237
+ const now = deps.now();
238
+ return preflight({
239
+ busy: view.slots.length,
240
+ recent: view.slots.filter((s) => now - s.startedAt < RECENT_START_MS).length,
241
+ freeDiskBytes: deps.freeDiskBytes(),
242
+ freeMemBytes: deps.freeMemBytes()
243
+ });
244
+ }
245
+ /** The pools (browser, database) other processes of this machine left a file for in poolDir(). The warm folder is the warm store's. */
246
+ export function listPools() {
247
+ const out = [];
248
+ for (const file of listJson(poolDir())) {
249
+ const text = readText(file);
250
+ let info = null;
251
+ try {
252
+ info = text === null ? null : JSON.parse(text);
253
+ }
254
+ catch {
255
+ /* A damaged file is listed without its content */
256
+ }
257
+ out.push({ name: file.slice(poolDir().length + 1).replace(/\.json$/, ''), file, info });
258
+ }
259
+ return out;
260
+ }
261
+ // -- FUNCTIONS: ACQUIRE -- //
262
+ const held = new Set();
263
+ let exitHook = false;
264
+ function dropHeldOnExit() {
265
+ if (exitHook)
266
+ return;
267
+ exitHook = true;
268
+ process.once('exit', () => {
269
+ for (const h of held) {
270
+ const body = parse(readText(h.file));
271
+ if (body?.token === h.token)
272
+ rmSync(h.file, { force: true });
273
+ }
274
+ });
275
+ }
276
+ const sleep = (ms, signal) => new Promise((resolve) => {
277
+ const t = setTimeout(done, ms);
278
+ function done() {
279
+ clearTimeout(t);
280
+ signal?.removeEventListener('abort', done);
281
+ resolve();
282
+ }
283
+ signal?.addEventListener('abort', done, { once: true });
284
+ });
285
+ export const abortError = () => Object.assign(new Error('Waiting for a slot was stopped.'), { name: 'AbortError' });
286
+ function tryTake(req, slots, deps, staleMs) {
287
+ ensureDir(slotsDir());
288
+ for (let index = 0; index < slots; index++) {
289
+ const file = slotFile(index);
290
+ const token = randomBytes(8).toString('hex');
291
+ const now = deps.now();
292
+ const body = {
293
+ v: 1,
294
+ index,
295
+ pid: process.pid,
296
+ token,
297
+ runId: req.runId,
298
+ kind: req.kind,
299
+ registration: req.registration,
300
+ startedAt: now,
301
+ beat: now
302
+ };
303
+ if (!createWhole(file, JSON.stringify(body) + '\n'))
304
+ continue;
305
+ // Slots with a number above the limit (the limit was lowered) still count: when the machine is over the limit now, this one goes back
306
+ if (readView(deps, staleMs).slots.length > slots) {
307
+ rmSync(file, { force: true });
308
+ return null;
309
+ }
310
+ return makeSlot(file, body, deps, staleMs);
311
+ }
312
+ return null;
313
+ }
314
+ function makeSlot(file, body, deps, staleMs) {
315
+ const handle = { file, token: body.token };
316
+ held.add(handle);
317
+ dropHeldOnExit();
318
+ let released = false;
319
+ let lost = false;
320
+ const beat = () => {
321
+ if (released || lost)
322
+ return;
323
+ const current = parse(readText(file));
324
+ if (!current || current.token !== body.token) {
325
+ lost = true;
326
+ return;
327
+ }
328
+ replaceWhole(file, JSON.stringify({ ...current, beat: deps.now() }) + '\n');
329
+ };
330
+ const timer = setInterval(beat, Math.max(20, Math.floor(staleMs / 3)));
331
+ timer.unref();
332
+ return {
333
+ id: body.token,
334
+ index: body.index,
335
+ held: () => !released && !lost,
336
+ release() {
337
+ if (released)
338
+ return;
339
+ released = true;
340
+ clearInterval(timer);
341
+ held.delete(handle);
342
+ const text = readText(file);
343
+ if (parse(text)?.token === body.token)
344
+ reap(file, text, () => true);
345
+ }
346
+ };
347
+ }
348
+ /**
349
+ * Waits for a slot and returns it. Waits (never fails) while the machine has no free slot, while other registrations go first, and while the disk or memory check
350
+ * says no. Rejects with an AbortError when the signal aborts. The caller releases the slot in a finally. The waiter file is beaten while waiting, and `waiting()`
351
+ * lists what this process waits for, so the caller keeps the board told that the run is alive.
352
+ */
353
+ export async function acquireSlot(req, opts = {}) {
354
+ const deps = withDeps(opts.deps);
355
+ const slots = opts.slots ?? maxSlots();
356
+ const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
357
+ const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
358
+ const { signal } = opts;
359
+ ensureDir(waitersDir());
360
+ const id = `${req.runId}-${randomBytes(3).toString('hex')}`;
361
+ const file = join(waitersDir(), `${id}.json`);
362
+ const arrivedAt = deps.now();
363
+ let lastBeat = 0;
364
+ const writeWaiter = (force = false) => {
365
+ const now = deps.now();
366
+ if (!force && now - lastBeat < staleMs / 3)
367
+ return;
368
+ lastBeat = now;
369
+ const body = {
370
+ v: 1,
371
+ id,
372
+ pid: process.pid,
373
+ runId: req.runId,
374
+ registration: req.registration,
375
+ arrivedAt,
376
+ beat: now
377
+ };
378
+ replaceWhole(file, JSON.stringify(body) + '\n');
379
+ };
380
+ writeWaiter(true);
381
+ const since = arrivedAt;
382
+ let said = null;
383
+ try {
384
+ for (;;) {
385
+ if (signal?.aborted)
386
+ throw abortError();
387
+ writeWaiter();
388
+ const view = readView(deps, staleMs);
389
+ let reason = null;
390
+ let why = '';
391
+ if (view.slots.length >= slots) {
392
+ reason = 'slots';
393
+ why = `All ${slots} slot${slots === 1 ? ' is' : 's are'} busy, so this run waits for one.`;
394
+ }
395
+ else if (head(view)?.id !== id) {
396
+ reason = 'turn';
397
+ why =
398
+ 'Another run goes first (the registrations take turns), so this run waits for its turn.';
399
+ }
400
+ else {
401
+ const now = deps.now();
402
+ const pf = preflight({
403
+ busy: view.slots.length,
404
+ recent: view.slots.filter((s) => now - s.startedAt < RECENT_START_MS).length,
405
+ freeDiskBytes: deps.freeDiskBytes(),
406
+ freeMemBytes: deps.freeMemBytes()
407
+ });
408
+ if (!pf.ok) {
409
+ reason = pf.code;
410
+ why = pf.why;
411
+ }
412
+ }
413
+ if (!reason) {
414
+ const slot = tryTake(req, slots, deps, staleMs);
415
+ if (slot)
416
+ return slot;
417
+ reason = 'slots';
418
+ why = 'Another process took the slot first, so this run waits for the next one.';
419
+ }
420
+ const info = {
421
+ runId: req.runId,
422
+ registration: req.registration,
423
+ since,
424
+ reason,
425
+ why
426
+ };
427
+ inProcessWaiting.set(id, info);
428
+ if (said !== reason) {
429
+ said = reason;
430
+ opts.onWait?.(info);
431
+ }
432
+ await sleep(pollMs, signal);
433
+ }
434
+ }
435
+ finally {
436
+ inProcessWaiting.delete(id);
437
+ rmSync(file, { force: true });
438
+ }
439
+ }
@@ -3,61 +3,117 @@
3
3
  // and otherwise installed once into ~/.agent-flow-board/checks/runtime. Nothing in this file imports Playwright at load time.
4
4
  // -- DEP-MODULES -- //
5
5
  import { execFile, spawn } from 'node:child_process';
6
- import { existsSync, writeFileSync } from 'node:fs';
6
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { createRequire } from 'node:module';
8
8
  import { dirname, join } from 'node:path';
9
9
  // -- MODULES -- //
10
+ import { acquireLock } from './lock.js';
10
11
  import { checksHome, ensureDir } from './paths.js';
11
12
  // -- CONSTANTS -- //
12
13
  /** The Playwright version the runtime install pins, so a check behaves the same on every machine. */
13
- const PLAYWRIGHT_VERSION = '1.64.0';
14
+ export const PLAYWRIGHT_VERSION = '1.64.0';
14
15
  const INSTALL_TIMEOUT_MS = 10 * 60_000;
16
+ /** Written when the runtime and the Chromium download both finished. Only this note says the install is whole (a download that was cut leaves files behind). */
17
+ const COMPLETE_NOTE = '.complete';
18
+ /** One machine installs once: the lock outlives the longest install, and a waiter gives up after a little longer than that. */
19
+ const INSTALL_LOCK_STALE_MS = 2 * INSTALL_TIMEOUT_MS + 60_000;
20
+ const INSTALL_LOCK_WAIT_MS = 2 * INSTALL_TIMEOUT_MS + 120_000;
15
21
  // -- FUNCTIONS -- //
16
22
  /** Where the runtime install lives. It is a folder of the bridge's own state, never a repo. */
17
23
  const runtimeDir = () => join(checksHome(), 'runtime');
18
24
  const looksInstalled = (nodeModules) => existsSync(join(nodeModules, 'playwright-core', 'package.json'));
25
+ /** The version of the playwright-core in a node_modules folder, or '' when it cannot be read. */
26
+ function versionIn(nodeModules) {
27
+ try {
28
+ const v = JSON.parse(readFileSync(join(nodeModules, 'playwright-core', 'package.json'), 'utf8'));
29
+ return v && typeof v === 'object' ? String(v.version ?? '') : '';
30
+ }
31
+ catch {
32
+ return '';
33
+ }
34
+ }
35
+ /**
36
+ * Which of two Playwright folders to use. The one next to the bridge is used when it is the pinned version; when it is another version and the runtime
37
+ * install holds the pinned one, the runtime copy wins (the browser pool relies on the isolation of that exact version). With neither pinned, the one next to
38
+ * the bridge is returned (assertPlaywrightVersion then says what is wrong), else the runtime copy, else null.
39
+ */
40
+ export function choosePlaywrightDir(beside, runtime, versionOf = versionIn) {
41
+ if (beside && versionOf(beside) === PLAYWRIGHT_VERSION)
42
+ return beside;
43
+ if (runtime && versionOf(runtime) === PLAYWRIGHT_VERSION)
44
+ return runtime;
45
+ return beside ?? runtime;
46
+ }
19
47
  /**
20
- * The node_modules folder that holds playwright-core (and @playwright/test when it is there): next to the bridge first, then the runtime
21
- * install. The sandboxed child is allowed to read this folder and nothing else outside its own. Null when Playwright is not installed anywhere.
48
+ * The node_modules folder that holds playwright-core (and @playwright/test when it is there): next to the bridge first (when it is the pinned version),
49
+ * then the runtime install. The sandboxed child is allowed to read this folder and nothing else outside its own. Null when Playwright is not installed anywhere.
22
50
  */
23
51
  export function playwrightRuntimeDir() {
52
+ let beside = null;
24
53
  try {
25
54
  const pkg = createRequire(import.meta.url).resolve('playwright-core/package.json');
26
55
  // .../node_modules/playwright-core/package.json -> .../node_modules
27
- return dirname(dirname(pkg));
56
+ beside = dirname(dirname(pkg));
28
57
  }
29
58
  catch {
30
59
  /* Not next to the bridge: try the runtime install */
31
60
  }
32
61
  const own = join(runtimeDir(), 'node_modules');
33
- return looksInstalled(own) ? own : null;
62
+ return choosePlaywrightDir(beside, looksInstalled(own) ? own : null);
63
+ }
64
+ /** The folders whose playwright-core version was already compared with the pin. */
65
+ const versionChecked = new Set();
66
+ /**
67
+ * Throws when the playwright-core in `dir` is not the pinned version. The browser pool relies on the isolation of that exact version
68
+ * (every connection to a launched server gets contexts of its own), so another version is refused rather than trusted.
69
+ */
70
+ export function assertPlaywrightVersion(dir) {
71
+ if (versionChecked.has(dir))
72
+ return;
73
+ let found = '';
74
+ try {
75
+ const v = JSON.parse(readFileSync(join(dir, 'playwright-core', 'package.json'), 'utf8'));
76
+ found = v && typeof v === 'object' ? String(v.version ?? '') : '';
77
+ }
78
+ catch {
79
+ /* Unreadable: reported below */
80
+ }
81
+ if (found !== PLAYWRIGHT_VERSION)
82
+ throw new Error(`The check tool needs Playwright ${PLAYWRIGHT_VERSION} but found ${found || 'no readable version'} in ${dir}. Run "agent-flow-board check setup".`);
83
+ versionChecked.add(dir);
34
84
  }
35
85
  /** Loads playwright-core from the folder playwrightRuntimeDir names, by absolute path. */
36
86
  export async function loadPlaywright() {
37
87
  const dir = playwrightRuntimeDir();
38
88
  if (!dir)
39
89
  throw new Error('Playwright is not installed yet. Run "agent-flow-board check setup" and it will install it.');
90
+ assertPlaywrightVersion(dir);
40
91
  const req = createRequire(join(dir, 'package.json'));
41
92
  return req(join(dir, 'playwright-core'));
42
93
  }
94
+ /** The Chromium rule that makes every host unresolvable except the preview's, localhost and the hosts named. */
95
+ export function hostResolverRule(hosts = []) {
96
+ const exclude = [...new Set(['127.0.0.1', 'localhost', ...hosts])]
97
+ .map((h) => `EXCLUDE ${h}`)
98
+ .join(', ');
99
+ return `--host-resolver-rules=MAP * ~NOTFOUND, ${exclude}`;
100
+ }
43
101
  /**
44
102
  * A browser the parent starts, so a check's process only connects to it and never has to start a program itself.
45
103
  * Every host except the preview's (and the hosts named) is unresolvable, whatever a check does with routes.
46
104
  */
47
105
  export async function launchBrowserServer({ headed = false, hosts = [] } = {}) {
48
106
  const { chromium } = await loadPlaywright();
49
- const exclude = [...new Set(['127.0.0.1', 'localhost', ...hosts])]
50
- .map((h) => `EXCLUDE ${h}`)
51
- .join(', ');
107
+ // Never pass the shared-browser option: without it every connection gets contexts of its own and sees none of another connection's
52
108
  const server = await chromium.launchServer({
53
109
  headless: !headed,
54
- args: [
55
- `--host-resolver-rules=MAP * ~NOTFOUND, ${exclude}`,
56
- '--disable-background-networking',
57
- '--no-first-run'
58
- ]
110
+ args: [hostResolverRule(hosts), '--disable-background-networking', '--no-first-run']
59
111
  });
60
- return { wsEndpoint: () => server.wsEndpoint(), close: () => server.close() };
112
+ return {
113
+ wsEndpoint: () => server.wsEndpoint(),
114
+ close: () => server.close(),
115
+ onClose: (callback) => void server.on('close', callback)
116
+ };
61
117
  }
62
118
  /** Says whether an installer failure looks like no network or a proxy, so the one-line error can tell the person what to look at. */
63
119
  export function describeInstallFailure(output) {
@@ -142,24 +198,12 @@ async function installRuntime(log) {
142
198
  throw new Error(`Could not install Playwright: ${describeInstallFailure(out.text)}. ${last}`);
143
199
  }
144
200
  }
145
- /**
146
- * Makes sure Playwright and its Chromium are there. The first use installs them (a few hundred megabytes, with progress lines in `log`);
147
- * later uses return at once. Never prompts. A failure is one Error that says whether it looks like offline or a proxy.
148
- */
149
- export async function ensureBrowser(log = () => { }) {
150
- if (!playwrightRuntimeDir())
151
- await installRuntime(log);
201
+ /** The pinned Playwright folder, or null: the runtime has to be installed (again) when the only copy is another version. */
202
+ function pinnedDir() {
152
203
  const dir = playwrightRuntimeDir();
153
- if (!dir)
154
- throw new Error('Playwright was installed but cannot be found.');
155
- const { chromium } = await loadPlaywright();
156
- try {
157
- if (existsSync(chromium.executablePath()))
158
- return;
159
- }
160
- catch {
161
- /* No executable path known yet: install below */
162
- }
204
+ return dir && versionIn(dir) === PLAYWRIGHT_VERSION ? dir : null;
205
+ }
206
+ async function installChromium(dir, log) {
163
207
  log('Installing the Chromium browser for Playwright (first use only)...');
164
208
  const cli = join(dir, 'playwright-core', 'cli.js');
165
209
  if (!existsSync(cli))
@@ -168,3 +212,64 @@ export async function ensureBrowser(log = () => { }) {
168
212
  if (code !== 0)
169
213
  throw new Error(`Could not install the Chromium browser: ${describeInstallFailure(tail)}. ${tail.split('\n').slice(-2).join(' ').trim().slice(0, 300)}`);
170
214
  }
215
+ const realInstallIo = {
216
+ pinnedDir,
217
+ chromiumPresent: async () => {
218
+ try {
219
+ const { chromium } = await loadPlaywright();
220
+ return existsSync(chromium.executablePath());
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ },
226
+ installRuntime,
227
+ installChromium,
228
+ notePath: () => join(runtimeDir(), COMPLETE_NOTE),
229
+ lockName: 'browser-install',
230
+ pause: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
231
+ };
232
+ /** Takes the install lock without blocking the event loop. Null when another install kept it for longer than a whole install takes. */
233
+ async function takeInstallLock(io, waitMs, staleMs) {
234
+ const until = Date.now() + waitMs;
235
+ for (;;) {
236
+ const lock = acquireLock(io.lockName, { staleMs });
237
+ if (lock)
238
+ return lock;
239
+ if (Date.now() > until)
240
+ return null;
241
+ await io.pause(500);
242
+ }
243
+ }
244
+ /**
245
+ * Makes sure Playwright and its Chromium are there. The first use installs them (a few hundred megabytes, with progress lines in `log`);
246
+ * later uses return at once. Never prompts. A failure is one Error that says whether it looks like offline or a proxy.
247
+ * Two processes never install at once: a lock covers the runtime install and the Chromium download, and a note (.complete) is written only after both finished, so a
248
+ * download that was cut short is done again instead of being trusted.
249
+ */
250
+ export async function ensureBrowser(log = () => { }, io = realInstallIo, limits = {}) {
251
+ const whole = async () => existsSync(io.notePath()) && io.pinnedDir() !== null && (await io.chromiumPresent());
252
+ if (await whole())
253
+ return;
254
+ const lock = await takeInstallLock(io, limits.waitMs ?? INSTALL_LOCK_WAIT_MS, limits.staleMs ?? INSTALL_LOCK_STALE_MS);
255
+ if (!lock)
256
+ throw new Error('Another process has been installing the browser for a long time. Wait for it, or run "agent-flow-board check doctor".');
257
+ try {
258
+ // Whoever held the lock before may have finished the whole install
259
+ if (await whole())
260
+ return;
261
+ rmSync(io.notePath(), { force: true });
262
+ if (!io.pinnedDir())
263
+ await io.installRuntime(log);
264
+ const dir = io.pinnedDir() ?? playwrightRuntimeDir();
265
+ if (!dir)
266
+ throw new Error('Playwright was installed but cannot be found.');
267
+ if (!(await io.chromiumPresent()))
268
+ await io.installChromium(dir, log);
269
+ ensureDir(dirname(io.notePath()));
270
+ writeFileSync(io.notePath(), new Date().toISOString() + '\n');
271
+ }
272
+ finally {
273
+ lock.release();
274
+ }
275
+ }