@agent-flow-board/bridge 0.1.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 (62) hide show
  1. package/README.md +29 -1
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +213 -0
  4. package/dist/checks/audit.js +126 -0
  5. package/dist/checks/bind.js +100 -0
  6. package/dist/checks/board.js +107 -0
  7. package/dist/checks/broker.js +439 -0
  8. package/dist/checks/browser.js +275 -0
  9. package/dist/checks/browserpool.js +221 -0
  10. package/dist/checks/cache.js +710 -0
  11. package/dist/checks/child.js +211 -0
  12. package/dist/checks/command.js +267 -0
  13. package/dist/checks/commands.js +219 -0
  14. package/dist/checks/connect.js +79 -0
  15. package/dist/checks/connections.js +42 -0
  16. package/dist/checks/copy.js +320 -0
  17. package/dist/checks/dbpool.js +710 -0
  18. package/dist/checks/doctor.js +649 -0
  19. package/dist/checks/envPolicy.js +212 -0
  20. package/dist/checks/envrules.js +322 -0
  21. package/dist/checks/evidence.js +114 -0
  22. package/dist/checks/fsretry.js +29 -0
  23. package/dist/checks/guard.js +188 -0
  24. package/dist/checks/guardrails.js +155 -0
  25. package/dist/checks/importlegacy.js +300 -0
  26. package/dist/checks/install.js +150 -0
  27. package/dist/checks/live.js +76 -0
  28. package/dist/checks/lock.js +207 -0
  29. package/dist/checks/login.js +203 -0
  30. package/dist/checks/mailguard-hook.cjs +216 -0
  31. package/dist/checks/mailguard.js +98 -0
  32. package/dist/checks/paths.js +85 -0
  33. package/dist/checks/preview.js +577 -0
  34. package/dist/checks/profiles.js +110 -0
  35. package/dist/checks/provision.js +175 -0
  36. package/dist/checks/recipe.js +246 -0
  37. package/dist/checks/redact.js +52 -0
  38. package/dist/checks/registry.js +548 -0
  39. package/dist/checks/repoenv.js +162 -0
  40. package/dist/checks/roots.js +144 -0
  41. package/dist/checks/routing.js +68 -0
  42. package/dist/checks/run.js +443 -0
  43. package/dist/checks/sandbox.js +208 -0
  44. package/dist/checks/scheduler.js +383 -0
  45. package/dist/checks/scope.js +383 -0
  46. package/dist/checks/sealedNames.js +39 -0
  47. package/dist/checks/secrets.js +452 -0
  48. package/dist/checks/secretscmd.js +80 -0
  49. package/dist/checks/service.js +589 -0
  50. package/dist/checks/signin.js +79 -0
  51. package/dist/checks/singleflight.js +307 -0
  52. package/dist/checks/store.js +92 -0
  53. package/dist/checks/surface.js +124 -0
  54. package/dist/checks/types.js +2 -0
  55. package/dist/checks/warm.js +342 -0
  56. package/dist/checks/watch.js +228 -0
  57. package/dist/checks/wire.js +87 -0
  58. package/dist/cli.js +18 -1
  59. package/dist/hooks/adapters.js +3 -1
  60. package/dist/hooks/call.js +6 -1
  61. package/dist/hooks/modVersion.js +26 -0
  62. package/package.json +2 -2
@@ -0,0 +1,383 @@
1
+ // THE WORKER'S SCHEDULING LOGIC, PURE: EVERYTHING IT TOUCHES (THE BOARD CONNECTIONS, THE RUNNER, THE MACHINE BROKER, THE SLEEP) IS INJECTED, SO IT IS TESTED WITH FAKES.
2
+ // What it decides: how many runs to take (never more than the slots plus the runs it may prepare ahead), to leave a run UNCLAIMED on the board while the machine is
3
+ // overloaded, to claim as late as possible (right before the work starts), to beat the board from the moment of the claim through every waiting phase, to let the next
4
+ // run install and build while the current one runs its check (two stages per run, `prepare` and `execute`), to let registrations take turns, to stop a run that
5
+ // outlives its limit, and to give back only its own held runs when it stops. The board stops a claimed run that does not beat for 10 minutes (BEAT_TIMEOUT_MINUTES in
6
+ // the board's checks.ts), so the first beat goes at claim time, not when the work starts, and the beat never depends on the stage.
7
+ // -- CONSTANTS -- //
8
+ const num = (name, fallback) => {
9
+ const v = Number(process.env[name]);
10
+ return Number.isFinite(v) && v > 0 ? v : fallback;
11
+ };
12
+ /** Idle poll, 5 s. AFB_CHECKS_POLL_MS overrides (the tests). */
13
+ export const pollMs = () => num('AFB_CHECKS_POLL_MS', 5_000);
14
+ /** Longest backoff after errors, 60 s. */
15
+ export const backoffMaxMs = () => num('AFB_CHECKS_BACKOFF_MAX_MS', 60_000);
16
+ /** A beat every 30 s. The board reports a run stuck after 10 minutes without one. */
17
+ export const beatMs = () => num('AFB_CHECKS_BEAT_MS', 30_000);
18
+ /** The longest one run may take, builds and waiting included: 40 minutes. */
19
+ export const runLimitMs = () => num('AFB_CHECKS_RUN_LIMIT_MS', 40 * 60_000);
20
+ // -- FUNCTIONS -- //
21
+ const defaultSleep = (ms, signal) => new Promise((resolve) => {
22
+ const t = setTimeout(done, ms);
23
+ function done() {
24
+ clearTimeout(t);
25
+ signal.removeEventListener('abort', done);
26
+ resolve();
27
+ }
28
+ signal.addEventListener('abort', done, { once: true });
29
+ });
30
+ /** Waits for a promise to settle, but not longer than ms. The timer is cleared the moment it settles, so it never keeps the process alive. */
31
+ const settleWithin = (p, ms) => new Promise((resolve) => {
32
+ const t = setTimeout(resolve, ms);
33
+ p.then(() => clearTimeout(t), () => clearTimeout(t)).finally(resolve);
34
+ });
35
+ const message = (e) => (e instanceof Error ? e.message : String(e));
36
+ const toError = (e) => (e instanceof Error ? e : new Error(String(e)));
37
+ function withTimeout(p, ms, what) {
38
+ return new Promise((resolve, reject) => {
39
+ const t = setTimeout(() => reject(new Error(`${what} did not answer within ${Math.round(ms / 1000)} seconds.`)), ms);
40
+ p.then((v) => {
41
+ clearTimeout(t);
42
+ resolve(v);
43
+ }, (e) => {
44
+ clearTimeout(t);
45
+ reject(toError(e));
46
+ });
47
+ });
48
+ }
49
+ /** A gate of `limit` places. When one frees, the waiter whose key holds the fewest places goes first (ties by arrival): registrations take turns. */
50
+ export class FairGate {
51
+ limit;
52
+ holders = new Map();
53
+ waiters = [];
54
+ seq = 0;
55
+ inside = 0;
56
+ constructor(limit) {
57
+ this.limit = limit;
58
+ }
59
+ acquire(key, signal) {
60
+ if (signal?.aborted)
61
+ return Promise.reject(Object.assign(new Error('Stopped.'), { name: 'AbortError' }));
62
+ return new Promise((resolve, reject) => {
63
+ const w = {
64
+ key,
65
+ seq: this.seq++,
66
+ go: () => {
67
+ signal?.removeEventListener('abort', onAbort);
68
+ this.inside++;
69
+ this.holders.set(key, (this.holders.get(key) ?? 0) + 1);
70
+ let freed = false;
71
+ resolve(() => {
72
+ if (freed)
73
+ return;
74
+ freed = true;
75
+ this.inside--;
76
+ this.holders.set(key, (this.holders.get(key) ?? 1) - 1);
77
+ this.next();
78
+ });
79
+ }
80
+ };
81
+ const onAbort = () => {
82
+ this.waiters = this.waiters.filter((x) => x !== w);
83
+ reject(Object.assign(new Error('Stopped.'), { name: 'AbortError' }));
84
+ };
85
+ signal?.addEventListener('abort', onAbort, { once: true });
86
+ this.waiters.push(w);
87
+ this.next();
88
+ });
89
+ }
90
+ next() {
91
+ while (this.inside < this.limit && this.waiters.length) {
92
+ this.waiters.sort((a, b) => (this.holders.get(a.key) ?? 0) - (this.holders.get(b.key) ?? 0) || a.seq - b.seq);
93
+ this.waiters.shift()?.go();
94
+ }
95
+ }
96
+ }
97
+ export function createScheduler(deps, o) {
98
+ const slots = Math.max(1, Math.floor(o.slots));
99
+ const prefetch = Math.max(0, Math.floor(o.prefetch ?? 1));
100
+ const poll = o.pollMs ?? pollMs();
101
+ const backoffMax = o.backoffMaxMs ?? backoffMaxMs();
102
+ const beatEvery = o.beatMs ?? beatMs();
103
+ const limitMs = o.runLimitMs ?? runLimitMs();
104
+ const callMs = o.callTimeoutMs ?? 25_000;
105
+ const stepWait = o.stepWaitMs ?? 3_000;
106
+ const grace = o.graceMs ?? 5_000;
107
+ const sleep = deps.sleep ?? defaultSleep;
108
+ const now = deps.now ?? (() => Date.now());
109
+ const isFatal = deps.fatal ?? ((e) => e?.code === 'UNAUTHENTICATED');
110
+ const isGone = deps.gone ?? ((e) => /already finished/i.test(message(e)));
111
+ const runs = new Map();
112
+ const prepareGate = new FairGate(slots);
113
+ const backoff = new Map();
114
+ const dead = new Map();
115
+ const stepping = new Set();
116
+ let reserved = 0;
117
+ let stopping = false;
118
+ let wakeUp = null;
119
+ let overloadSaid = null;
120
+ let offset = 0;
121
+ const wake = () => {
122
+ const w = wakeUp;
123
+ wakeUp = null;
124
+ w?.();
125
+ };
126
+ const nap = (ms, signal) => Promise.race([
127
+ sleep(ms, signal),
128
+ new Promise((resolve) => {
129
+ wakeUp = resolve;
130
+ })
131
+ ]);
132
+ const count = (stage) => [...runs.values()].filter((r) => r.stage === stage).length;
133
+ const status = () => ({
134
+ slots,
135
+ claimed: runs.size,
136
+ preparing: count('prepare') + count('prepare-wait'),
137
+ waitingForSlot: count('slot-wait'),
138
+ executing: count('execute')
139
+ });
140
+ /** Whether one more run may be claimed right now. Checked right before the claim, so a run the machine cannot start stays queued on the board. */
141
+ function capacity() {
142
+ if (stopping)
143
+ return false;
144
+ const ready = deps.broker.ready();
145
+ if (!ready.ok) {
146
+ if (overloadSaid !== ready.code)
147
+ deps.log(ready.why);
148
+ overloadSaid = ready.code;
149
+ return false;
150
+ }
151
+ overloadSaid = null;
152
+ const pending = count('prepare-wait') + count('prepare') + count('slot-wait');
153
+ const load = deps.broker.totals().busy + pending + reserved;
154
+ // A run that waits for a slot is not in the machine total yet, so it is counted once, as pending
155
+ return load < slots + prefetch;
156
+ }
157
+ function startRun(conn, job) {
158
+ const ac = new AbortController();
159
+ const rec = { job, conn, ac, stage: 'prepare-wait', reason: null, reported: false };
160
+ runs.set(job.id, rec);
161
+ const ctx = {
162
+ runId: job.id,
163
+ signal: ac.signal,
164
+ log: (l) => deps.log(l),
165
+ reason: () => rec.reason
166
+ };
167
+ const stop = (why) => {
168
+ if (rec.reason)
169
+ return;
170
+ rec.reason = why;
171
+ // The reason travels on the signal, so a run knows whether it was stopped, timed out or given up on
172
+ ac.abort(why);
173
+ };
174
+ rec.done = (async () => {
175
+ // The beat starts here, at the claim, and runs until the run is over: waiting for a turn, a slot or the disk never silences it
176
+ let told = false;
177
+ const beat = () => void Promise.resolve()
178
+ .then(() => conn.beat(job.id))
179
+ .catch((e) => {
180
+ if (isGone(e)) {
181
+ if (!told)
182
+ deps.log(`The board gave up on this run: ${message(e)}`);
183
+ told = true;
184
+ stop('gone');
185
+ }
186
+ });
187
+ beat();
188
+ const beatTimer = setInterval(beat, beatEvery);
189
+ const limitTimer = setTimeout(() => stop('timeout'), limitMs);
190
+ const aborted = new Promise((_, reject) => {
191
+ ac.signal.addEventListener('abort', () => reject(Object.assign(new Error('The run was stopped.'), { name: 'AbortError' })), { once: true });
192
+ });
193
+ aborted.catch(() => undefined);
194
+ const key = job.registration ?? conn.key;
195
+ const work = (async () => {
196
+ const free = await prepareGate.acquire(key, ac.signal);
197
+ let prepared;
198
+ try {
199
+ rec.stage = 'prepare';
200
+ prepared = await deps.runner.prepare(job, ctx);
201
+ }
202
+ finally {
203
+ free();
204
+ }
205
+ rec.stage = 'slot-wait';
206
+ wake();
207
+ const slot = await deps.broker.acquire({ runId: job.id, kind: 'check', registration: key }, { signal: ac.signal });
208
+ try {
209
+ rec.stage = 'execute';
210
+ await deps.runner.execute(job, prepared, ctx);
211
+ }
212
+ finally {
213
+ slot.release();
214
+ }
215
+ })();
216
+ work.catch(() => undefined);
217
+ try {
218
+ await Promise.race([work, aborted]);
219
+ rec.reported = true;
220
+ }
221
+ catch (e) {
222
+ if (rec.reason === 'timeout') {
223
+ const mins = Math.max(1, Math.round(limitMs / 60_000));
224
+ const why = limitMs < 60_000 ? `${Math.round(limitMs / 1000)} seconds` : `${mins} minutes`;
225
+ await deps.runner
226
+ .fail(job, new Error(`The run took longer than ${why} and was stopped.`), ctx)
227
+ .catch(() => undefined);
228
+ }
229
+ else if (rec.reason === null) {
230
+ deps.log(`could not run ${job.id}: ${message(e)}`);
231
+ await deps.runner.fail(job, toError(e), ctx).catch(() => undefined);
232
+ }
233
+ rec.reported = true;
234
+ }
235
+ finally {
236
+ clearInterval(beatTimer);
237
+ clearTimeout(limitTimer);
238
+ // An abandoned stage gets a moment to end by itself before the run's resources are closed
239
+ if (rec.reason)
240
+ await settleWithin(work, grace);
241
+ try {
242
+ await deps.runner.cleanup?.(job, ctx);
243
+ }
244
+ catch (e) {
245
+ deps.log(`could not clean up ${job.id}: ${message(e)}`);
246
+ }
247
+ runs.delete(job.id);
248
+ wake();
249
+ }
250
+ })();
251
+ }
252
+ /** One look at one board: ask for the count, then take as many as the machine can start. Returns how many were taken. */
253
+ async function step(conn) {
254
+ let taken = 0;
255
+ if (!capacity())
256
+ return { taken, blocked: true };
257
+ let waiting;
258
+ try {
259
+ waiting = await withTimeout(conn.peek(), callMs, `${conn.key}: the board`);
260
+ backoff.delete(conn.key);
261
+ while (waiting-- > 0) {
262
+ // Late and synchronous: the capacity is read again and a place is reserved before the claim goes out
263
+ if (!capacity())
264
+ return { taken, blocked: true };
265
+ reserved++;
266
+ try {
267
+ const job = await withTimeout(conn.claim(), callMs, `${conn.key}: the board`);
268
+ if (!job)
269
+ break;
270
+ if (stopping) {
271
+ await conn.release([job.id]).catch(() => undefined);
272
+ break;
273
+ }
274
+ startRun(conn, job);
275
+ taken++;
276
+ }
277
+ finally {
278
+ reserved--;
279
+ }
280
+ }
281
+ }
282
+ catch (e) {
283
+ if (isFatal(e)) {
284
+ dead.set(conn.key, conn.fingerprint ?? '');
285
+ deps.log(`${conn.key}: ${message(e)}`);
286
+ }
287
+ else {
288
+ const prev = backoff.get(conn.key)?.wait ?? poll;
289
+ const wait = Math.min(backoffMax, Math.max(prev * 2, poll * 2));
290
+ backoff.set(conn.key, { wait, until: now() + wait });
291
+ deps.log(`${conn.key}: ${message(e)}`);
292
+ }
293
+ }
294
+ return { taken, blocked: false };
295
+ }
296
+ async function pass() {
297
+ // The connections are read again on every pass, so a board connected or forgotten meanwhile is picked up without a restart
298
+ const conns = deps.connections();
299
+ const keys = new Set(conns.map((c) => c.key));
300
+ for (const k of [...dead.keys()])
301
+ if (!keys.has(k))
302
+ dead.delete(k);
303
+ const live = conns.filter((c) => {
304
+ const d = dead.get(c.key);
305
+ if (d !== undefined && d === (c.fingerprint ?? ''))
306
+ return false;
307
+ if (d !== undefined)
308
+ dead.delete(c.key);
309
+ return !stepping.has(c.key) && (backoff.get(c.key)?.until ?? 0) <= now();
310
+ });
311
+ // The boards take turns at being asked first
312
+ const ordered = live.length
313
+ ? [...live.slice(offset % live.length), ...live.slice(0, offset % live.length)]
314
+ : [];
315
+ offset++;
316
+ let taken = 0;
317
+ let blocked = false;
318
+ const steps = ordered.map(async (c) => {
319
+ stepping.add(c.key);
320
+ try {
321
+ const r = await step(c);
322
+ taken += r.taken;
323
+ blocked ||= r.blocked;
324
+ }
325
+ finally {
326
+ stepping.delete(c.key);
327
+ }
328
+ });
329
+ // A board that answers slowly does not hold the others back
330
+ await settleWithin(Promise.all(steps), stepWait);
331
+ return { taken, blocked };
332
+ }
333
+ async function run({ signal, once = false }) {
334
+ stopping = false;
335
+ let total = 0;
336
+ const onAbort = () => {
337
+ stopping = true;
338
+ wake();
339
+ };
340
+ signal.addEventListener('abort', onAbort, { once: true });
341
+ try {
342
+ while (!signal.aborted) {
343
+ const { taken, blocked } = await pass();
344
+ total += taken;
345
+ if (once) {
346
+ if (!taken && !(blocked && runs.size))
347
+ break;
348
+ if (!taken)
349
+ await nap(poll, signal);
350
+ continue;
351
+ }
352
+ // Something was taken: look again at once. Otherwise the idle poll, cut short when a run ends and frees room
353
+ await nap(taken ? 0 : poll, signal);
354
+ }
355
+ if (once && !signal.aborted)
356
+ await Promise.all([...runs.values()].map((r) => r.done));
357
+ }
358
+ finally {
359
+ signal.removeEventListener('abort', onAbort);
360
+ stopping = signal.aborted;
361
+ if (signal.aborted) {
362
+ // Give back exactly the runs this scheduler holds and has not reported, grouped by the connection they were claimed on
363
+ const byConn = new Map();
364
+ for (const r of runs.values()) {
365
+ if (r.reported)
366
+ continue;
367
+ r.reason ??= 'stopped';
368
+ byConn.set(r.conn, [...(byConn.get(r.conn) ?? []), r.job.id]);
369
+ }
370
+ await Promise.all([...byConn].map(([c, ids]) => c.release(ids).catch(() => undefined)));
371
+ for (const r of runs.values())
372
+ r.ac.abort('stopped');
373
+ await Promise.all([...runs.values()].map((r) => r.done));
374
+ }
375
+ }
376
+ return total;
377
+ }
378
+ return {
379
+ run,
380
+ status,
381
+ heldIds: () => [...runs.values()].filter((r) => !r.reported).map((r) => r.job.id)
382
+ };
383
+ }