@agent-flow-board/bridge 0.1.0 → 0.2.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 +13 -1
- package/dist/checks/audit.js +125 -0
- package/dist/checks/bind.js +99 -0
- package/dist/checks/board.js +107 -0
- package/dist/checks/browser.js +170 -0
- package/dist/checks/child.js +211 -0
- package/dist/checks/command.js +280 -0
- package/dist/checks/connect.js +79 -0
- package/dist/checks/connections.js +41 -0
- package/dist/checks/envPolicy.js +163 -0
- package/dist/checks/guard.js +188 -0
- package/dist/checks/guardrails.js +155 -0
- package/dist/checks/lock.js +183 -0
- package/dist/checks/paths.js +61 -0
- package/dist/checks/preview.js +350 -0
- package/dist/checks/provision.js +175 -0
- package/dist/checks/recipe.js +232 -0
- package/dist/checks/redact.js +52 -0
- package/dist/checks/run.js +253 -0
- package/dist/checks/sandbox.js +186 -0
- package/dist/checks/scope.js +294 -0
- package/dist/checks/signin.js +73 -0
- package/dist/checks/types.js +2 -0
- package/dist/checks/watch.js +101 -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,294 @@
|
|
|
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.
|
|
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
|
+
// folder that was not made by a run.
|
|
6
|
+
// -- DEP-MODULES -- //
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
// -- MODULES -- //
|
|
12
|
+
import { pidAlive } from './lock.js';
|
|
13
|
+
import { NAME, copiesDir, ensureDir, runsDir, under } from './paths.js';
|
|
14
|
+
import { removeContainer } from './provision.js';
|
|
15
|
+
// -- 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
|
+
/** 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
|
+
const NO_MARKER_GRACE_MS = 15 * 60_000;
|
|
21
|
+
const DAY_MS = 86_400_000;
|
|
22
|
+
// -- FUNCTIONS -- //
|
|
23
|
+
/** 'r' + a timestamp + random characters: a plain name, and sortable by time. */
|
|
24
|
+
export const newRunId = () => 'r' + Date.now().toString(36) + randomBytes(3).toString('hex');
|
|
25
|
+
const noop = () => { };
|
|
26
|
+
/** Writes a file whole: a temporary file renamed into place, so a reader never sees half of it. */
|
|
27
|
+
function writeWhole(file, text) {
|
|
28
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
29
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
30
|
+
renameSync(tmp, file);
|
|
31
|
+
}
|
|
32
|
+
function readMarker(dir) {
|
|
33
|
+
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
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export class RunScope {
|
|
45
|
+
runId;
|
|
46
|
+
disposers = [];
|
|
47
|
+
log;
|
|
48
|
+
info = {};
|
|
49
|
+
started = false;
|
|
50
|
+
closed = false;
|
|
51
|
+
constructor(runId = newRunId(), log = noop) {
|
|
52
|
+
if (!NAME.test(runId))
|
|
53
|
+
throw new Error('The run id is not a plain name.');
|
|
54
|
+
this.runId = runId;
|
|
55
|
+
this.log = log;
|
|
56
|
+
}
|
|
57
|
+
/** The folder of this run. */
|
|
58
|
+
get dir() {
|
|
59
|
+
return under(runsDir(), this.runId);
|
|
60
|
+
}
|
|
61
|
+
/** Writes the live marker. Called before anything else is made; every method that makes a resource calls it too, so it can never be skipped. */
|
|
62
|
+
start() {
|
|
63
|
+
if (this.started)
|
|
64
|
+
return;
|
|
65
|
+
ensureDir(this.dir);
|
|
66
|
+
this.started = true;
|
|
67
|
+
this.writeMarker();
|
|
68
|
+
}
|
|
69
|
+
writeMarker() {
|
|
70
|
+
const marker = {
|
|
71
|
+
runId: this.runId,
|
|
72
|
+
pid: process.pid,
|
|
73
|
+
startedAt: new Date().toISOString(),
|
|
74
|
+
...this.info
|
|
75
|
+
};
|
|
76
|
+
writeWhole(join(this.dir, MARKER), JSON.stringify(marker) + '\n');
|
|
77
|
+
}
|
|
78
|
+
/** 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
|
+
setInfo(info) {
|
|
80
|
+
this.start();
|
|
81
|
+
this.info = { ...this.info, ...info };
|
|
82
|
+
this.writeMarker();
|
|
83
|
+
}
|
|
84
|
+
/** Registers a resource. It is closed when the scope closes, newest first. */
|
|
85
|
+
add(name, dispose) {
|
|
86
|
+
this.start();
|
|
87
|
+
this.disposers.push({ name, dispose });
|
|
88
|
+
}
|
|
89
|
+
/** Closes every resource newest first, then removes the marker. Never throws; a failure goes to the log. Safe to call twice. */
|
|
90
|
+
async close() {
|
|
91
|
+
if (this.closed)
|
|
92
|
+
return;
|
|
93
|
+
this.closed = true;
|
|
94
|
+
while (this.disposers.length) {
|
|
95
|
+
const item = this.disposers.pop();
|
|
96
|
+
if (!item)
|
|
97
|
+
break;
|
|
98
|
+
try {
|
|
99
|
+
await item.dispose();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
this.log(`Could not close ${item.name}.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
if (this.started) {
|
|
107
|
+
writeWhole(join(this.dir, ENDED), new Date().toISOString() + '\n');
|
|
108
|
+
rmSync(join(this.dir, MARKER), { force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
this.log('Could not remove the live marker of this run.');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Removes a folder without ever walking through a link: a symlink or junction is removed as a link (not recursed into), a folder is emptied then removed.
|
|
118
|
+
* Retries a few times because Windows holds files of a process that has only just been killed.
|
|
119
|
+
*/
|
|
120
|
+
export function removeTree(path) {
|
|
121
|
+
let st;
|
|
122
|
+
try {
|
|
123
|
+
st = lstatSync(path);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (st.isSymbolicLink()) {
|
|
129
|
+
try {
|
|
130
|
+
rmSync(path, { recursive: false, force: true });
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
try {
|
|
134
|
+
unlinkSync(path);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
rmdirSync(path);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!st.isDirectory()) {
|
|
143
|
+
rmSync(path, { force: true, maxRetries: 5, retryDelay: 100 });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const entry of readdirSync(path))
|
|
147
|
+
removeTree(join(path, entry));
|
|
148
|
+
// Windows holds a folder for a moment after a process that used it was killed
|
|
149
|
+
for (let i = 0;; i++) {
|
|
150
|
+
try {
|
|
151
|
+
rmdirSync(path);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
if (i >= 5 || e.code === 'ENOENT') {
|
|
156
|
+
if (e.code === 'ENOENT')
|
|
157
|
+
return;
|
|
158
|
+
throw e;
|
|
159
|
+
}
|
|
160
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Kills a process and everything it started. */
|
|
165
|
+
export function killTree(pid) {
|
|
166
|
+
if (!Number.isInteger(pid) || pid <= 1)
|
|
167
|
+
return Promise.resolve();
|
|
168
|
+
if (process.platform === 'win32') {
|
|
169
|
+
// Windows cannot deliver SIGTERM to a child, so the tree is ended by taskkill
|
|
170
|
+
return new Promise((resolve) => {
|
|
171
|
+
execFile('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true }, () => resolve());
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// A child spawned detached leads its own group: the negative id reaches the whole group. A child that is not a leader falls back to itself
|
|
175
|
+
const send = (signal) => {
|
|
176
|
+
try {
|
|
177
|
+
process.kill(-pid, signal);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
try {
|
|
181
|
+
process.kill(pid, signal);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* Already gone */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
send('SIGTERM');
|
|
189
|
+
return new Promise((resolve) => {
|
|
190
|
+
const until = Date.now() + 3_000;
|
|
191
|
+
const timer = setInterval(() => {
|
|
192
|
+
if (!pidAlive(pid)) {
|
|
193
|
+
clearInterval(timer);
|
|
194
|
+
resolve();
|
|
195
|
+
}
|
|
196
|
+
else if (Date.now() > until) {
|
|
197
|
+
clearInterval(timer);
|
|
198
|
+
send('SIGKILL');
|
|
199
|
+
resolve();
|
|
200
|
+
}
|
|
201
|
+
}, 100);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/** On a stop signal: closes the scope, then calls onKill (which exits the process). Returns the function that removes the handlers. */
|
|
205
|
+
export function installSignalHandlers(scope, onKill) {
|
|
206
|
+
let busy = false;
|
|
207
|
+
const handler = () => {
|
|
208
|
+
if (busy)
|
|
209
|
+
return;
|
|
210
|
+
busy = true;
|
|
211
|
+
void scope.close().finally(() => onKill());
|
|
212
|
+
};
|
|
213
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGBREAK'];
|
|
214
|
+
for (const s of signals)
|
|
215
|
+
process.on(s, handler);
|
|
216
|
+
return () => {
|
|
217
|
+
for (const s of signals)
|
|
218
|
+
process.off(s, handler);
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/** Removes a run folder and its private copy by their exact paths. */
|
|
222
|
+
function removeRun(id) {
|
|
223
|
+
removeTree(under(copiesDir(), id));
|
|
224
|
+
removeTree(under(runsDir(), id));
|
|
225
|
+
}
|
|
226
|
+
const ageOf = (path) => {
|
|
227
|
+
try {
|
|
228
|
+
return Date.now() - statSync(path).mtimeMs;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const plainIds = (dir) => {
|
|
235
|
+
if (!existsSync(dir))
|
|
236
|
+
return [];
|
|
237
|
+
return readdirSync(dir).filter((n) => NAME.test(n));
|
|
238
|
+
};
|
|
239
|
+
/**
|
|
240
|
+
* 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
|
+
* Stops the recorded preview process tree and database container, then removes only those run folders and copies. Returns how many runs it cleaned.
|
|
242
|
+
*/
|
|
243
|
+
export async function sweepOrphans(log = noop) {
|
|
244
|
+
let cleaned = 0;
|
|
245
|
+
const seen = new Set();
|
|
246
|
+
for (const id of plainIds(runsDir())) {
|
|
247
|
+
seen.add(id);
|
|
248
|
+
const dir = under(runsDir(), id);
|
|
249
|
+
const marker = readMarker(dir);
|
|
250
|
+
if (marker) {
|
|
251
|
+
if (pidAlive(marker.pid))
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
else if (existsSync(join(dir, MARKER)) ||
|
|
255
|
+
existsSync(join(dir, ENDED)) ||
|
|
256
|
+
ageOf(dir) < NO_MARKER_GRACE_MS) {
|
|
257
|
+
continue; // Finished in order (clearRuns handles it), or too young to judge
|
|
258
|
+
}
|
|
259
|
+
if (marker?.previewPid && pidAlive(marker.previewPid))
|
|
260
|
+
await killTree(marker.previewPid);
|
|
261
|
+
if (marker?.containerName)
|
|
262
|
+
await removeContainer(marker.containerName);
|
|
263
|
+
removeRun(id);
|
|
264
|
+
cleaned++;
|
|
265
|
+
log(`Cleaned the leftovers of run ${id}.`);
|
|
266
|
+
}
|
|
267
|
+
// A copy whose run folder is gone: removed when it is old enough that no run is still making it
|
|
268
|
+
for (const id of plainIds(copiesDir())) {
|
|
269
|
+
if (seen.has(id) || ageOf(under(copiesDir(), id)) < NO_MARKER_GRACE_MS)
|
|
270
|
+
continue;
|
|
271
|
+
removeTree(under(copiesDir(), id));
|
|
272
|
+
cleaned++;
|
|
273
|
+
log(`Removed the orphaned copy of run ${id}.`);
|
|
274
|
+
}
|
|
275
|
+
return cleaned;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Removes finished run folders (and their copies). A run with the live marker of a running process is always kept; a run whose process died is left to
|
|
279
|
+
* sweepOrphans, which also stops what it left running. By default only runs older than 7 days go; `all` removes every finished one.
|
|
280
|
+
*/
|
|
281
|
+
export function clearRuns(opts = {}) {
|
|
282
|
+
const limit = (opts.olderThanDays ?? 7) * DAY_MS;
|
|
283
|
+
let removed = 0;
|
|
284
|
+
for (const id of plainIds(runsDir())) {
|
|
285
|
+
const dir = under(runsDir(), id);
|
|
286
|
+
if (existsSync(join(dir, MARKER)))
|
|
287
|
+
continue;
|
|
288
|
+
if (!opts.all && ageOf(dir) < limit)
|
|
289
|
+
continue;
|
|
290
|
+
removeRun(id);
|
|
291
|
+
removed++;
|
|
292
|
+
}
|
|
293
|
+
return { removed };
|
|
294
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SIGNING IN TO THE PREVIEW AS ITS TEST USER
|
|
2
|
+
// The recipe may name a preview-only account (a user the app seeds in the throwaway database). The tool types its email and password into the
|
|
3
|
+
// app's own sign in form in a browser that records nothing, keeps only the cookies and storage it ends with, and forgets the password. The
|
|
4
|
+
// password is only ever typed into the app under test: the sign in page must be on its own address, and nothing else is reachable.
|
|
5
|
+
// -- MODULES -- //
|
|
6
|
+
import { loadPlaywright } from './browser.js';
|
|
7
|
+
import { redact } from './redact.js';
|
|
8
|
+
// -- CONSTANTS -- //
|
|
9
|
+
const DEFAULT_PAGE = {
|
|
10
|
+
path: '/sign-in',
|
|
11
|
+
emailLabel: 'Email',
|
|
12
|
+
passwordLabel: 'Password',
|
|
13
|
+
submit: 'Sign in'
|
|
14
|
+
};
|
|
15
|
+
// -- FUNCTIONS -- //
|
|
16
|
+
const mask = (email) => email.replace(/^(.).*(@.*)$/, '$1***$2');
|
|
17
|
+
/** Signs in through the app's own form and returns the browser state. The error never holds the password. */
|
|
18
|
+
export async function signInWithPassword(baseUrl, account, page = {}) {
|
|
19
|
+
const s = { ...DEFAULT_PAGE, ...page };
|
|
20
|
+
const origin = new URL(baseUrl).origin;
|
|
21
|
+
if (new URL(s.path, baseUrl).origin !== origin)
|
|
22
|
+
throw new Error(`The sign in page ${s.path} is not on ${origin}, so nothing was typed.`);
|
|
23
|
+
const { chromium } = await loadPlaywright();
|
|
24
|
+
const browser = await chromium.launch({
|
|
25
|
+
headless: true,
|
|
26
|
+
args: ['--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1, EXCLUDE localhost']
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
const ctx = await browser.newContext({ baseURL: baseUrl, serviceWorkers: 'block' });
|
|
30
|
+
await ctx.route('**/*', (route) => new URL(route.request().url()).origin === origin || route.request().url().startsWith('data:')
|
|
31
|
+
? route.continue()
|
|
32
|
+
: route.abort());
|
|
33
|
+
const tab = await ctx.newPage();
|
|
34
|
+
await tab.goto(s.path, { waitUntil: 'load', timeout: 30_000 });
|
|
35
|
+
await tab.getByLabel(s.emailLabel, { exact: true }).fill(account.email);
|
|
36
|
+
await tab.getByLabel(s.passwordLabel, { exact: true }).fill(account.password);
|
|
37
|
+
await tab.getByRole('button', { name: s.submit, exact: true }).click();
|
|
38
|
+
try {
|
|
39
|
+
await tab.waitForURL((u) => u.pathname !== new URL(s.path, baseUrl).pathname, {
|
|
40
|
+
timeout: 20_000
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
const shown = (await tab
|
|
45
|
+
.locator('[role=alert]')
|
|
46
|
+
.first()
|
|
47
|
+
.textContent({ timeout: 1000 })
|
|
48
|
+
.catch(() => null))?.trim();
|
|
49
|
+
throw new Error(redact(`Signing in as ${mask(account.email)} did not leave ${s.path}${shown ? `. The page says: ${shown.slice(0, 200)}` : ''}. An account that only signs in with Google has no password: use a recipe test user, which has one.`, [account.password]));
|
|
50
|
+
}
|
|
51
|
+
return await ctx.storageState();
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await browser.close().catch(() => undefined);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Every secret value in a saved browser state (cookie values and stored values), so a run never prints or uploads them. */
|
|
58
|
+
export function stateNeedles(state) {
|
|
59
|
+
const out = new Set();
|
|
60
|
+
const add = (v) => {
|
|
61
|
+
if (typeof v === 'string' && v.length >= 8) {
|
|
62
|
+
out.add(v);
|
|
63
|
+
out.add(encodeURIComponent(v));
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const s = state;
|
|
67
|
+
for (const c of s?.cookies ?? [])
|
|
68
|
+
add(c.value);
|
|
69
|
+
for (const o of s?.origins ?? [])
|
|
70
|
+
for (const l of o.localStorage ?? [])
|
|
71
|
+
add(l.value);
|
|
72
|
+
return [...out];
|
|
73
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// `check watch`: TAKE QUEUED RUNS FROM EVERY CONNECTED BOARD (THE RUN BUTTON ON THE BOARD)
|
|
2
|
+
// One worker per machine. A queued run is taken only for a project that has a local binding (a folder where `check run` was used) and whose
|
|
3
|
+
// pinned files have not changed since: with none, the run ends "could not run" and the current directory is never a fallback.
|
|
4
|
+
// -- MODULES -- //
|
|
5
|
+
import { appendAudit } from './audit.js';
|
|
6
|
+
import { boundFolder, pinStatus } from './bind.js';
|
|
7
|
+
import { RunnerBoard } from './board.js';
|
|
8
|
+
import { listConnections } from './connections.js';
|
|
9
|
+
import { loadRunnerToken } from './connect.js';
|
|
10
|
+
import { machineLock } from './lock.js';
|
|
11
|
+
import { runCheck } from './run.js';
|
|
12
|
+
import { sweepOrphans } from './scope.js';
|
|
13
|
+
// -- CONSTANTS -- //
|
|
14
|
+
const POLL_MS = 5000;
|
|
15
|
+
const sleep = (ms, signal) => new Promise((resolve) => {
|
|
16
|
+
const t = setTimeout(resolve, ms);
|
|
17
|
+
signal.addEventListener('abort', () => {
|
|
18
|
+
clearTimeout(t);
|
|
19
|
+
resolve();
|
|
20
|
+
}, { once: true });
|
|
21
|
+
});
|
|
22
|
+
/** Runs the worker until the signal aborts (or one pass with `once`). Returns how many runs it took. */
|
|
23
|
+
export async function watchChecks(o) {
|
|
24
|
+
const lock = machineLock('watch');
|
|
25
|
+
if (!lock)
|
|
26
|
+
throw new Error('Another "check watch" is already running on this machine. Only one worker runs per machine.');
|
|
27
|
+
const slots = Math.max(1, Math.min(o.parallel, 8));
|
|
28
|
+
const held = new Map();
|
|
29
|
+
let taken = 0;
|
|
30
|
+
try {
|
|
31
|
+
await sweepOrphans(o.log);
|
|
32
|
+
appendAudit('watch-start', { slots });
|
|
33
|
+
do {
|
|
34
|
+
// The connections are read again on every loop, so a new `check connect` is picked up without a restart
|
|
35
|
+
for (const c of listConnections()) {
|
|
36
|
+
if (held.size >= slots || o.signal.aborted)
|
|
37
|
+
break;
|
|
38
|
+
const token = loadRunnerToken(o.store, c.server, c.org);
|
|
39
|
+
if (!token)
|
|
40
|
+
continue;
|
|
41
|
+
const board = new RunnerBoard(c.server, token, o.fetch, () => ({
|
|
42
|
+
'x-afb-tool-slots': `${slots}:${held.size}`
|
|
43
|
+
}));
|
|
44
|
+
let job = null;
|
|
45
|
+
try {
|
|
46
|
+
if ((await board.peek()) > 0)
|
|
47
|
+
job = await board.claimNext();
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
o.log(`${c.org}: ${e instanceof Error ? e.message : String(e)}`);
|
|
51
|
+
}
|
|
52
|
+
if (!job)
|
|
53
|
+
continue;
|
|
54
|
+
taken++;
|
|
55
|
+
const claimed = job;
|
|
56
|
+
const done = handle(o, c.server, c.org, token, claimed).finally(() => held.delete(claimed.id));
|
|
57
|
+
held.set(claimed.id, { board, done });
|
|
58
|
+
}
|
|
59
|
+
if (o.once)
|
|
60
|
+
break;
|
|
61
|
+
await sleep(POLL_MS, o.signal);
|
|
62
|
+
} while (!o.signal.aborted);
|
|
63
|
+
await Promise.allSettled([...held.values()].map((h) => h.done));
|
|
64
|
+
return taken;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
// Runs still held when the worker stops go back to the queue, only these, never another process's runs
|
|
68
|
+
const byBoard = new Map();
|
|
69
|
+
for (const [id, h] of held)
|
|
70
|
+
byBoard.set(h.board, [...(byBoard.get(h.board) ?? []), id]);
|
|
71
|
+
for (const [board, ids] of byBoard)
|
|
72
|
+
await board.release(ids).catch(() => undefined);
|
|
73
|
+
appendAudit('watch-stop', { taken });
|
|
74
|
+
lock.release();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function handle(o, server, org, token, job) {
|
|
78
|
+
const board = new RunnerBoard(server, token, o.fetch);
|
|
79
|
+
const fail = (reason) => board.finish(job.id, { result: 'error', reason }).catch(() => undefined);
|
|
80
|
+
const projectKey = job.project.key;
|
|
81
|
+
const bound = boundFolder(org, projectKey);
|
|
82
|
+
if (!bound)
|
|
83
|
+
return void (await fail(`This machine has no folder for ${projectKey}. In the project's folder run: agent-flow-board check run ${job.item.ref} ${job.check.key} --org ${org}`));
|
|
84
|
+
if (pinStatus(org, projectKey) !== 'ok')
|
|
85
|
+
return void (await fail(`The files that decide what runs in ${bound.folder} changed since they were approved. In that folder run: agent-flow-board check run ${job.item.ref} ${job.check.key} --org ${org}`));
|
|
86
|
+
o.log(`${org}: running ${job.item.ref} ${job.check.key} in ${bound.folder}`);
|
|
87
|
+
const outcome = await runCheck({
|
|
88
|
+
server,
|
|
89
|
+
org,
|
|
90
|
+
item: job.item.ref,
|
|
91
|
+
key: job.check.key,
|
|
92
|
+
dir: bound.folder,
|
|
93
|
+
step: job.stepKey ?? undefined,
|
|
94
|
+
claimed: job,
|
|
95
|
+
runnerToken: token,
|
|
96
|
+
personalToken: null,
|
|
97
|
+
fetch: o.fetch,
|
|
98
|
+
log: o.log
|
|
99
|
+
});
|
|
100
|
+
o.log(`${org}: ${job.item.ref} ${job.check.key}: ${outcome.message}`);
|
|
101
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// agent-flow-board hook claude|antigravity|gemini [event] one hook event from stdin, one JSON object to stdout
|
|
9
9
|
// agent-flow-board hooks print claude|antigravity|gemini [--org <slug>] the settings to paste into the tool
|
|
10
10
|
// agent-flow-board git-hook install|uninstall|check the warn-only post-commit hook
|
|
11
|
+
// agent-flow-board check connect|run|pull|bind|watch|status|doctor|clear|setup|disconnect browser checks on this machine (see check usage)
|
|
11
12
|
// agent-flow-board mockup push <file.html> --server <url> --org <slug> --item <ref> upload one mockup file (never queued)
|
|
12
13
|
// agent-flow-board mockup kit <file.html> --server <url> --org <slug> --project <key> upload the project's kit (colours, fonts, icons)
|
|
13
14
|
// -- DEP-MODULES -- //
|
|
@@ -95,6 +96,22 @@ async function belongsToOrg(server, org, token) {
|
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
async function main() {
|
|
99
|
+
if (cmd === 'check') {
|
|
100
|
+
// Loaded only here, so the hooks and the MCP forwarder never load the check code or Playwright
|
|
101
|
+
const { checkMain } = await import('./checks/command.js');
|
|
102
|
+
const code = await checkMain({
|
|
103
|
+
args: args.slice(1),
|
|
104
|
+
store,
|
|
105
|
+
fetch: fetchImpl,
|
|
106
|
+
personalToken: (server, org) => tokenFor(server.replace(/\/$/, ''), org, true),
|
|
107
|
+
log,
|
|
108
|
+
out: (line) => process.stdout.write(line + '\n')
|
|
109
|
+
});
|
|
110
|
+
// A browser or a preview may still hold a handle open: the exit code is what the caller reads, so end now
|
|
111
|
+
process.exitCode = code;
|
|
112
|
+
setTimeout(() => process.exit(code), 300).unref();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
98
115
|
if (cmd === 'hook') {
|
|
99
116
|
// A hook prints exactly one JSON object and always exits 0, so it can never break the tool that runs it
|
|
100
117
|
let out = {};
|
|
@@ -258,7 +275,7 @@ async function main() {
|
|
|
258
275
|
return;
|
|
259
276
|
}
|
|
260
277
|
if (cmd !== 'mcp' && cmd !== 'login') {
|
|
261
|
-
log('Usage: agent-flow-board mcp|login|logout --server <url>, setup --org <slug>, queue list|retry <id>|drop <id>,
|
|
278
|
+
log('Usage: agent-flow-board mcp|login|logout --server <url>, setup --org <slug>, queue list|retry <id>|drop <id>, mockup push <file.html>, or check connect|run|watch|status');
|
|
262
279
|
process.exit(2);
|
|
263
280
|
}
|
|
264
281
|
const server = serverUrl();
|
package/dist/hooks/adapters.js
CHANGED
|
@@ -75,7 +75,9 @@ export const claude = {
|
|
|
75
75
|
hookEventName: 'SessionStart',
|
|
76
76
|
additionalContext: a.context ?? '',
|
|
77
77
|
...(a.title ? { sessionTitle: a.title } : {})
|
|
78
|
-
}
|
|
78
|
+
},
|
|
79
|
+
// The one line for the person (an older editor status mod), shown once as the conversation starts
|
|
80
|
+
...(a.notice ? { systemMessage: a.notice } : {})
|
|
79
81
|
};
|
|
80
82
|
if (event === 'prompt' && a.title)
|
|
81
83
|
return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', sessionTitle: a.title } };
|
package/dist/hooks/call.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto';
|
|
|
4
4
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { configDir } from '../tokenStore.js';
|
|
7
|
+
import { installedModVersion } from './modVersion.js';
|
|
7
8
|
// -- CONSTANTS -- //
|
|
8
9
|
const TIMEOUT_MS = 4000;
|
|
9
10
|
// -- FUNCTIONS -- //
|
|
@@ -57,10 +58,14 @@ export async function callHook(o, event) {
|
|
|
57
58
|
warnOnce(`the server ${o.server} did not give an organisation for this sign-in.`);
|
|
58
59
|
return null;
|
|
59
60
|
}
|
|
61
|
+
// A Claude Code start tells the board which editor status mod is installed, so it can say when a newer one is served
|
|
62
|
+
const modVersion = event.tool === 'claude-code' && event.event === 'start'
|
|
63
|
+
? installedModVersion(o.home)
|
|
64
|
+
: undefined;
|
|
60
65
|
const res = await o.fetch(`${o.server}/api/v1/orgs/${org}/hooks`, {
|
|
61
66
|
method: 'POST',
|
|
62
67
|
headers: { authorization: `Bearer ${o.token}`, 'content-type': 'application/json' },
|
|
63
|
-
body: JSON.stringify(event),
|
|
68
|
+
body: JSON.stringify(modVersion ? { ...event, modVersion } : event),
|
|
64
69
|
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
65
70
|
});
|
|
66
71
|
if (!res.ok)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// THE VERSION OF THE EDITOR STATUS MOD THIS PERSON HAS INSTALLED (AFB-20261108-0856)
|
|
2
|
+
// The setup prompt writes the mod to ~/.claude/skills/afb-flow-status. The hook sends the version on a start so the board can say
|
|
3
|
+
// when a newer one is served. A hook must never fail or slow down because of this file, so every problem means "no version".
|
|
4
|
+
// -- DEP-MODULES -- //
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
// -- CONSTANTS -- //
|
|
9
|
+
const MOD_FOLDER = ['.claude', 'skills', 'afb-flow-status'];
|
|
10
|
+
const VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}$/;
|
|
11
|
+
/** A manifest is a few hundred bytes: a file far bigger than that is not read. */
|
|
12
|
+
const MAX_BYTES = 64 * 1024;
|
|
13
|
+
// -- FUNCTIONS -- //
|
|
14
|
+
/** The installed mod's plain major.minor.patch version, or undefined when there is no mod, no readable manifest or no plain version. */
|
|
15
|
+
export function installedModVersion(home = homedir()) {
|
|
16
|
+
try {
|
|
17
|
+
const text = readFileSync(join(home, ...MOD_FOLDER, '.claude-plugin', 'plugin.json'), 'utf8');
|
|
18
|
+
if (text.length > MAX_BYTES)
|
|
19
|
+
return undefined;
|
|
20
|
+
const version = JSON.parse(text).version;
|
|
21
|
+
return typeof version === 'string' && VERSION.test(version) ? version : undefined;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-flow-board/bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Local bridge to an agent-flow-board server: forwards MCP calls over stdio, runs the coding tool hooks
|
|
5
|
+
"description": "Local bridge to an agent-flow-board server: forwards MCP calls over stdio, runs the coding tool hooks, writes the editor status and runs browser checks on this machine. It holds no board tool code.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://afb.arbenger.com",
|
|
8
8
|
"repository": {
|