@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,649 @@
|
|
|
1
|
+
// `check status` AND `check doctor [--fix]` AND `check clear ...`: THE HEALTH OF THIS MACHINE'S CHECK SETUP, FOUND, FIXED WHERE IT IS SAFE, AND PRINTED AS ONE RESULT AN AGENT CAN READ.
|
|
2
|
+
// What --fix removes is only what a record names exactly: a run folder or private copy of a run whose marker names a dead process (or that has ended), a container or pooled
|
|
3
|
+
// database by the name its own marker recorded (afb-checks- prefix, never the person's database, never a container without a marker), a marker whose process id is dead,
|
|
4
|
+
// a stale worker lock, and the oldest finished runs beyond the newest 30. It never lists Docker to find things to remove, never prunes, never removes a live run or anything
|
|
5
|
+
// younger than two minutes, and never touches the scripts folder. Every message carries names and counts only: no value, no token, no path of a secret.
|
|
6
|
+
// -- DEP-MODULES -- //
|
|
7
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
8
|
+
import { basename, join } from 'node:path';
|
|
9
|
+
// -- MODULES -- //
|
|
10
|
+
import { verifyAudit } from './audit.js';
|
|
11
|
+
import { machineTotals } from './broker.js';
|
|
12
|
+
import { listBrowserMarkers, sweepBrowserMarkers } from './browserpool.js';
|
|
13
|
+
import { ensureBrowser, playwrightRuntimeDir } from './browser.js';
|
|
14
|
+
import { loadRunnerToken, runnerAccount } from './connect.js';
|
|
15
|
+
import { forgetConnection, listConnections } from './connections.js';
|
|
16
|
+
import { listPools, sweepPool } from './dbpool.js';
|
|
17
|
+
import { acquireLock, lockHolderPid, pidAlive } from './lock.js';
|
|
18
|
+
import { NAME, brokerDir, checksHome, copiesDir, endedPath, locksDir, logDirFor, markerPath, poolDir, runsDir, under } from './paths.js';
|
|
19
|
+
import { fileNameFor, listProfiles, removeProfile } from './profiles.js';
|
|
20
|
+
import { findRegistration, nameClash, nameShadowsBoard, pinRegistration, pinState, readRegistrations, repoHeldBySibling } from './registry.js';
|
|
21
|
+
import { rootHolding } from './roots.js';
|
|
22
|
+
import { permissionFlag } from './sandbox.js';
|
|
23
|
+
import { allRunIds, clearRuns, readMarker, removeTree, sweepOrphans } from './scope.js';
|
|
24
|
+
import { listSecrets, storeProblems } from './secrets.js';
|
|
25
|
+
import { readServiceStatus } from './service.js';
|
|
26
|
+
import { sweepWarm } from './warm.js';
|
|
27
|
+
// -- CONSTANTS -- //
|
|
28
|
+
/** How many finished runs keep their folder (pictures and result). Older ones are removed by `doctor --fix`. */
|
|
29
|
+
export const KEEP_RUNS = 30;
|
|
30
|
+
/** Nothing younger than this is removed: it may only be starting. */
|
|
31
|
+
export const GRACE_MS = 2 * 60_000;
|
|
32
|
+
const NO_MARKER_GRACE_MS = 15 * 60_000;
|
|
33
|
+
const CLEAR_TARGETS = ['runs', 'profiles', 'auth', 'dbs', 'previews', 'all'];
|
|
34
|
+
const BOOLEAN = new Set(['--fix', '--json', '--yes', '--all', '--dry-run']);
|
|
35
|
+
const LOOPBACK = ['127.0.0.1', 'localhost', '[::1]'];
|
|
36
|
+
// -- FUNCTIONS: SMALL HELPERS -- //
|
|
37
|
+
const cut = (s, n = 200) => (s.length > n ? `${s.slice(0, n)}...` : s);
|
|
38
|
+
function flagValue(args, name) {
|
|
39
|
+
const i = args.indexOf(name);
|
|
40
|
+
if (i < 0)
|
|
41
|
+
return undefined;
|
|
42
|
+
const v = args[i + 1];
|
|
43
|
+
return v === undefined || v.startsWith('--') ? undefined : v;
|
|
44
|
+
}
|
|
45
|
+
/** The words that are not flags or flag values. */
|
|
46
|
+
function positional(args) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (let i = 0; i < args.length; i++) {
|
|
49
|
+
const a = args[i] ?? '';
|
|
50
|
+
if (a.startsWith('--')) {
|
|
51
|
+
if (!BOOLEAN.has(a))
|
|
52
|
+
i++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
out.push(a);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
const readJson = (file) => {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const ageOf = (path, now) => {
|
|
68
|
+
try {
|
|
69
|
+
return now - statSync(path).mtimeMs;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const plainIds = (dir) => {
|
|
76
|
+
try {
|
|
77
|
+
return readdirSync(dir).filter((n) => NAME.test(n));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/** The default rule for a board address: https, or http only on this machine, and no user name in it. */
|
|
84
|
+
export function trustedBoard(server) {
|
|
85
|
+
try {
|
|
86
|
+
const u = new URL(server);
|
|
87
|
+
if (u.username || u.password)
|
|
88
|
+
return false;
|
|
89
|
+
if (u.protocol === 'https:')
|
|
90
|
+
return true;
|
|
91
|
+
return u.protocol === 'http:' && LOOPBACK.includes(u.hostname);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function emit(ctx, json, result, lines) {
|
|
98
|
+
if (json)
|
|
99
|
+
ctx.out(JSON.stringify(result));
|
|
100
|
+
else
|
|
101
|
+
for (const l of lines)
|
|
102
|
+
ctx.log(l);
|
|
103
|
+
}
|
|
104
|
+
function readRuns(alive) {
|
|
105
|
+
const live = new Set();
|
|
106
|
+
const dead = [];
|
|
107
|
+
const finished = [];
|
|
108
|
+
for (const id of allRunIds()) {
|
|
109
|
+
const dir = under(runsDir(), id);
|
|
110
|
+
const marker = readMarker(id)?.marker;
|
|
111
|
+
if (existsSync(markerPath(id))) {
|
|
112
|
+
if (marker && alive(marker.pid))
|
|
113
|
+
live.add(id);
|
|
114
|
+
else
|
|
115
|
+
dead.push(id);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
let mtime = 0;
|
|
119
|
+
try {
|
|
120
|
+
mtime = statSync(dir).mtimeMs;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* Gone */
|
|
124
|
+
}
|
|
125
|
+
finished.push({ id, mtime });
|
|
126
|
+
}
|
|
127
|
+
finished.sort((a, b) => b.mtime - a.mtime);
|
|
128
|
+
return { live, dead, finished };
|
|
129
|
+
}
|
|
130
|
+
/** Copies no live run uses, old enough, whose run is gone or ended. Dead runs are named separately (their copy goes with them). */
|
|
131
|
+
function strayCopies(view, now, grace) {
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const id of plainIds(copiesDir())) {
|
|
134
|
+
if (view.live.has(id) || view.dead.includes(id))
|
|
135
|
+
continue;
|
|
136
|
+
const copy = under(copiesDir(), id);
|
|
137
|
+
if (ageOf(copy, now) < grace)
|
|
138
|
+
continue;
|
|
139
|
+
const run = under(runsDir(), id);
|
|
140
|
+
if (existsSync(run) && !existsSync(endedPath(id)) && ageOf(run, now) < NO_MARKER_GRACE_MS)
|
|
141
|
+
continue;
|
|
142
|
+
out.push(id);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
function runsBeyond(view, keep, now, grace) {
|
|
147
|
+
return view.finished
|
|
148
|
+
.slice(keep)
|
|
149
|
+
.filter((r) => now - r.mtime >= grace)
|
|
150
|
+
.map((r) => r.id);
|
|
151
|
+
}
|
|
152
|
+
function removeRunFolders(id) {
|
|
153
|
+
removeTree(under(copiesDir(), id));
|
|
154
|
+
removeTree(under(runsDir(), id));
|
|
155
|
+
removeTree(logDirFor(id));
|
|
156
|
+
rmSync(markerPath(id), { force: true });
|
|
157
|
+
rmSync(endedPath(id), { force: true });
|
|
158
|
+
}
|
|
159
|
+
// -- FUNCTIONS: MARKERS OF OTHER MODULES -- //
|
|
160
|
+
/**
|
|
161
|
+
* Every JSON file under a folder (to a depth of four) that is a marker with a process id: { pid, ... }. Files without a numeric `pid` (pool markers name `ownerPid`,
|
|
162
|
+
* which their own sweep reads) are left alone. Returns the dead ones, as names relative to the folder.
|
|
163
|
+
*/
|
|
164
|
+
function deadMarkers(root, alive) {
|
|
165
|
+
const out = [];
|
|
166
|
+
const walk = (dir, depth) => {
|
|
167
|
+
let names;
|
|
168
|
+
try {
|
|
169
|
+
names = readdirSync(dir);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
for (const n of names) {
|
|
175
|
+
const full = join(dir, n);
|
|
176
|
+
let st;
|
|
177
|
+
try {
|
|
178
|
+
st = lstatSync(full);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (st.isSymbolicLink())
|
|
184
|
+
continue;
|
|
185
|
+
if (st.isDirectory()) {
|
|
186
|
+
if (depth < 4)
|
|
187
|
+
walk(full, depth + 1);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (!n.endsWith('.json') || n.includes('.tmp'))
|
|
191
|
+
continue;
|
|
192
|
+
const v = readJson(full);
|
|
193
|
+
const pid = v && typeof v === 'object' ? v.pid : undefined;
|
|
194
|
+
if (typeof pid === 'number' && !alive(pid))
|
|
195
|
+
out.push(full.slice(root.length + 1));
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
walk(root, 0);
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
function removeMarkers(root, names) {
|
|
202
|
+
for (const n of names)
|
|
203
|
+
rmSync(join(root, n), { force: true });
|
|
204
|
+
}
|
|
205
|
+
/** The worker locks (machine-*.lock) whose process is gone. */
|
|
206
|
+
function staleWorkerLocks() {
|
|
207
|
+
let names;
|
|
208
|
+
try {
|
|
209
|
+
names = readdirSync(locksDir());
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
return names
|
|
215
|
+
.filter((n) => /^machine-[A-Za-z0-9_-]{1,56}\.lock$/.test(n))
|
|
216
|
+
.map((n) => n.slice(0, -'.lock'.length))
|
|
217
|
+
.filter((name) => lockHolderPid(name) === null);
|
|
218
|
+
}
|
|
219
|
+
function dropWorkerLock(name) {
|
|
220
|
+
// A stale lock is taken over (moved aside) by the same rule every worker uses, then given back
|
|
221
|
+
const lock = acquireLock(name, { staleMs: 60_000 });
|
|
222
|
+
if (!lock)
|
|
223
|
+
return false;
|
|
224
|
+
lock.release();
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
// -- FUNCTIONS: DOCTOR -- //
|
|
228
|
+
/**
|
|
229
|
+
* Runs every check. With `fix` it repairs what it may. Each problem has one plain sentence and the exact next step. `deps` lets a test stand in for docker, the browser
|
|
230
|
+
* install, the board trust rule and the clock.
|
|
231
|
+
*/
|
|
232
|
+
export async function runDoctor(ctx, opts, deps = {}) {
|
|
233
|
+
const fix = opts.fix;
|
|
234
|
+
const now = (deps.now ?? Date.now)();
|
|
235
|
+
const alive = deps.pidAlive ?? pidAlive;
|
|
236
|
+
const grace = deps.graceMs ?? GRACE_MS;
|
|
237
|
+
const keep = deps.keepRuns ?? KEEP_RUNS;
|
|
238
|
+
const fixed = [];
|
|
239
|
+
const problems = [];
|
|
240
|
+
const found = (problem, next) => problems.push({ problem, next });
|
|
241
|
+
const did = (text) => fixed.push(text);
|
|
242
|
+
const guard = async (what, fn) => {
|
|
243
|
+
try {
|
|
244
|
+
await fn();
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
found(`${what} could not be checked: ${cut(e instanceof Error ? e.message : String(e))}`, 'Run: agent-flow-board check doctor --fix --json');
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
// -- THE AUDIT CHAIN: EVIDENCE ONLY, NOTHING TO REPAIR -- //
|
|
251
|
+
await guard('The audit log', () => {
|
|
252
|
+
if (!verifyAudit().ok)
|
|
253
|
+
found('The audit log does not verify (it was edited or cut).', 'Do not edit it. Move checks/audit.log and audit.log.head aside to start a new chain.');
|
|
254
|
+
});
|
|
255
|
+
// -- THE SANDBOX -- //
|
|
256
|
+
if (!(deps.hasPermissionModel ?? (() => Boolean(permissionFlag())))())
|
|
257
|
+
found('This Node has no permission model, so check scripts cannot be sandboxed.', 'Install Node 20.18 or later (or any Node 22).');
|
|
258
|
+
// -- THE BROWSER INSTALL -- //
|
|
259
|
+
await guard('The browser install', async () => {
|
|
260
|
+
if ((deps.browserInstalled ?? (() => Boolean(playwrightRuntimeDir())))())
|
|
261
|
+
return;
|
|
262
|
+
if (fix) {
|
|
263
|
+
try {
|
|
264
|
+
await (deps.ensureBrowser ?? ensureBrowser)(ctx.log);
|
|
265
|
+
did('The browser was installed.');
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
found(`The browser could not be installed: ${cut(e instanceof Error ? e.message : String(e))}`, 'Check the network, then run: agent-flow-board check setup');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
else
|
|
272
|
+
found('The browser is not installed.', 'Run: agent-flow-board check doctor --fix');
|
|
273
|
+
});
|
|
274
|
+
// -- WHAT DEAD RUNS LEFT: FOLDERS, COPIES, CONTAINERS, PREVIEWS -- //
|
|
275
|
+
await guard('The runs', async () => {
|
|
276
|
+
const view = readRuns(alive);
|
|
277
|
+
if (fix) {
|
|
278
|
+
if (view.dead.length) {
|
|
279
|
+
const n = await sweepOrphans(ctx.log, { docker: deps.docker });
|
|
280
|
+
if (n)
|
|
281
|
+
did(`${n} run(s) left by a process that is gone were cleaned up (folder, copy, preview, database).`);
|
|
282
|
+
}
|
|
283
|
+
const fresh = readRuns(alive);
|
|
284
|
+
const stray = strayCopies(fresh, now, grace);
|
|
285
|
+
for (const id of stray)
|
|
286
|
+
removeTree(under(copiesDir(), id));
|
|
287
|
+
if (stray.length)
|
|
288
|
+
did(`${stray.length} private copy(ies) of a repo, left by runs that are gone, were removed.`);
|
|
289
|
+
const old = runsBeyond(fresh, keep, now, grace);
|
|
290
|
+
for (const id of old)
|
|
291
|
+
removeRunFolders(id);
|
|
292
|
+
if (old.length)
|
|
293
|
+
did(`${old.length} old run folder(s) were removed (the newest ${keep} are kept).`);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
if (view.dead.length)
|
|
297
|
+
found(`${view.dead.length} run(s) were left by a process that is gone (folders, copies, maybe a preview or a database).`, 'Run: agent-flow-board check doctor --fix');
|
|
298
|
+
const stray = strayCopies(view, now, grace);
|
|
299
|
+
if (stray.length)
|
|
300
|
+
found(`${stray.length} private copy(ies) of a repo belong to runs that are gone.`, 'Run: agent-flow-board check doctor --fix');
|
|
301
|
+
const old = runsBeyond(view, keep, now, grace);
|
|
302
|
+
if (old.length)
|
|
303
|
+
found(`${old.length} finished run folder(s) are beyond the newest ${keep}.`, 'Run: agent-flow-board check doctor --fix');
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
// -- THE POOLS: DATABASE, BROWSER, WARM PREVIEWS, AND ANY MARKER WITH A DEAD PROCESS ID -- //
|
|
307
|
+
await guard('The pools', async () => {
|
|
308
|
+
const deadPools = listPools().filter((p) => !p.ownerAlive && p.liveRuns === 0 && now - Date.parse(p.marker.createdAt) >= grace);
|
|
309
|
+
const deadBrowsers = listBrowserMarkers().filter((b) => !b.ownerAlive);
|
|
310
|
+
const marks = [
|
|
311
|
+
...deadMarkers(brokerDir(), alive).map((n) => ['broker', n]),
|
|
312
|
+
...deadMarkers(poolDir(), alive).map((n) => ['pool', n])
|
|
313
|
+
];
|
|
314
|
+
if (!fix) {
|
|
315
|
+
if (deadPools.length)
|
|
316
|
+
found(`${deadPools.length} pooled database container(s) have no owner and no live run: ${deadPools.map((p) => p.marker.containerName).join(', ')}.`, 'Run: agent-flow-board check doctor --fix');
|
|
317
|
+
if (deadBrowsers.length + marks.length)
|
|
318
|
+
found(`${deadBrowsers.length + marks.length} marker(s) of processes that are gone are left in the pool and broker folders.`, 'Run: agent-flow-board check doctor --fix');
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const removedPools = await (deps.sweepPools ?? (() => sweepPool({ docker: deps.docker })))();
|
|
322
|
+
if (removedPools.length)
|
|
323
|
+
did(`Pooled database container(s) removed by their recorded name: ${removedPools.join(', ')}.`);
|
|
324
|
+
const warm = await (deps.sweepWarm ?? sweepWarm)();
|
|
325
|
+
if (warm)
|
|
326
|
+
did(`${warm} warm preview(s) that expired or lost their owner were stopped.`);
|
|
327
|
+
const b = (deps.sweepBrowsers ?? sweepBrowserMarkers)();
|
|
328
|
+
const brokerNames = marks.filter(([w]) => w === 'broker').map(([, n]) => n);
|
|
329
|
+
const poolNames = marks.filter(([w]) => w === 'pool').map(([, n]) => n);
|
|
330
|
+
removeMarkers(brokerDir(), brokerNames);
|
|
331
|
+
removeMarkers(poolDir(), poolNames);
|
|
332
|
+
if (b + brokerNames.length + poolNames.length)
|
|
333
|
+
did(`${b + brokerNames.length + poolNames.length} marker(s) of processes that are gone were removed (slots, browsers, pools).`);
|
|
334
|
+
});
|
|
335
|
+
// -- THE WORKER LOCK -- //
|
|
336
|
+
await guard('The worker lock', () => {
|
|
337
|
+
const stale = staleWorkerLocks();
|
|
338
|
+
if (!stale.length)
|
|
339
|
+
return;
|
|
340
|
+
if (!fix) {
|
|
341
|
+
found('The lock of a worker that is no longer running is still there.', 'Run: agent-flow-board check doctor --fix');
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const gone = stale.filter(dropWorkerLock).length;
|
|
345
|
+
if (gone)
|
|
346
|
+
did(`${gone} stale worker lock(s) were removed.`);
|
|
347
|
+
});
|
|
348
|
+
// -- THE BOARDS: A CREDENTIAL FOR A BOARD THAT IS NOT TRUSTED, A CONNECTION WITH NO TOKEN -- //
|
|
349
|
+
await guard('The connections', () => {
|
|
350
|
+
const trusted = deps.trustedBoard ?? trustedBoard;
|
|
351
|
+
const tokenOf = deps.token ??
|
|
352
|
+
(ctx.store ? (s, o) => loadRunnerToken(ctx.store, s, o) : null);
|
|
353
|
+
for (const c of listConnections()) {
|
|
354
|
+
if (!trusted(c.server)) {
|
|
355
|
+
if (fix) {
|
|
356
|
+
ctx.store?.delete(runnerAccount(c.server, c.org));
|
|
357
|
+
forgetConnection(c.server, c.org);
|
|
358
|
+
did(`The credential for ${c.org} on a board that is not trusted was forgotten.`);
|
|
359
|
+
}
|
|
360
|
+
else
|
|
361
|
+
found(`${c.org} is connected to a board address that is not trusted (it must be https, or http on this machine).`, 'Run: agent-flow-board check doctor --fix');
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (tokenOf && !tokenOf(c.server, c.org))
|
|
365
|
+
found(`${c.org} on ${c.server} has no runner token.`, `Run: agent-flow-board check connect --server ${c.server} --org ${c.org} --json`);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
// -- THE REGISTRATIONS: DAMAGE, COLLISIONS, FOLDERS, PIN, SEALED STORES -- //
|
|
369
|
+
await guard('The registrations', () => {
|
|
370
|
+
const { registrations, problems: damaged } = readRegistrations();
|
|
371
|
+
for (const d of damaged)
|
|
372
|
+
found(d, 'Register it again: agent-flow-board check project <NAME> --org <slug> --repo <dir>');
|
|
373
|
+
for (const r of registrations) {
|
|
374
|
+
const id = `${r.org}/${r.name}`;
|
|
375
|
+
const collisions = collisionsOf(r);
|
|
376
|
+
for (const c of collisions)
|
|
377
|
+
found(c, `Run: agent-flow-board check project forget ${r.name} --org ${r.org} --remove (or register it with its own clone or app folder)`);
|
|
378
|
+
if (r.repo) {
|
|
379
|
+
if (!existsSync(r.repo))
|
|
380
|
+
found(`${id}: the repo folder is not there any more.`, `Run: agent-flow-board check project forget ${r.name} --org ${r.org} --remove, then register it again.`);
|
|
381
|
+
else if (!rootHolding(r.repo))
|
|
382
|
+
found(`${id}: the repo is not inside a folder the owner approved.`, 'Ask the owner: check approve --action roots --target <parent folder>, then check roots add <folder> --approval <code>.');
|
|
383
|
+
else {
|
|
384
|
+
const pin = pinState(r);
|
|
385
|
+
if (pin === 'changed') {
|
|
386
|
+
if (fix) {
|
|
387
|
+
try {
|
|
388
|
+
pinRegistration(r.org, r.name);
|
|
389
|
+
did(`${id}: the files that decide what runs had changed. They passed the guardrails and were pinned again.`);
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
found(`${id}: the files that decide what runs changed and were refused: ${cut(e instanceof Error ? e.message : String(e))}`, 'Fix what it names, then run: agent-flow-board check doctor --fix');
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
else
|
|
396
|
+
found(`${id}: the files that decide what runs changed since they were pinned.`, 'Run: agent-flow-board check doctor --fix (they are checked against the guardrails first)');
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
for (const p of storeProblems(r.org, r.name))
|
|
401
|
+
found(cut(p), `Import the sealed values again: agent-flow-board check secrets import --project ${r.name} --org ${r.org} (doctor never removes a store)`);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
// -- THE SERVICE: REPORT ONLY -- //
|
|
405
|
+
await guard('The service', () => {
|
|
406
|
+
const s = readServiceStatus();
|
|
407
|
+
if (s && s.state === 'stopped' && s.message)
|
|
408
|
+
found(`The worker service stopped: ${cut(s.message)}`, 'Run: agent-flow-board check service status');
|
|
409
|
+
});
|
|
410
|
+
return { ok: problems.length === 0, fixed, problems };
|
|
411
|
+
}
|
|
412
|
+
/** Plain sentences for every uniqueness rule this registration breaks (a file edited by hand, or a rule added after it was made). */
|
|
413
|
+
function collisionsOf(r) {
|
|
414
|
+
const out = [];
|
|
415
|
+
const id = `${r.org}/${r.name}`;
|
|
416
|
+
if (r.repo) {
|
|
417
|
+
const sibling = repoHeldBySibling(r.org, r.name, r.repo, r.appDir);
|
|
418
|
+
if (sibling)
|
|
419
|
+
out.push(`${id} and ${sibling} name the same repo folder. Two registrations never share a folder.`);
|
|
420
|
+
}
|
|
421
|
+
const clash = nameClash(r.org, r.name);
|
|
422
|
+
if (clash)
|
|
423
|
+
out.push(`${id} and ${clash} differ only by letter case.`);
|
|
424
|
+
const shadow = nameShadowsBoard(r.org, r.name, r.boardProject);
|
|
425
|
+
if (shadow)
|
|
426
|
+
out.push(`${id} and ${shadow} shadow each other's board project.`);
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
// -- FUNCTIONS: STATUS -- //
|
|
430
|
+
function registrationStatus(r) {
|
|
431
|
+
const count = (fn, fallback) => {
|
|
432
|
+
try {
|
|
433
|
+
return fn().length;
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return fallback;
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
const secrets = count(() => listSecrets(r.org, r.name).keys, null);
|
|
440
|
+
const signIns = count(() => listProfiles(r.org, r.name), 0) ?? 0;
|
|
441
|
+
let pin = 'n/a';
|
|
442
|
+
if (r.repo) {
|
|
443
|
+
try {
|
|
444
|
+
pin = pinState(r);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
pin = 'unreadable';
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
org: r.org,
|
|
452
|
+
name: r.name,
|
|
453
|
+
boardProject: r.boardProject,
|
|
454
|
+
kind: r.url ? 'site' : 'repo',
|
|
455
|
+
...(r.repo ? { folder: basename(r.repo) } : {}),
|
|
456
|
+
...(r.appDir ? { appDir: r.appDir } : {}),
|
|
457
|
+
pin,
|
|
458
|
+
signIns,
|
|
459
|
+
secrets,
|
|
460
|
+
adoptEnv: r.adoptEnv
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/** The state of this machine: registrations, the machine's slots, the service, the connections, the audit chain. Runs the read-only doctor checks too. */
|
|
464
|
+
export async function runStatus(ctx, deps = {}) {
|
|
465
|
+
const base = await runDoctor(ctx, { fix: false }, deps);
|
|
466
|
+
const tokenOf = deps.token ??
|
|
467
|
+
(ctx.store ? (s, o) => loadRunnerToken(ctx.store, s, o) : null);
|
|
468
|
+
const slots = (() => {
|
|
469
|
+
try {
|
|
470
|
+
return machineTotals();
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
})();
|
|
476
|
+
const svc = readServiceStatus();
|
|
477
|
+
const audit = verifyAudit();
|
|
478
|
+
return {
|
|
479
|
+
...base,
|
|
480
|
+
home: checksHome(),
|
|
481
|
+
registrations: readRegistrations().registrations.map(registrationStatus),
|
|
482
|
+
slots,
|
|
483
|
+
service: svc ? { state: svc.state, message: svc.message } : null,
|
|
484
|
+
connections: listConnections().map((c) => ({
|
|
485
|
+
server: c.server,
|
|
486
|
+
org: c.org,
|
|
487
|
+
connected: tokenOf ? Boolean(tokenOf(c.server, c.org)) : null
|
|
488
|
+
})),
|
|
489
|
+
audit: { ok: audit.ok, lines: audit.lines },
|
|
490
|
+
browser: (deps.browserInstalled ?? (() => Boolean(playwrightRuntimeDir())))()
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* `check status [--json]` and `check doctor [--fix] [--json]`. The first word of `args` is `status` or `doctor` (status when absent). Exit 0, or 1 when `doctor` lists a problem.
|
|
495
|
+
* Supersedes statusOrDoctor in command.ts.
|
|
496
|
+
*/
|
|
497
|
+
export async function healthCommand(args, ctx, deps = {}) {
|
|
498
|
+
const word = args[0] === 'doctor' ? 'doctor' : 'status';
|
|
499
|
+
const json = ctx.json ?? args.includes('--json');
|
|
500
|
+
const fix = args.includes('--fix');
|
|
501
|
+
try {
|
|
502
|
+
if (word === 'doctor') {
|
|
503
|
+
// Two doctors at once would race on the same files
|
|
504
|
+
const lock = fix ? acquireLock('doctor', { staleMs: 10 * 60_000 }) : null;
|
|
505
|
+
if (fix && !lock) {
|
|
506
|
+
emit(ctx, json, {
|
|
507
|
+
ok: false,
|
|
508
|
+
fixed: [],
|
|
509
|
+
problems: [
|
|
510
|
+
{ problem: 'Another doctor is running.', next: 'Wait for it, then run doctor again.' }
|
|
511
|
+
]
|
|
512
|
+
}, ['Another doctor is running. Wait for it, then run doctor again.']);
|
|
513
|
+
return 1;
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
const r = await runDoctor(ctx, { fix }, deps);
|
|
517
|
+
emit(ctx, json, r, [
|
|
518
|
+
...r.fixed.map((f) => `Fixed: ${f}`),
|
|
519
|
+
...r.problems.map((p) => `Problem: ${p.problem} Next: ${p.next}`),
|
|
520
|
+
r.ok
|
|
521
|
+
? 'Nothing is wrong.'
|
|
522
|
+
: 'Some problems are left. They are listed above with the exact next step.'
|
|
523
|
+
]);
|
|
524
|
+
return r.ok ? 0 : 1;
|
|
525
|
+
}
|
|
526
|
+
finally {
|
|
527
|
+
lock?.release();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const s = await runStatus(ctx, deps);
|
|
531
|
+
emit(ctx, json, s, [
|
|
532
|
+
`State folder: ${s.home}`,
|
|
533
|
+
`Registrations: ${s.registrations.map((r) => `${r.org}/${r.name} -> ${r.boardProject} (${r.kind === 'site' ? 'live site' : (r.folder ?? '')}${r.appDir ? `/${r.appDir}` : ''}, pin ${r.pin})`).join('; ') || 'none'}`,
|
|
534
|
+
`Slots: ${s.slots ? `${s.slots.busy} of ${s.slots.slots} busy` : 'unknown'}`,
|
|
535
|
+
`Service: ${s.service ? s.service.state : 'not installed'}`,
|
|
536
|
+
`Connections: ${s.connections.map((c) => `${c.org}@${c.server}${c.connected === false ? ' (no token)' : ''}`).join(', ') || 'none'}`,
|
|
537
|
+
`Audit chain: ${s.audit.ok ? 'ok' : 'BROKEN'} (${s.audit.lines} lines)`,
|
|
538
|
+
...s.problems.map((p) => `Problem: ${p.problem} Next: ${p.next}`),
|
|
539
|
+
s.problems.length ? '' : 'Nothing is wrong.'
|
|
540
|
+
].filter((l) => l !== ''));
|
|
541
|
+
return 0;
|
|
542
|
+
}
|
|
543
|
+
catch (e) {
|
|
544
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
545
|
+
if (json)
|
|
546
|
+
ctx.out(JSON.stringify({ ok: false, event: 'error', error: message }));
|
|
547
|
+
else
|
|
548
|
+
ctx.log(message);
|
|
549
|
+
return 2;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// -- FUNCTIONS: CLEAR -- //
|
|
553
|
+
export const CLEAR_USAGE = 'Usage: agent-flow-board check clear [runs|profiles|auth|dbs|previews|all] [--registration NAME --org <slug> [--site <address>]] [--yes] [--all]\n' +
|
|
554
|
+
' runs finished run folders and their copies (older than 7 days; every finished one with --all)\n' +
|
|
555
|
+
' profiles saved sign-ins of one registration (--registration, --org; --site for one address)\n' +
|
|
556
|
+
' auth the saved sign-ins of one registration and the runner token of its organisation\n' +
|
|
557
|
+
' dbs databases and containers the tool recorded, by their recorded names\n' +
|
|
558
|
+
' previews previews, browsers and warm previews that are left\n' +
|
|
559
|
+
' all runs (all finished), dbs and previews; never sign-ins or sealed secrets\n' +
|
|
560
|
+
' profiles, auth, dbs and all need --yes. The scripts folder, sealed secrets and the audit log are never touched.';
|
|
561
|
+
/**
|
|
562
|
+
* `check clear runs|profiles|auth|dbs|previews|all [--yes] [--all]`. Removes only exact recorded folders, markers, names and files; a live run is always kept. With no target it
|
|
563
|
+
* clears finished runs, as the earlier `clear` did.
|
|
564
|
+
*/
|
|
565
|
+
export async function clearCommand(args, ctx, deps = {}) {
|
|
566
|
+
const json = ctx.json ?? args.includes('--json');
|
|
567
|
+
const words = positional(args[0] === 'clear' ? args.slice(1) : args);
|
|
568
|
+
const target = (words[0] ?? 'runs');
|
|
569
|
+
const done = [];
|
|
570
|
+
const left = [];
|
|
571
|
+
try {
|
|
572
|
+
if (!CLEAR_TARGETS.includes(target)) {
|
|
573
|
+
ctx.log(CLEAR_USAGE);
|
|
574
|
+
return 2;
|
|
575
|
+
}
|
|
576
|
+
const all = args.includes('--all');
|
|
577
|
+
const yes = args.includes('--yes');
|
|
578
|
+
const needsYes = target === 'profiles' || target === 'auth' || target === 'dbs' || target === 'all';
|
|
579
|
+
if (needsYes && !yes) {
|
|
580
|
+
emit(ctx, json, { ok: false, error: 'This deletes data. Add --yes to confirm.' }, [
|
|
581
|
+
`This removes ${target === 'all' ? 'runs, databases and previews' : target}. Add --yes to confirm (nothing waits on a terminal).`
|
|
582
|
+
]);
|
|
583
|
+
return 2;
|
|
584
|
+
}
|
|
585
|
+
const alive = deps.pidAlive ?? pidAlive;
|
|
586
|
+
if (target === 'runs' || target === 'all') {
|
|
587
|
+
const r = clearRuns({ all: all || target === 'all' });
|
|
588
|
+
done.push(`${r.removed} finished run folder(s) removed`);
|
|
589
|
+
}
|
|
590
|
+
if (target === 'dbs' || target === 'all') {
|
|
591
|
+
// A run that is working keeps its database: only runs whose marker names a dead process are cleaned, and pools with no owner and no live run
|
|
592
|
+
const n = await sweepOrphans(ctx.log, { docker: deps.docker });
|
|
593
|
+
const pools = await (deps.sweepPools ?? (() => sweepPool({ docker: deps.docker, minAgeMs: 0 })))();
|
|
594
|
+
done.push(`${n} dead run(s) cleaned (their databases by recorded name), ${pools.length} pooled container(s) removed`);
|
|
595
|
+
if (readRuns(alive).live.size)
|
|
596
|
+
left.push('the databases of runs that are working');
|
|
597
|
+
}
|
|
598
|
+
if (target === 'previews' || target === 'all') {
|
|
599
|
+
const n = await sweepOrphans(ctx.log, { docker: deps.docker });
|
|
600
|
+
const warm = await (deps.sweepWarm ?? sweepWarm)();
|
|
601
|
+
const b = (deps.sweepBrowsers ?? sweepBrowserMarkers)();
|
|
602
|
+
const marks = deadMarkers(poolDir(), alive);
|
|
603
|
+
removeMarkers(poolDir(), marks);
|
|
604
|
+
done.push(`${n} dead run(s) cleaned, ${warm} warm preview(s) stopped, ${b + marks.length} marker(s) removed`);
|
|
605
|
+
}
|
|
606
|
+
if (target === 'profiles' || target === 'auth') {
|
|
607
|
+
const org = flagValue(args, '--org');
|
|
608
|
+
const name = flagValue(args, '--registration');
|
|
609
|
+
if (!org || !name) {
|
|
610
|
+
ctx.log(`clear ${target} needs the registration: --registration NAME --org <slug>.`);
|
|
611
|
+
return 2;
|
|
612
|
+
}
|
|
613
|
+
const reg = findRegistration(org, name);
|
|
614
|
+
if (!reg) {
|
|
615
|
+
ctx.log(`There is no registration ${name} in ${org}.`);
|
|
616
|
+
return 2;
|
|
617
|
+
}
|
|
618
|
+
const site = flagValue(args, '--site');
|
|
619
|
+
const entries = listProfiles(org, reg.name).filter((p) => !site || p.file === fileNameFor(site));
|
|
620
|
+
let gone = 0;
|
|
621
|
+
for (const p of entries)
|
|
622
|
+
if (removeProfile(org, reg.name, p.file))
|
|
623
|
+
gone++;
|
|
624
|
+
done.push(`${gone} saved sign-in(s) of ${org}/${reg.name} removed`);
|
|
625
|
+
if (target === 'auth') {
|
|
626
|
+
let tokens = 0;
|
|
627
|
+
for (const c of listConnections().filter((x) => x.org === org)) {
|
|
628
|
+
ctx.store?.delete(runnerAccount(c.server, c.org));
|
|
629
|
+
forgetConnection(c.server, c.org);
|
|
630
|
+
tokens++;
|
|
631
|
+
}
|
|
632
|
+
done.push(`${tokens} connection(s) of ${org} forgotten (connect again to work for them)`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
emit(ctx, json, { ok: true, target, done, left }, [
|
|
636
|
+
...done.map((d) => `Done: ${d}.`),
|
|
637
|
+
left.length ? `Left alone because a run is still using them: ${left.join(', ')}.` : 'Done.'
|
|
638
|
+
]);
|
|
639
|
+
return 0;
|
|
640
|
+
}
|
|
641
|
+
catch (e) {
|
|
642
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
643
|
+
if (json)
|
|
644
|
+
ctx.out(JSON.stringify({ ok: false, event: 'error', error: message }));
|
|
645
|
+
else
|
|
646
|
+
ctx.log(message);
|
|
647
|
+
return 2;
|
|
648
|
+
}
|
|
649
|
+
}
|