@agent-flow-board/bridge 0.2.0 → 0.4.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 (54) hide show
  1. package/README.md +26 -0
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +274 -0
  4. package/dist/checks/askBoard.js +143 -0
  5. package/dist/checks/audit.js +3 -2
  6. package/dist/checks/bind.js +3 -2
  7. package/dist/checks/board.js +30 -0
  8. package/dist/checks/broker.js +439 -0
  9. package/dist/checks/browser.js +137 -32
  10. package/dist/checks/browserpool.js +221 -0
  11. package/dist/checks/cache.js +710 -0
  12. package/dist/checks/command.js +126 -87
  13. package/dist/checks/commands.js +315 -0
  14. package/dist/checks/connections.js +4 -3
  15. package/dist/checks/copy.js +320 -0
  16. package/dist/checks/dbpool.js +710 -0
  17. package/dist/checks/doctor.js +655 -0
  18. package/dist/checks/envPolicy.js +50 -1
  19. package/dist/checks/envrules.js +322 -0
  20. package/dist/checks/evidence.js +114 -0
  21. package/dist/checks/fsretry.js +29 -0
  22. package/dist/checks/importlegacy.js +300 -0
  23. package/dist/checks/install.js +150 -0
  24. package/dist/checks/live.js +76 -0
  25. package/dist/checks/lock.js +28 -4
  26. package/dist/checks/login.js +307 -0
  27. package/dist/checks/mailguard-hook.cjs +216 -0
  28. package/dist/checks/mailguard.js +98 -0
  29. package/dist/checks/paths.js +24 -0
  30. package/dist/checks/preview.js +418 -191
  31. package/dist/checks/profiles.js +110 -0
  32. package/dist/checks/provision.js +1 -1
  33. package/dist/checks/recipe.js +19 -5
  34. package/dist/checks/registry.js +548 -0
  35. package/dist/checks/repoenv.js +162 -0
  36. package/dist/checks/roots.js +144 -0
  37. package/dist/checks/routing.js +68 -0
  38. package/dist/checks/run.js +243 -53
  39. package/dist/checks/sandbox.js +32 -10
  40. package/dist/checks/scheduler.js +383 -0
  41. package/dist/checks/scope.js +119 -30
  42. package/dist/checks/sealedNames.js +39 -0
  43. package/dist/checks/secrets.js +454 -0
  44. package/dist/checks/secretscmd.js +80 -0
  45. package/dist/checks/service.js +589 -0
  46. package/dist/checks/signin.js +7 -1
  47. package/dist/checks/signinKey.js +104 -0
  48. package/dist/checks/singleflight.js +307 -0
  49. package/dist/checks/store.js +92 -0
  50. package/dist/checks/surface.js +124 -0
  51. package/dist/checks/warm.js +342 -0
  52. package/dist/checks/watch.js +190 -63
  53. package/dist/checks/wire.js +87 -0
  54. package/package.json +1 -1
@@ -0,0 +1,342 @@
1
+ // WARM PREVIEWS: A PREVIEW THAT STAYS UP A FEW MINUTES SO THE NEXT CHECK ON THE SAME TREE USES IT WITH A FRESH BROWSER CONTEXT.
2
+ // Opt in only (the recipe says "warm": true) and only for an app that cannot leak or lose anything: no database, no mail, no sealed secrets, no adopted .env.
3
+ // The key is a hash of the registration, the tree, the pin, the recipe and the environment, so a changed tree or pin is a different key and never finds the old
4
+ // preview. Each warm preview has a marker under poolDir()/warm (owner process, preview process, port, expiry) and one small file per user, so the count of users
5
+ // survives separate processes. The preview is stopped by its owner when it expires, when the worker stops (stopAll), and by doctor (sweepWarm) when its owner is
6
+ // gone or it expired. Only process ids recorded in a marker are ever killed, and only with killTree.
7
+ // -- DEP-MODULES -- //
8
+ import { createHash, randomBytes } from 'node:crypto';
9
+ import { linkSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ // -- MODULES -- //
12
+ import { pidAlive } from './lock.js';
13
+ import { ensureDir, poolDir } from './paths.js';
14
+ import { renameRetry } from './fsretry.js';
15
+ import { killTree } from './scope.js';
16
+ // -- CONSTANTS -- //
17
+ /** How long a warm preview stays up after its last user, 5 minutes. AFB_CHECKS_WARM_TTL_MS overrides. */
18
+ export const warmTtlMs = () => {
19
+ const v = Number(process.env.AFB_CHECKS_WARM_TTL_MS);
20
+ return Number.isFinite(v) && v > 0 ? v : 5 * 60_000;
21
+ };
22
+ const OWNER_START = Date.now() - Math.round(process.uptime() * 1000);
23
+ // -- FUNCTIONS -- //
24
+ /** The key of a warm preview. Any part empty (the registration, the tree or the recipe) is refused: such a key would match too much. */
25
+ export function warmKey(p) {
26
+ if (!p.registration || !p.treeId || !p.recipeHash)
27
+ throw new Error('A warm key needs a registration, a tree and a recipe.');
28
+ const h = createHash('sha256');
29
+ for (const part of [p.registration, p.treeId, p.pin ?? '', p.recipeHash, p.envHash ?? ''])
30
+ h.update(`${part.length}:${part}\n`);
31
+ return h.digest('hex').slice(0, 40);
32
+ }
33
+ /** Whether a run may use a warm preview, and the plain reason when it may not. */
34
+ export function warmAllowed(i) {
35
+ if (!i.recipeWarm)
36
+ return { allowed: false, why: 'The recipe does not ask for a warm preview.' };
37
+ if (i.adoptEnv)
38
+ return {
39
+ allowed: false,
40
+ why: 'A warm preview is never used when the registration adopts its .env.'
41
+ };
42
+ if (i.sealed)
43
+ return { allowed: false, why: 'A warm preview is never used with sealed secrets.' };
44
+ if (i.hasDatabase)
45
+ return { allowed: false, why: 'A warm preview is never used for an app with a database.' };
46
+ if (i.hasMailOrDbVars)
47
+ return {
48
+ allowed: false,
49
+ why: 'A warm preview is never used when the app has mail or database settings.'
50
+ };
51
+ if (i.readOnly === false)
52
+ return { allowed: false, why: 'A warm preview is only used for read-only checks.' };
53
+ return { allowed: true };
54
+ }
55
+ const KEY = /^[a-f0-9]{40}$/;
56
+ function readText(file) {
57
+ try {
58
+ return readFileSync(file, 'utf8');
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ function parseMarker(text) {
65
+ if (text === null)
66
+ return null;
67
+ try {
68
+ const v = JSON.parse(text);
69
+ if (!v || v.v !== 1 || typeof v.key !== 'string' || !KEY.test(v.key))
70
+ return null;
71
+ if (typeof v.ownerPid !== 'number' ||
72
+ typeof v.expiresAt !== 'number' ||
73
+ typeof v.port !== 'number')
74
+ return null;
75
+ if (typeof v.baseUrl !== 'string')
76
+ return null;
77
+ return v;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ function writeWhole(file, text, exclusive) {
84
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
85
+ writeFileSync(tmp, text, { mode: 0o600 });
86
+ try {
87
+ if (exclusive)
88
+ linkSync(tmp, file);
89
+ else
90
+ renameRetry(tmp, file);
91
+ return true;
92
+ }
93
+ catch (e) {
94
+ if (exclusive && e.code === 'EEXIST')
95
+ return false;
96
+ if (!exclusive)
97
+ return false;
98
+ throw e;
99
+ }
100
+ finally {
101
+ rmSync(tmp, { force: true });
102
+ }
103
+ }
104
+ export class WarmStore {
105
+ deps;
106
+ /** The previews this process started and still owns. Only the owner stops one through its handle. */
107
+ owned = new Map();
108
+ timer = null;
109
+ constructor(deps = {}) {
110
+ this.deps = {
111
+ dir: deps.dir ?? join(poolDir(), 'warm'),
112
+ now: deps.now ?? (() => Date.now()),
113
+ pidAlive: deps.pidAlive ?? pidAlive,
114
+ kill: deps.kill ?? killTree,
115
+ ttlMs: deps.ttlMs ?? warmTtlMs()
116
+ };
117
+ }
118
+ marker = (key) => join(this.deps.dir, `${key}.json`);
119
+ useDir = (key) => join(this.deps.dir, `${key}.use`);
120
+ assertKey(key) {
121
+ if (!KEY.test(key))
122
+ throw new Error('That is not a warm key.');
123
+ }
124
+ /** How many live users the preview has. A use file of a dead process is removed. */
125
+ users(key) {
126
+ this.assertKey(key);
127
+ let n = 0;
128
+ let names;
129
+ try {
130
+ names = readdirSync(this.useDir(key));
131
+ }
132
+ catch {
133
+ return 0;
134
+ }
135
+ for (const name of names) {
136
+ const file = join(this.useDir(key), name);
137
+ let pid;
138
+ try {
139
+ pid = JSON.parse(readText(file) ?? '{}').pid ?? 0;
140
+ }
141
+ catch {
142
+ pid = 0;
143
+ }
144
+ if (pid && this.deps.pidAlive(pid))
145
+ n++;
146
+ else
147
+ rmSync(file, { force: true });
148
+ }
149
+ return n;
150
+ }
151
+ addUser(key) {
152
+ ensureDir(this.useDir(key));
153
+ const id = randomBytes(6).toString('hex');
154
+ writeFileSync(join(this.useDir(key), `${id}.json`), JSON.stringify({ pid: process.pid }), {
155
+ mode: 0o600
156
+ });
157
+ return id;
158
+ }
159
+ lease(m, id) {
160
+ let released = false;
161
+ return {
162
+ key: m.key,
163
+ baseUrl: m.baseUrl,
164
+ port: m.port,
165
+ release: () => {
166
+ if (released)
167
+ return;
168
+ released = true;
169
+ rmSync(join(this.useDir(m.key), `${id}.json`), { force: true });
170
+ // The last user starts the time to live
171
+ if (this.users(m.key) === 0) {
172
+ const cur = parseMarker(readText(this.marker(m.key)));
173
+ if (cur)
174
+ writeWhole(this.marker(m.key), JSON.stringify({ ...cur, expiresAt: this.deps.now() + this.deps.ttlMs }) + '\n', false);
175
+ }
176
+ }
177
+ };
178
+ }
179
+ /** Whether the marker describes a preview that can be used right now. */
180
+ usable(m) {
181
+ if (!this.deps.pidAlive(m.ownerPid))
182
+ return false;
183
+ if (m.previewPid && !this.deps.pidAlive(m.previewPid))
184
+ return false;
185
+ return this.deps.now() < m.expiresAt || this.users(m.key) > 0;
186
+ }
187
+ /** The warm preview of this exact key, or null. A different tree, pin, recipe or environment is a different key and finds nothing. */
188
+ acquire(key) {
189
+ this.assertKey(key);
190
+ const m = parseMarker(readText(this.marker(key)));
191
+ if (!m || m.key !== key || !this.usable(m))
192
+ return null;
193
+ const id = this.addUser(key);
194
+ // The marker may have been swept between the look and the use file: then there is nothing to use
195
+ const again = parseMarker(readText(this.marker(key)));
196
+ if (!again || again.key !== key) {
197
+ rmSync(join(this.useDir(key), `${id}.json`), { force: true });
198
+ return null;
199
+ }
200
+ return this.lease(again, id);
201
+ }
202
+ /**
203
+ * Records a running preview as the warm preview of `key` and counts the caller as its first user. Returns null when a usable one is there already (two runs built
204
+ * the same tree at once): the caller keeps its own preview as a plain one and stops it when it is done.
205
+ */
206
+ register(key, preview, registration) {
207
+ this.assertKey(key);
208
+ ensureDir(this.deps.dir);
209
+ const existing = parseMarker(readText(this.marker(key)));
210
+ if (existing && this.usable(existing))
211
+ return null;
212
+ if (existing)
213
+ rmSync(this.marker(key), { force: true });
214
+ const now = this.deps.now();
215
+ const m = {
216
+ v: 1,
217
+ key,
218
+ registration,
219
+ baseUrl: preview.baseUrl,
220
+ port: preview.port,
221
+ previewPid: preview.pid ?? null,
222
+ ownerPid: process.pid,
223
+ ownerStart: OWNER_START,
224
+ createdAt: now,
225
+ expiresAt: now + this.deps.ttlMs
226
+ };
227
+ if (!writeWhole(this.marker(key), JSON.stringify(m) + '\n', true))
228
+ return null;
229
+ this.owned.set(key, preview);
230
+ this.watch();
231
+ return this.lease(m, this.addUser(key));
232
+ }
233
+ /** Looks at every marker: an expired preview with no user, or one whose owner is gone, is stopped and forgotten. Returns how many were stopped. */
234
+ async sweep() {
235
+ let names;
236
+ try {
237
+ names = readdirSync(this.deps.dir).filter((f) => f.endsWith('.json'));
238
+ }
239
+ catch {
240
+ return 0;
241
+ }
242
+ let stopped = 0;
243
+ for (const name of names) {
244
+ const key = name.slice(0, -'.json'.length);
245
+ if (!KEY.test(key))
246
+ continue;
247
+ const m = parseMarker(readText(this.marker(key)));
248
+ if (!m || m.key !== key) {
249
+ // Damaged: no pid can be trusted, so only the file goes
250
+ rmSync(this.marker(key), { force: true });
251
+ continue;
252
+ }
253
+ const ownerGone = !this.deps.pidAlive(m.ownerPid);
254
+ const previewGone = m.previewPid !== null && !this.deps.pidAlive(m.previewPid);
255
+ const expired = this.deps.now() >= m.expiresAt && this.users(key) === 0;
256
+ if (!ownerGone && !previewGone && !expired)
257
+ continue;
258
+ await this.stop(key, m);
259
+ stopped++;
260
+ }
261
+ return stopped;
262
+ }
263
+ async stop(key, m) {
264
+ const handle = this.owned.get(key);
265
+ this.owned.delete(key);
266
+ try {
267
+ if (handle)
268
+ await handle.stop();
269
+ else if (m.previewPid && m.previewPid !== process.pid && this.deps.pidAlive(m.previewPid))
270
+ await this.deps.kill(m.previewPid);
271
+ }
272
+ catch {
273
+ /* A preview that will not stop is left to the next sweep: the marker is kept */
274
+ if (handle || (m.previewPid && this.deps.pidAlive(m.previewPid))) {
275
+ if (handle)
276
+ this.owned.set(key, handle);
277
+ return;
278
+ }
279
+ }
280
+ rmSync(this.marker(key), { force: true });
281
+ rmSync(this.useDir(key), { recursive: true, force: true });
282
+ }
283
+ /** Stops every preview this process owns (the worker is stopping). */
284
+ async stopAll() {
285
+ if (this.timer)
286
+ clearInterval(this.timer);
287
+ this.timer = null;
288
+ let n = 0;
289
+ for (const [key, handle] of [...this.owned]) {
290
+ this.owned.delete(key);
291
+ try {
292
+ await handle.stop();
293
+ }
294
+ catch {
295
+ /* The sweep of the next worker or doctor stops it by its recorded process id */
296
+ continue;
297
+ }
298
+ rmSync(this.marker(key), { force: true });
299
+ rmSync(this.useDir(key), { recursive: true, force: true });
300
+ n++;
301
+ }
302
+ return n;
303
+ }
304
+ /** The owner looks at its previews now and then, so an expired one is stopped without anybody asking. */
305
+ watch() {
306
+ if (this.timer)
307
+ return;
308
+ const every = Math.max(1_000, Math.min(30_000, Math.floor(this.deps.ttlMs / 4)));
309
+ this.timer = setInterval(() => void this.sweep().catch(() => undefined), every);
310
+ this.timer.unref();
311
+ }
312
+ /** The markers on disk, for doctor and status. */
313
+ list() {
314
+ try {
315
+ return readdirSync(this.deps.dir)
316
+ .filter((f) => f.endsWith('.json'))
317
+ .map((f) => parseMarker(readText(join(this.deps.dir, f))))
318
+ .filter((m) => m !== null);
319
+ }
320
+ catch {
321
+ return [];
322
+ }
323
+ }
324
+ }
325
+ const stores = new Map();
326
+ /** The warm store of this machine's pool folder. One object per folder, so the previews this process owns are found again. */
327
+ export function warmStore() {
328
+ const dir = join(poolDir(), 'warm');
329
+ let s = stores.get(dir);
330
+ if (!s) {
331
+ s = new WarmStore({ dir });
332
+ stores.set(dir, s);
333
+ }
334
+ return s;
335
+ }
336
+ export const acquireWarm = (key, store = warmStore()) => store.acquire(key);
337
+ export const releaseWarm = (lease) => lease.release();
338
+ export const registerWarm = (key, preview, registration, store = warmStore()) => store.register(key, preview, registration);
339
+ /** Called when the worker stops. */
340
+ export const stopAll = (store = warmStore()) => store.stopAll();
341
+ /** Called by doctor: stops expired previews and the previews of owners that are gone. */
342
+ export const sweepWarm = (store = warmStore()) => store.sweep();
@@ -1,101 +1,228 @@
1
- // `check watch`: TAKE QUEUED RUNS FROM EVERY CONNECTED BOARD (THE RUN BUTTON ON THE BOARD)
2
- // One worker per machine. A queued run is taken only for a project that has a local binding (a folder where `check run` was used) and whose
3
- // pinned files have not changed since: with none, the run ends "could not run" and the current directory is never a fallback.
1
+ // `check watch`: TAKE QUEUED RUNS FROM EVERY CONNECTED BOARD (THE RUN BUTTON ON THE BOARD), ON THE MACHINE'S SLOTS
2
+ // One worker per machine. The scheduler (scheduler.ts) decides how many runs to take: never more than the machine's slots, none while the disk or memory preflight
3
+ // fails, registrations taking turns, a heartbeat to the board from the moment of the claim, a 40 minute limit, and only its own held runs given back when it stops.
4
+ // A queued run for a project with a registration is routed by the registration rules (run.ts); a project with none runs in the folder where `check run` was used
5
+ // before, and with neither the run ends "could not run" and the current directory is never a fallback.
6
+ // -- DEP-MODULES -- //
7
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
4
9
  // -- MODULES -- //
5
10
  import { appendAudit } from './audit.js';
6
11
  import { boundFolder, pinStatus } from './bind.js';
7
- import { RunnerBoard } from './board.js';
12
+ import { BoardError, RunnerBoard } from './board.js';
13
+ import { acquireSlot, canTake, machineTotals, maxSlots } from './broker.js';
14
+ import { closeAllBrowsers } from './browserpool.js';
8
15
  import { listConnections } from './connections.js';
9
16
  import { loadRunnerToken } from './connect.js';
17
+ import { closePoolIfIdle, configureDatabasePool } from './dbpool.js';
10
18
  import { machineLock } from './lock.js';
19
+ import { checksHome } from './paths.js';
20
+ import { renameRetry } from './fsretry.js';
21
+ import { readRegistrations } from './registry.js';
11
22
  import { runCheck } from './run.js';
23
+ import { createScheduler } from './scheduler.js';
12
24
  import { sweepOrphans } from './scope.js';
13
25
  // -- CONSTANTS -- //
14
- const POLL_MS = 5000;
15
- const sleep = (ms, signal) => new Promise((resolve) => {
16
- const t = setTimeout(resolve, ms);
17
- signal.addEventListener('abort', () => {
18
- clearTimeout(t);
19
- resolve();
20
- }, { once: true });
21
- });
26
+ const STATUS_EVERY_MS = 15_000;
27
+ /** The line the worker prints for a terminal stop. */
28
+ export const terminalLine = (kind) => `AFB_TERMINAL: ${kind}`;
29
+ // -- FUNCTIONS -- //
30
+ /** worker.json: what the service and `status` read to see whether the worker is busy. Written whole, never half. */
31
+ function writeWorkerStatus(slots, runs) {
32
+ try {
33
+ const file = join(checksHome(), 'worker.json');
34
+ mkdirSync(checksHome(), { recursive: true });
35
+ const tmp = `${file}.${process.pid}.tmp`;
36
+ const totals = machineTotals();
37
+ writeFileSync(tmp, JSON.stringify({
38
+ pid: process.pid,
39
+ slots,
40
+ busy: totals.busy,
41
+ runs,
42
+ at: new Date().toISOString()
43
+ }));
44
+ renameRetry(tmp, file);
45
+ }
46
+ catch {
47
+ /* A status that cannot be written must never stop the worker */
48
+ }
49
+ }
22
50
  /** Runs the worker until the signal aborts (or one pass with `once`). Returns how many runs it took. */
23
51
  export async function watchChecks(o) {
24
52
  const lock = machineLock('watch');
25
53
  if (!lock)
26
54
  throw new Error('Another "check watch" is already running on this machine. Only one worker runs per machine.');
27
- const slots = Math.max(1, Math.min(o.parallel, 8));
28
- const held = new Map();
55
+ const slots = maxSlots(o.parallel);
56
+ // The database pool is sized to the slots (its max_connections) and kept warm by this worker between runs; it is closed when the worker stops
57
+ configureDatabasePool({ slots, removeWhenIdle: false });
58
+ const owners = new Map();
29
59
  let taken = 0;
60
+ // A stop no restart can fix: the worker prints one sentinel line, then the plain sentence, and ends with that sentence as its error
61
+ const inner = new AbortController();
62
+ let terminal = null;
63
+ const stopFor = (kind, message) => {
64
+ if (terminal)
65
+ return;
66
+ terminal = { kind, message };
67
+ o.log(terminalLine(kind));
68
+ o.log(message);
69
+ inner.abort();
70
+ };
71
+ if (o.signal.aborted)
72
+ inner.abort();
73
+ else
74
+ o.signal.addEventListener('abort', () => inner.abort(), { once: true });
75
+ // The boards that answered 401, by the fingerprint of the token they refused: a new `check connect` gives a new token and clears it
76
+ const refused = new Map();
77
+ let known = new Map();
78
+ const noteBoardError = (key, fingerprint, e) => {
79
+ if (!(e instanceof BoardError))
80
+ return;
81
+ if (e.code === 'UPGRADE_REQUIRED') {
82
+ stopFor('needs-newer', `This machine needs a newer bridge to talk to this board. ${e.message}`);
83
+ }
84
+ else if (e.status === 401) {
85
+ refused.set(key, fingerprint);
86
+ if ([...known].every(([k, fp]) => refused.get(k) === fp))
87
+ stopFor('rejected', 'The board no longer accepts this machine for checks. Run: agent-flow-board check connect --server <url> --org <slug> --json');
88
+ }
89
+ };
90
+ // The same beat refreshes the worker lock, so a crashed worker whose process id was reused does not lock out the next `check watch`
91
+ const status = setInterval(() => {
92
+ writeWorkerStatus(slots, taken);
93
+ lock.touch();
94
+ }, STATUS_EVERY_MS);
95
+ status.unref();
30
96
  try {
97
+ // A worker with nothing to serve is not a worker that will have something later: a restart cannot fix it, so it says so and stops (not for one pass with --once)
98
+ if (!o.once) {
99
+ const all = listConnections();
100
+ if (!all.length)
101
+ stopFor('not-set-up', 'Not set up: this machine is not connected to any board. Run: agent-flow-board check connect --server <url> --org <slug> --json');
102
+ else if (!all.some((c) => loadRunnerToken(o.store, c.server, c.org)))
103
+ stopFor('not-connected', 'Not connected: no connection of this machine has a runner token. Run: agent-flow-board check connect --server <url> --org <slug> --json');
104
+ if (terminal)
105
+ throw new Error(terminal.message);
106
+ }
31
107
  await sweepOrphans(o.log);
32
108
  appendAudit('watch-start', { slots });
33
- do {
109
+ writeWorkerStatus(slots, 0);
110
+ const scheduler = createScheduler({
34
111
  // The connections are read again on every loop, so a new `check connect` is picked up without a restart
35
- for (const c of listConnections()) {
36
- if (held.size >= slots || o.signal.aborted)
37
- break;
38
- const token = loadRunnerToken(o.store, c.server, c.org);
39
- if (!token)
40
- continue;
41
- const board = new RunnerBoard(c.server, token, o.fetch, () => ({
42
- 'x-afb-tool-slots': `${slots}:${held.size}`
43
- }));
44
- let job = null;
45
- try {
46
- if ((await board.peek()) > 0)
47
- job = await board.claimNext();
48
- }
49
- catch (e) {
50
- o.log(`${c.org}: ${e instanceof Error ? e.message : String(e)}`);
112
+ connections: () => {
113
+ known = new Map();
114
+ return listConnections().flatMap((c) => {
115
+ const token = loadRunnerToken(o.store, c.server, c.org);
116
+ if (!token)
117
+ return [];
118
+ const key = `${c.server}|${c.org}`;
119
+ const fingerprint = token.slice(-8);
120
+ known.set(key, fingerprint);
121
+ const board = new RunnerBoard(c.server, token, o.fetch, () => ({
122
+ 'x-afb-tool-slots': `${machineTotals().slots}:${machineTotals().busy}`
123
+ }));
124
+ return [
125
+ {
126
+ key,
127
+ fingerprint,
128
+ peek: () => board.peek().catch((e) => {
129
+ noteBoardError(key, fingerprint, e);
130
+ throw e;
131
+ }),
132
+ claim: async () => {
133
+ const job = await board.claimNext().catch((e) => {
134
+ noteBoardError(key, fingerprint, e);
135
+ throw e;
136
+ });
137
+ if (job) {
138
+ owners.set(job.id, { server: c.server, org: c.org, token });
139
+ taken++;
140
+ }
141
+ return job;
142
+ },
143
+ beat: (id) => board.beat(id),
144
+ release: (ids) => board.release(ids)
145
+ }
146
+ ];
147
+ });
148
+ },
149
+ runner: {
150
+ // Copy, install and build happen inside the run itself; the overlap of the next run's build with this run's check is decided by the benchmark (step 5.7)
151
+ prepare: async () => null,
152
+ execute: async (job, _prepared, ctx) => {
153
+ const owner = owners.get(job.id);
154
+ if (!owner)
155
+ throw new Error('The run is not known to this worker.');
156
+ await handle(o, owner, job, ctx.log, ctx.signal);
157
+ },
158
+ fail: async (job, error) => {
159
+ const owner = owners.get(job.id);
160
+ if (!owner)
161
+ return;
162
+ await new RunnerBoard(owner.server, owner.token, o.fetch)
163
+ .finish(job.id, { result: 'error', reason: error.message.slice(0, 1900) })
164
+ .catch(() => undefined);
165
+ },
166
+ cleanup: async (job) => {
167
+ owners.delete(job.id);
51
168
  }
52
- if (!job)
53
- continue;
54
- taken++;
55
- const claimed = job;
56
- const done = handle(o, c.server, c.org, token, claimed).finally(() => held.delete(claimed.id));
57
- held.set(claimed.id, { board, done });
58
- }
59
- if (o.once)
60
- break;
61
- await sleep(POLL_MS, o.signal);
62
- } while (!o.signal.aborted);
63
- await Promise.allSettled([...held.values()].map((h) => h.done));
169
+ },
170
+ broker: {
171
+ acquire: (req, opts) => acquireSlot(req, opts),
172
+ ready: () => canTake(),
173
+ totals: () => machineTotals()
174
+ },
175
+ log: o.log
176
+ }, { slots, prefetch: 0 });
177
+ await scheduler.run({ signal: inner.signal, once: o.once });
178
+ // A worker that stopped for a reason a restart cannot fix ends with that reason, so the command exits with it
179
+ if (terminal)
180
+ throw new Error(terminal.message);
64
181
  return taken;
65
182
  }
66
183
  finally {
67
- // Runs still held when the worker stops go back to the queue, only these, never another process's runs
68
- const byBoard = new Map();
69
- for (const [id, h] of held)
70
- byBoard.set(h.board, [...(byBoard.get(h.board) ?? []), id]);
71
- for (const [board, ids] of byBoard)
72
- await board.release(ids).catch(() => undefined);
184
+ clearInterval(status);
185
+ // The scheduler gave back only its own unreported runs. The browsers and the database pool container this worker kept warm are closed now
186
+ await closeAllBrowsers().catch(() => undefined);
187
+ await closePoolIfIdle().catch(() => undefined);
73
188
  appendAudit('watch-stop', { taken });
74
189
  lock.release();
75
190
  }
76
191
  }
77
- async function handle(o, server, org, token, job) {
78
- const board = new RunnerBoard(server, token, o.fetch);
192
+ async function handle(o, owner, job, log, signal) {
193
+ const board = new RunnerBoard(owner.server, owner.token, o.fetch);
79
194
  const fail = (reason) => board.finish(job.id, { result: 'error', reason }).catch(() => undefined);
80
195
  const projectKey = job.project.key;
81
- const bound = boundFolder(org, projectKey);
82
- if (!bound)
83
- return void (await fail(`This machine has no folder for ${projectKey}. In the project's folder run: agent-flow-board check run ${job.item.ref} ${job.check.key} --org ${org}`));
84
- if (pinStatus(org, projectKey) !== 'ok')
85
- return void (await fail(`The files that decide what runs in ${bound.folder} changed since they were approved. In that folder run: agent-flow-board check run ${job.item.ref} ${job.check.key} --org ${org}`));
86
- o.log(`${org}: running ${job.item.ref} ${job.check.key} in ${bound.folder}`);
196
+ const read = readRegistrations(owner.org);
197
+ const registered = read.registrations.some((r) => r.boardProject === projectKey);
198
+ let dir = process.cwd();
199
+ if (!registered && read.problems.length)
200
+ // A registration that is damaged or was changed by hand is not "no registration": the folder of an earlier `check run` is not taken in its place
201
+ return void (await fail(`${read.problems.join(' ')} No other registration serves ${projectKey}, so the run was not started. Register the project again with: agent-flow-board check project`));
202
+ if (!registered) {
203
+ // No registration for this project: the folder where `check run` was used before, and only if its files did not change since
204
+ const bound = boundFolder(owner.org, projectKey);
205
+ if (!bound || !existsSync(bound.folder))
206
+ return void (await fail(`This machine has no folder for ${projectKey}. In the project's folder run: agent-flow-board check run ${job.item.ref} ${job.check.key} --org ${owner.org}`));
207
+ if (pinStatus(owner.org, projectKey) !== 'ok')
208
+ return void (await fail(`The files that decide what runs in ${bound.folder} changed since they were approved. In that folder run: agent-flow-board check run ${job.item.ref} ${job.check.key} --org ${owner.org}`));
209
+ dir = bound.folder;
210
+ }
211
+ log(`${owner.org}: running ${job.item.ref} ${job.check.key}`);
87
212
  const outcome = await runCheck({
88
- server,
89
- org,
213
+ server: owner.server,
214
+ org: owner.org,
90
215
  item: job.item.ref,
91
216
  key: job.check.key,
92
- dir: bound.folder,
217
+ dir,
93
218
  step: job.stepKey ?? undefined,
94
219
  claimed: job,
95
- runnerToken: token,
220
+ scheduled: true,
221
+ signal,
222
+ runnerToken: owner.token,
96
223
  personalToken: null,
97
224
  fetch: o.fetch,
98
- log: o.log
225
+ log
99
226
  });
100
- o.log(`${org}: ${job.item.ref} ${job.check.key}: ${outcome.message}`);
227
+ log(`${owner.org}: ${job.item.ref} ${job.check.key}: ${outcome.message}`);
101
228
  }