@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,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
+ }
@@ -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 (runs/<run id>/ACTIVE: the process id, the start time and what the run made that a
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, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
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 { removeContainer } from './provision.js';
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
- renameSync(tmp, file);
29
+ renameRetry(tmp, file);
31
30
  }
32
- function readMarker(dir) {
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
- const v = JSON.parse(readFileSync(join(dir, MARKER), 'utf8'));
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(join(this.dir, MARKER), JSON.stringify(marker) + '\n');
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
- this.info = { ...this.info, ...info };
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(join(this.dir, ENDED), new Date().toISOString() + '\n');
108
- rmSync(join(this.dir, MARKER), { force: true });
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 plainIds(runsDir())) {
320
+ for (const id of allRunIds()) {
247
321
  seen.add(id);
248
322
  const dir = under(runsDir(), id);
249
- const marker = readMarker(dir);
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(join(dir, MARKER)) ||
255
- existsSync(join(dir, ENDED)) ||
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?.containerName)
262
- await removeContainer(marker.containerName);
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 plainIds(runsDir())) {
373
+ for (const id of allRunIds()) {
285
374
  const dir = under(runsDir(), id);
286
- if (existsSync(join(dir, MARKER)))
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
+ }