@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.
- package/README.md +29 -1
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +213 -0
- package/dist/checks/audit.js +126 -0
- package/dist/checks/bind.js +100 -0
- package/dist/checks/board.js +107 -0
- package/dist/checks/broker.js +439 -0
- package/dist/checks/browser.js +275 -0
- package/dist/checks/browserpool.js +221 -0
- package/dist/checks/cache.js +710 -0
- package/dist/checks/child.js +211 -0
- package/dist/checks/command.js +267 -0
- package/dist/checks/commands.js +219 -0
- package/dist/checks/connect.js +79 -0
- package/dist/checks/connections.js +42 -0
- package/dist/checks/copy.js +320 -0
- package/dist/checks/dbpool.js +710 -0
- package/dist/checks/doctor.js +649 -0
- package/dist/checks/envPolicy.js +212 -0
- package/dist/checks/envrules.js +322 -0
- package/dist/checks/evidence.js +114 -0
- package/dist/checks/fsretry.js +29 -0
- package/dist/checks/guard.js +188 -0
- package/dist/checks/guardrails.js +155 -0
- package/dist/checks/importlegacy.js +300 -0
- package/dist/checks/install.js +150 -0
- package/dist/checks/live.js +76 -0
- package/dist/checks/lock.js +207 -0
- package/dist/checks/login.js +203 -0
- package/dist/checks/mailguard-hook.cjs +216 -0
- package/dist/checks/mailguard.js +98 -0
- package/dist/checks/paths.js +85 -0
- package/dist/checks/preview.js +577 -0
- package/dist/checks/profiles.js +110 -0
- package/dist/checks/provision.js +175 -0
- package/dist/checks/recipe.js +246 -0
- package/dist/checks/redact.js +52 -0
- package/dist/checks/registry.js +548 -0
- package/dist/checks/repoenv.js +162 -0
- package/dist/checks/roots.js +144 -0
- package/dist/checks/routing.js +68 -0
- package/dist/checks/run.js +443 -0
- package/dist/checks/sandbox.js +208 -0
- package/dist/checks/scheduler.js +383 -0
- package/dist/checks/scope.js +383 -0
- package/dist/checks/sealedNames.js +39 -0
- package/dist/checks/secrets.js +452 -0
- package/dist/checks/secretscmd.js +80 -0
- package/dist/checks/service.js +589 -0
- package/dist/checks/signin.js +79 -0
- package/dist/checks/singleflight.js +307 -0
- package/dist/checks/store.js +92 -0
- package/dist/checks/surface.js +124 -0
- package/dist/checks/types.js +2 -0
- package/dist/checks/warm.js +342 -0
- package/dist/checks/watch.js +228 -0
- package/dist/checks/wire.js +87 -0
- package/dist/cli.js +18 -1
- package/dist/hooks/adapters.js +3 -1
- package/dist/hooks/call.js +6 -1
- package/dist/hooks/modVersion.js +26 -0
- package/package.json +2 -2
|
@@ -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();
|
|
@@ -0,0 +1,228 @@
|
|
|
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';
|
|
9
|
+
// -- MODULES -- //
|
|
10
|
+
import { appendAudit } from './audit.js';
|
|
11
|
+
import { boundFolder, pinStatus } from './bind.js';
|
|
12
|
+
import { BoardError, RunnerBoard } from './board.js';
|
|
13
|
+
import { acquireSlot, canTake, machineTotals, maxSlots } from './broker.js';
|
|
14
|
+
import { closeAllBrowsers } from './browserpool.js';
|
|
15
|
+
import { listConnections } from './connections.js';
|
|
16
|
+
import { loadRunnerToken } from './connect.js';
|
|
17
|
+
import { closePoolIfIdle, configureDatabasePool } from './dbpool.js';
|
|
18
|
+
import { machineLock } from './lock.js';
|
|
19
|
+
import { checksHome } from './paths.js';
|
|
20
|
+
import { renameRetry } from './fsretry.js';
|
|
21
|
+
import { readRegistrations } from './registry.js';
|
|
22
|
+
import { runCheck } from './run.js';
|
|
23
|
+
import { createScheduler } from './scheduler.js';
|
|
24
|
+
import { sweepOrphans } from './scope.js';
|
|
25
|
+
// -- CONSTANTS -- //
|
|
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
|
+
}
|
|
50
|
+
/** Runs the worker until the signal aborts (or one pass with `once`). Returns how many runs it took. */
|
|
51
|
+
export async function watchChecks(o) {
|
|
52
|
+
const lock = machineLock('watch');
|
|
53
|
+
if (!lock)
|
|
54
|
+
throw new Error('Another "check watch" is already running on this machine. Only one worker runs per machine.');
|
|
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();
|
|
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();
|
|
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
|
+
}
|
|
107
|
+
await sweepOrphans(o.log);
|
|
108
|
+
appendAudit('watch-start', { slots });
|
|
109
|
+
writeWorkerStatus(slots, 0);
|
|
110
|
+
const scheduler = createScheduler({
|
|
111
|
+
// The connections are read again on every loop, so a new `check connect` is picked up without a restart
|
|
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);
|
|
168
|
+
}
|
|
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);
|
|
181
|
+
return taken;
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
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);
|
|
188
|
+
appendAudit('watch-stop', { taken });
|
|
189
|
+
lock.release();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function handle(o, owner, job, log, signal) {
|
|
193
|
+
const board = new RunnerBoard(owner.server, owner.token, o.fetch);
|
|
194
|
+
const fail = (reason) => board.finish(job.id, { result: 'error', reason }).catch(() => undefined);
|
|
195
|
+
const projectKey = job.project.key;
|
|
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}`);
|
|
212
|
+
const outcome = await runCheck({
|
|
213
|
+
server: owner.server,
|
|
214
|
+
org: owner.org,
|
|
215
|
+
item: job.item.ref,
|
|
216
|
+
key: job.check.key,
|
|
217
|
+
dir,
|
|
218
|
+
step: job.stepKey ?? undefined,
|
|
219
|
+
claimed: job,
|
|
220
|
+
scheduled: true,
|
|
221
|
+
signal,
|
|
222
|
+
runnerToken: owner.token,
|
|
223
|
+
personalToken: null,
|
|
224
|
+
fetch: o.fetch,
|
|
225
|
+
log
|
|
226
|
+
});
|
|
227
|
+
log(`${owner.org}: ${job.item.ref} ${job.check.key}: ${outcome.message}`);
|
|
228
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// WHAT THE BOARD IS TOLD ABOUT THE REGISTRATIONS. Every call carries the header x-afb-tool-registrations: a base64url JSON list of {p: board project, n: NAME, r: folder name, b: branch}, at most 16,
|
|
2
|
+
// only this organisation's, refreshed every 20 seconds. The board reads the text of whoever holds the token, so each field is checked against the board's own plain pattern first. A bad
|
|
3
|
+
// r or b is dropped from its entry (the board would call the whole header damaged); the entry stays. Never a path, a command or a secret.
|
|
4
|
+
// -- DEP-MODULES -- //
|
|
5
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
6
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
7
|
+
// -- MODULES -- //
|
|
8
|
+
import { PROJECT_KEY, REGISTRATION_NAME } from './registry.js';
|
|
9
|
+
// -- CONSTANTS -- //
|
|
10
|
+
export const REGISTRATIONS_HEADER = 'x-afb-tool-registrations';
|
|
11
|
+
/** How often the list is sent again, in milliseconds. */
|
|
12
|
+
export const REGISTRATIONS_REFRESH_MS = 20_000;
|
|
13
|
+
export const MAX_WIRE_REGISTRATIONS = 16;
|
|
14
|
+
/** The board refuses a header longer than this. */
|
|
15
|
+
const MAX_HEADER_CHARS = 6000;
|
|
16
|
+
const REPO = /^[A-Za-z0-9 ._-]{1,100}$/;
|
|
17
|
+
const BRANCH = /^[A-Za-z0-9._/-]{1,100}$/;
|
|
18
|
+
// -- FUNCTIONS -- //
|
|
19
|
+
/** The checked out branch of a repo, read from .git/HEAD without starting git. Null for a detached HEAD, no repo or a head it cannot read. */
|
|
20
|
+
export function readBranch(repo) {
|
|
21
|
+
try {
|
|
22
|
+
let gitDir = join(repo, '.git');
|
|
23
|
+
if (statSync(gitDir).isFile()) {
|
|
24
|
+
const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(gitDir, 'utf8'));
|
|
25
|
+
if (!m?.[1])
|
|
26
|
+
return null;
|
|
27
|
+
const target = m[1].trim();
|
|
28
|
+
gitDir = isAbsolute(target) ? target : resolve(dirname(gitDir), target);
|
|
29
|
+
}
|
|
30
|
+
const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim();
|
|
31
|
+
const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
|
32
|
+
return ref?.[1] ?? null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** The registrations of one organisation as board entries, with the fields that do not match dropped. */
|
|
39
|
+
export function wireEntries(org, registrations) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const r of registrations) {
|
|
42
|
+
if (r.org !== org)
|
|
43
|
+
continue;
|
|
44
|
+
// An entry without a valid project key or name cannot be sent at all: the board would refuse the whole header
|
|
45
|
+
if (!PROJECT_KEY.test(r.boardProject) || !REGISTRATION_NAME.test(r.name))
|
|
46
|
+
continue;
|
|
47
|
+
const entry = { p: r.boardProject, n: r.name };
|
|
48
|
+
const folder = r.repoName ?? (r.repo ? basename(r.repo) : null);
|
|
49
|
+
if (folder && REPO.test(folder))
|
|
50
|
+
entry.r = folder;
|
|
51
|
+
if (r.branch && BRANCH.test(r.branch))
|
|
52
|
+
entry.b = r.branch;
|
|
53
|
+
out.push(entry);
|
|
54
|
+
if (out.length === MAX_WIRE_REGISTRATIONS)
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/** The header value for one organisation, or '' when it has none. The list is cut from the end until it fits what the board accepts. */
|
|
60
|
+
export function registrationsHeader(org, registrations) {
|
|
61
|
+
const list = wireEntries(org, registrations);
|
|
62
|
+
while (list.length) {
|
|
63
|
+
const value = Buffer.from(JSON.stringify(list)).toString('base64url');
|
|
64
|
+
if (value.length <= MAX_HEADER_CHARS)
|
|
65
|
+
return value;
|
|
66
|
+
list.pop();
|
|
67
|
+
}
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
/** Gives a registration its branch from the repo (no git process), for the header and the report. */
|
|
71
|
+
export function withBranch(reg) {
|
|
72
|
+
return { ...reg, branch: reg.repo && existsSync(reg.repo) ? readBranch(reg.repo) : null };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Cleans the registration fields of a run report before it is sent: a registration that is not a plain name, and a folder name or branch that does not match the board's pattern,
|
|
76
|
+
* are removed from the report (the rest of the report stays).
|
|
77
|
+
*/
|
|
78
|
+
export function sanitiseReport(report) {
|
|
79
|
+
const out = { ...report };
|
|
80
|
+
if (out.registration !== undefined && !REGISTRATION_NAME.test(out.registration))
|
|
81
|
+
delete out.registration;
|
|
82
|
+
if (out.repoName !== undefined && !REPO.test(out.repoName))
|
|
83
|
+
delete out.repoName;
|
|
84
|
+
if (out.branch !== undefined && !BRANCH.test(out.branch))
|
|
85
|
+
delete out.branch;
|
|
86
|
+
return out;
|
|
87
|
+
}
|