@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.
- package/README.md +16 -0
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +213 -0
- package/dist/checks/audit.js +3 -2
- package/dist/checks/bind.js +3 -2
- package/dist/checks/broker.js +439 -0
- package/dist/checks/browser.js +137 -32
- package/dist/checks/browserpool.js +221 -0
- package/dist/checks/cache.js +710 -0
- package/dist/checks/command.js +75 -88
- package/dist/checks/commands.js +219 -0
- package/dist/checks/connections.js +4 -3
- 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 +50 -1
- package/dist/checks/envrules.js +322 -0
- package/dist/checks/evidence.js +114 -0
- package/dist/checks/fsretry.js +29 -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 +28 -4
- 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 +24 -0
- package/dist/checks/preview.js +418 -191
- package/dist/checks/profiles.js +110 -0
- package/dist/checks/provision.js +1 -1
- package/dist/checks/recipe.js +19 -5
- 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 +243 -53
- package/dist/checks/sandbox.js +32 -10
- package/dist/checks/scheduler.js +383 -0
- package/dist/checks/scope.js +119 -30
- 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 +7 -1
- package/dist/checks/singleflight.js +307 -0
- package/dist/checks/store.js +92 -0
- package/dist/checks/surface.js +124 -0
- package/dist/checks/warm.js +342 -0
- package/dist/checks/watch.js +190 -63
- package/dist/checks/wire.js +87 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/checks/scope.js
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
// THE THINGS ONE RUN OWNS, AND WHAT HAPPENS TO THEM WHEN THE RUN ENDS, WHATEVER ENDS IT.
|
|
2
|
-
// The layer that makes the resources writes a live marker file first (
|
|
3
|
-
// crash would leave behind: the preview process, the database container and the private copy). Resources are closed newest first and closing never throws.
|
|
2
|
+
// The layer that makes the resources writes a live marker file first (markers/<run id>.json, in a folder the sandboxed check cannot reach: the process id, the
|
|
3
|
+
// start time and what the run made that a crash would leave behind: the preview process, the database container and the private copy). Resources are closed newest first and closing never throws.
|
|
4
4
|
// A later run sweeps what a dead process left: only the exact run folders and copies named by a dead marker (or by no marker and old enough), never a
|
|
5
5
|
// folder that was not made by a run.
|
|
6
6
|
// -- DEP-MODULES -- //
|
|
7
7
|
import { execFile } from 'node:child_process';
|
|
8
8
|
import { randomBytes } from 'node:crypto';
|
|
9
|
-
import { existsSync, lstatSync, readFileSync, readdirSync,
|
|
9
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
// -- MODULES -- //
|
|
12
12
|
import { pidAlive } from './lock.js';
|
|
13
|
-
import { NAME, copiesDir, ensureDir, runsDir, under } from './paths.js';
|
|
14
|
-
import {
|
|
13
|
+
import { NAME, copiesDir, endedPath, ensureDir, logDirFor, logsDir, markerPath, markersDir, runsDir, under } from './paths.js';
|
|
14
|
+
import { dropPooledDatabase, pooledNames } from './dbpool.js';
|
|
15
|
+
import { renameRetry } from './fsretry.js';
|
|
16
|
+
import { CONTAINER_PREFIX, removeContainer } from './provision.js';
|
|
15
17
|
// -- CONSTANTS -- //
|
|
16
|
-
const MARKER = 'ACTIVE';
|
|
17
|
-
/** Written when a run ends in order, so a finished run's folder is never mistaken for one whose marker was lost. */
|
|
18
|
-
const ENDED = 'ENDED';
|
|
19
18
|
/** A run folder with no marker is only an orphan after this long (the marker is written first, so younger ones are being made right now). */
|
|
20
19
|
const NO_MARKER_GRACE_MS = 15 * 60_000;
|
|
21
20
|
const DAY_MS = 86_400_000;
|
|
@@ -27,19 +26,64 @@ const noop = () => { };
|
|
|
27
26
|
function writeWhole(file, text) {
|
|
28
27
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
29
28
|
writeFileSync(tmp, text, { mode: 0o600 });
|
|
30
|
-
|
|
29
|
+
renameRetry(tmp, file);
|
|
31
30
|
}
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Reads the marker of a run and checks it against what the tool itself would have written for that run id. A field that names anything else (a database,
|
|
33
|
+
* a role, a container, a process that is older than the run) is dropped and a note says so, so a marker can never make a sweep touch something that is not the
|
|
34
|
+
* run's own. A marker that does not hold a process id and its own run id is not a marker (null).
|
|
35
|
+
*/
|
|
36
|
+
export function readMarker(runId) {
|
|
37
|
+
let v;
|
|
33
38
|
try {
|
|
34
|
-
|
|
35
|
-
if (!v || typeof v !== 'object')
|
|
36
|
-
return null;
|
|
37
|
-
const m = v;
|
|
38
|
-
return typeof m.pid === 'number' && typeof m.runId === 'string' ? m : null;
|
|
39
|
+
v = JSON.parse(readFileSync(markerPath(runId), 'utf8'));
|
|
39
40
|
}
|
|
40
41
|
catch {
|
|
41
42
|
return null;
|
|
42
43
|
}
|
|
44
|
+
if (!v || typeof v !== 'object')
|
|
45
|
+
return null;
|
|
46
|
+
const m = v;
|
|
47
|
+
if (typeof m.pid !== 'number' || m.runId !== runId)
|
|
48
|
+
return null;
|
|
49
|
+
const startedAt = typeof m.startedAt === 'string' ? m.startedAt : '';
|
|
50
|
+
const notes = [];
|
|
51
|
+
const out = { runId, pid: m.pid, startedAt };
|
|
52
|
+
if (m.previewPid !== undefined) {
|
|
53
|
+
const spawned = Date.parse(String(m.previewStartedAt ?? ''));
|
|
54
|
+
if (Number.isInteger(m.previewPid) &&
|
|
55
|
+
m.previewPid > 1 &&
|
|
56
|
+
m.previewPid !== m.pid &&
|
|
57
|
+
Number.isFinite(spawned) &&
|
|
58
|
+
spawned >= Date.parse(startedAt) - 1000) {
|
|
59
|
+
out.previewPid = m.previewPid;
|
|
60
|
+
out.previewStartedAt = String(m.previewStartedAt);
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
notes.push(`The marker of run ${runId} names a preview process that does not check out, so it was ignored.`);
|
|
64
|
+
}
|
|
65
|
+
if (m.containerName !== undefined) {
|
|
66
|
+
if (m.containerName === `${CONTAINER_PREFIX}${runId}`)
|
|
67
|
+
out.containerName = m.containerName;
|
|
68
|
+
else
|
|
69
|
+
notes.push(`The marker of run ${runId} names a container that is not the one this run makes, so it was ignored.`);
|
|
70
|
+
}
|
|
71
|
+
if (m.dbName !== undefined || m.roleName !== undefined || m.poolId !== undefined) {
|
|
72
|
+
const names = pooledNames(runId);
|
|
73
|
+
if (m.dbName === names.dbName &&
|
|
74
|
+
m.roleName === names.roleName &&
|
|
75
|
+
typeof m.poolId === 'string' &&
|
|
76
|
+
NAME.test(m.poolId)) {
|
|
77
|
+
out.dbName = m.dbName;
|
|
78
|
+
out.roleName = m.roleName;
|
|
79
|
+
out.poolId = m.poolId;
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
notes.push(`The marker of run ${runId} names a database or role that is not this run's, so it was ignored.`);
|
|
83
|
+
}
|
|
84
|
+
if (typeof m.copyDir === 'string')
|
|
85
|
+
out.copyDir = m.copyDir;
|
|
86
|
+
return { marker: out, notes };
|
|
43
87
|
}
|
|
44
88
|
export class RunScope {
|
|
45
89
|
runId;
|
|
@@ -63,6 +107,8 @@ export class RunScope {
|
|
|
63
107
|
if (this.started)
|
|
64
108
|
return;
|
|
65
109
|
ensureDir(this.dir);
|
|
110
|
+
ensureDir(markersDir());
|
|
111
|
+
ensureDir(logDirFor(this.runId));
|
|
66
112
|
this.started = true;
|
|
67
113
|
this.writeMarker();
|
|
68
114
|
}
|
|
@@ -73,16 +119,29 @@ export class RunScope {
|
|
|
73
119
|
startedAt: new Date().toISOString(),
|
|
74
120
|
...this.info
|
|
75
121
|
};
|
|
76
|
-
writeWhole(
|
|
122
|
+
writeWhole(markerPath(this.runId), JSON.stringify(marker) + '\n');
|
|
77
123
|
}
|
|
78
124
|
/** Records what a crash would leave behind (the preview process id, the database container, the private copy) so a later sweep can remove it. */
|
|
79
125
|
setInfo(info) {
|
|
126
|
+
// After the scope closed nothing may rewrite the marker: it was removed with the run
|
|
127
|
+
if (this.closed)
|
|
128
|
+
return;
|
|
80
129
|
this.start();
|
|
81
|
-
|
|
130
|
+
const stamped = info.previewPid !== undefined && info.previewStartedAt === undefined
|
|
131
|
+
? { ...info, previewStartedAt: new Date().toISOString() }
|
|
132
|
+
: info;
|
|
133
|
+
this.info = { ...this.info, ...stamped };
|
|
82
134
|
this.writeMarker();
|
|
83
135
|
}
|
|
84
136
|
/** Registers a resource. It is closed when the scope closes, newest first. */
|
|
85
137
|
add(name, dispose) {
|
|
138
|
+
// A resource made after the scope closed is closed at once: nothing will come back to do it
|
|
139
|
+
if (this.closed) {
|
|
140
|
+
void Promise.resolve()
|
|
141
|
+
.then(dispose)
|
|
142
|
+
.catch(() => this.log(`Could not close ${name}.`));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
86
145
|
this.start();
|
|
87
146
|
this.disposers.push({ name, dispose });
|
|
88
147
|
}
|
|
@@ -104,8 +163,9 @@ export class RunScope {
|
|
|
104
163
|
}
|
|
105
164
|
try {
|
|
106
165
|
if (this.started) {
|
|
107
|
-
writeWhole(
|
|
108
|
-
rmSync(
|
|
166
|
+
writeWhole(endedPath(this.runId), new Date().toISOString() + '\n');
|
|
167
|
+
rmSync(markerPath(this.runId), { force: true });
|
|
168
|
+
removeTree(logDirFor(this.runId));
|
|
109
169
|
}
|
|
110
170
|
}
|
|
111
171
|
catch {
|
|
@@ -222,6 +282,9 @@ export function installSignalHandlers(scope, onKill) {
|
|
|
222
282
|
function removeRun(id) {
|
|
223
283
|
removeTree(under(copiesDir(), id));
|
|
224
284
|
removeTree(under(runsDir(), id));
|
|
285
|
+
removeTree(logDirFor(id));
|
|
286
|
+
rmSync(markerPath(id), { force: true });
|
|
287
|
+
rmSync(endedPath(id), { force: true });
|
|
225
288
|
}
|
|
226
289
|
const ageOf = (path) => {
|
|
227
290
|
try {
|
|
@@ -236,30 +299,48 @@ const plainIds = (dir) => {
|
|
|
236
299
|
return [];
|
|
237
300
|
return readdirSync(dir).filter((n) => NAME.test(n));
|
|
238
301
|
};
|
|
302
|
+
/** The run ids that have a folder, a marker or an end note. */
|
|
303
|
+
export function allRunIds() {
|
|
304
|
+
const ids = new Set(plainIds(runsDir()));
|
|
305
|
+
if (existsSync(markersDir()))
|
|
306
|
+
for (const f of readdirSync(markersDir())) {
|
|
307
|
+
const m = /^(.+)\.(json|ended)$/.exec(f);
|
|
308
|
+
if (m && NAME.test(m[1]))
|
|
309
|
+
ids.add(m[1]);
|
|
310
|
+
}
|
|
311
|
+
return [...ids];
|
|
312
|
+
}
|
|
239
313
|
/**
|
|
240
314
|
* Cleans what dead runs left: a run whose marker names a dead process, or that has neither a marker nor an end note and is older than 15 minutes.
|
|
241
315
|
* Stops the recorded preview process tree and database container, then removes only those run folders and copies. Returns how many runs it cleaned.
|
|
242
316
|
*/
|
|
243
|
-
export async function sweepOrphans(log = noop) {
|
|
317
|
+
export async function sweepOrphans(log = noop, deps = {}) {
|
|
244
318
|
let cleaned = 0;
|
|
245
319
|
const seen = new Set();
|
|
246
|
-
for (const id of
|
|
320
|
+
for (const id of allRunIds()) {
|
|
247
321
|
seen.add(id);
|
|
248
322
|
const dir = under(runsDir(), id);
|
|
249
|
-
const
|
|
323
|
+
const checked = readMarker(id);
|
|
324
|
+
const marker = checked?.marker ?? null;
|
|
325
|
+
for (const note of checked?.notes ?? [])
|
|
326
|
+
log(note);
|
|
250
327
|
if (marker) {
|
|
251
328
|
if (pidAlive(marker.pid))
|
|
252
329
|
continue;
|
|
253
330
|
}
|
|
254
|
-
else if (existsSync(
|
|
255
|
-
existsSync(
|
|
256
|
-
ageOf(dir) < NO_MARKER_GRACE_MS) {
|
|
331
|
+
else if (existsSync(markerPath(id)) ||
|
|
332
|
+
existsSync(endedPath(id)) ||
|
|
333
|
+
(existsSync(dir) ? ageOf(dir) : Infinity) < NO_MARKER_GRACE_MS) {
|
|
257
334
|
continue; // Finished in order (clearRuns handles it), or too young to judge
|
|
258
335
|
}
|
|
259
336
|
if (marker?.previewPid && pidAlive(marker.previewPid))
|
|
260
337
|
await killTree(marker.previewPid);
|
|
261
|
-
if (marker?.
|
|
262
|
-
|
|
338
|
+
if (marker?.dbName && marker.roleName && marker.poolId) {
|
|
339
|
+
// A pooled run: only its own database and roles go. The pool container is never removed here, other runs live in it
|
|
340
|
+
await dropPooledDatabase({ dbName: marker.dbName, roleName: marker.roleName, poolId: marker.poolId }, { docker: deps.docker });
|
|
341
|
+
}
|
|
342
|
+
else if (marker?.containerName)
|
|
343
|
+
await removeContainer(marker.containerName, deps.docker);
|
|
263
344
|
removeRun(id);
|
|
264
345
|
cleaned++;
|
|
265
346
|
log(`Cleaned the leftovers of run ${id}.`);
|
|
@@ -272,6 +353,14 @@ export async function sweepOrphans(log = noop) {
|
|
|
272
353
|
cleaned++;
|
|
273
354
|
log(`Removed the orphaned copy of run ${id}.`);
|
|
274
355
|
}
|
|
356
|
+
// The logs of a run that has no folder, marker or copy any more: old enough that no run is still writing them
|
|
357
|
+
for (const id of plainIds(logsDir())) {
|
|
358
|
+
if (seen.has(id) || existsSync(markerPath(id)) || ageOf(logDirFor(id)) < NO_MARKER_GRACE_MS)
|
|
359
|
+
continue;
|
|
360
|
+
removeTree(logDirFor(id));
|
|
361
|
+
cleaned++;
|
|
362
|
+
log(`Removed the orphaned logs of run ${id}.`);
|
|
363
|
+
}
|
|
275
364
|
return cleaned;
|
|
276
365
|
}
|
|
277
366
|
/**
|
|
@@ -281,11 +370,11 @@ export async function sweepOrphans(log = noop) {
|
|
|
281
370
|
export function clearRuns(opts = {}) {
|
|
282
371
|
const limit = (opts.olderThanDays ?? 7) * DAY_MS;
|
|
283
372
|
let removed = 0;
|
|
284
|
-
for (const id of
|
|
373
|
+
for (const id of allRunIds()) {
|
|
285
374
|
const dir = under(runsDir(), id);
|
|
286
|
-
if (existsSync(
|
|
375
|
+
if (existsSync(markerPath(id)))
|
|
287
376
|
continue;
|
|
288
|
-
if (!opts.all && ageOf(dir) < limit)
|
|
377
|
+
if (!opts.all && (existsSync(dir) ? ageOf(dir) : Infinity) < limit)
|
|
289
378
|
continue;
|
|
290
379
|
removeRun(id);
|
|
291
380
|
removed++;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// THE NAMES A SEALED VALUE MAY HAVE AND MAY BE GIVEN TO. PURE CONSTANTS AND PREDICATES (NO FILES, NO KEYS), SO THE RECIPE VALIDATOR, THE SEALED STORE AND THE
|
|
2
|
+
// ENV RULES SHARE ONE SET. Ported verbatim from the old tool's lib/secrets.mjs.
|
|
3
|
+
// -- CONSTANTS -- //
|
|
4
|
+
/** The setting names a sealed value may be given to (and the names that may be imported): Supabase's own and the app's auth secret. NODE_OPTIONS, PATH and the like can never be one. */
|
|
5
|
+
export const SUPABASE_KEY = /^((NEXT_PUBLIC_|PUBLIC_|VITE_)?SUPABASE_(URL|ANON_KEY|PUBLISHABLE_KEY)|SUPABASE_SERVICE_ROLE_KEY|SUPABASE_SECRET_KEY|ATTENDEE_AUTH_SECRET)$/;
|
|
6
|
+
/** The mail settings: the app's Postmark token, From address and stream. A preview given them can send mail, so it only runs behind the mail guard. */
|
|
7
|
+
export const MAIL_ENV_KEY = /^POSTMARK_(SERVER_TOKEN|FROM_EMAIL|MESSAGE_STREAM)$/;
|
|
8
|
+
export const SECRET_ENV_KEY = new RegExp(`(?:${SUPABASE_KEY.source})|(?:${MAIL_ENV_KEY.source})`);
|
|
9
|
+
/** Names that hold a key which bypasses row-level security, whatever the value looks like. */
|
|
10
|
+
export const SERVICE_NAME = /(SERVICE_ROLE_KEY|SUPABASE_SECRET_KEY)$/;
|
|
11
|
+
/** A setting a browser bundle may carry: a service key never goes into one. */
|
|
12
|
+
export const CLIENT_NAME = /^(NEXT_PUBLIC_|PUBLIC_|VITE_)/;
|
|
13
|
+
/** In a recipe: {secret:NAME}. */
|
|
14
|
+
export const SECRET_PLACEHOLDER = /^\{secret:([A-Z0-9_]{2,60})\}$/;
|
|
15
|
+
export const URL_KEY = /SUPABASE_URL$/;
|
|
16
|
+
export const EMAIL = /^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,200}\.[A-Za-z]{2,24}$/;
|
|
17
|
+
/** The whole mail set by name: a variable of any of these providers is a mail setting. SES must be a whole word (SESSION_SECRET is not mail). */
|
|
18
|
+
export const MAIL_SET_NAME = /(MAIL|SMTP|POSTMARK|SENDGRID|MAILGUN|RESEND)|(^|_)SES(_|$)/i;
|
|
19
|
+
// -- FUNCTIONS -- //
|
|
20
|
+
export const isMailKey = (name) => MAIL_ENV_KEY.test(String(name));
|
|
21
|
+
/** Every plain sentence about how a recipe env setting `key` uses the placeholder `value`, or an empty list. Names only, never the value. */
|
|
22
|
+
export function placeholderProblems(key, value) {
|
|
23
|
+
const m = SECRET_PLACEHOLDER.exec(value);
|
|
24
|
+
if (m && m[1] !== undefined && SERVICE_NAME.test(m[1]) && CLIENT_NAME.test(key))
|
|
25
|
+
return [`The recipe gives ${key}, which a browser bundle carries, the service key ${m[1]}.`];
|
|
26
|
+
if (m &&
|
|
27
|
+
m[1] !== undefined &&
|
|
28
|
+
SECRET_ENV_KEY.test(m[1]) &&
|
|
29
|
+
SECRET_ENV_KEY.test(key) &&
|
|
30
|
+
(isMailKey(key) !== isMailKey(m[1]) || (isMailKey(key) && key !== m[1])))
|
|
31
|
+
return [
|
|
32
|
+
`The recipe gives ${key} the sealed value ${m[1]}: a mail setting takes only the sealed value of its own name (another kind of value is never swapped in).`
|
|
33
|
+
];
|
|
34
|
+
if (!m || m[1] === undefined || !SECRET_ENV_KEY.test(m[1]) || !SECRET_ENV_KEY.test(key))
|
|
35
|
+
return [
|
|
36
|
+
`The recipe gives ${key} a sealed value that is not written as {secret:NAME}, or names a key that only a Supabase setting or a mail setting (POSTMARK_SERVER_TOKEN, POSTMARK_FROM_EMAIL, POSTMARK_MESSAGE_STREAM) may take.`
|
|
37
|
+
];
|
|
38
|
+
return [];
|
|
39
|
+
}
|