@agentpulselabs/cli-plugin-pulse 1.1.3 → 1.1.5
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/package.json +3 -2
- package/src/hub/core/db.mjs +21 -5
- package/src/hub/workers/manager.mjs +44 -6
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/daemonRuntime.js +25 -7
- package/src/lib/hubDbReader.js +6 -0
- package/src/lib/hubScan.js +1 -0
- package/src/lib/ledger.js +21 -12
- package/src/lib/lifecycle.js +3 -1
- package/src/lib/runnerStore.js +61 -49
- package/src/runner/bridge.js +8 -3
- package/src/runner/capture.js +83 -20
- package/src/runner/channelDiscipline.js +48 -14
- package/src/runner/channelWalk.js +42 -0
- package/src/runner/deliveryStates.js +22 -9
- package/src/runner/directoryCache.js +34 -0
- package/src/runner/facade.js +71 -14
- package/src/runner/hubCapture.js +105 -56
- package/src/runner/loopMonitor.js +89 -0
- package/src/runner/runnerHub.js +3 -1
- package/src/runner/runnerProcess.js +58 -16
- package/src/runner/runnerService.js +18 -2
- package/src/runner/serial.js +5 -0
- package/src/runner/spool.js +46 -11
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open, readdir, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parsePosts } from '../hub/lib/channels.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* hub/lib/channels.mjs `channelFiles` without blocking the event loop: every roots/<repo>/.agents/<task>/channel.md
|
|
8
|
+
* changed since sinceMs, in the same order, with its size as well, so a caller can tell an unchanged file by its
|
|
9
|
+
* mtime and size without reading it.
|
|
10
|
+
*/
|
|
11
|
+
export async function channelFilesAsync(roots, sinceMs) {
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const root of roots) {
|
|
14
|
+
let repos = [];
|
|
15
|
+
try { repos = await readdir(root); } catch { continue; }
|
|
16
|
+
for (const repo of repos) {
|
|
17
|
+
const dir = path.join(root, repo, '.agents');
|
|
18
|
+
let tasks = [];
|
|
19
|
+
try { tasks = await readdir(dir); } catch { continue; }
|
|
20
|
+
const found = await Promise.all(tasks.map(async (task) => {
|
|
21
|
+
const file = path.join(dir, task, 'channel.md');
|
|
22
|
+
try {
|
|
23
|
+
const st = await stat(file);
|
|
24
|
+
return st.isFile() && st.mtimeMs >= sinceMs ? { key: `${repo}/${task}`, task, repo, file, mtime: st.mtimeMs, size: st.size } : null;
|
|
25
|
+
} catch { return null; }
|
|
26
|
+
}));
|
|
27
|
+
out.push(...found.filter(Boolean));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A channel's posts, read without following a symlink: `{ posts, mtime }`, or null when it can't be read or is over maxBytes. */
|
|
34
|
+
export async function readPostsAsync(file, maxBytes) {
|
|
35
|
+
let handle;
|
|
36
|
+
try {
|
|
37
|
+
handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
38
|
+
const st = await handle.stat();
|
|
39
|
+
if (!st.isFile() || st.size > maxBytes) return null;
|
|
40
|
+
return { posts: parsePosts(await handle.readFile('utf8'), st.mtimeMs), mtime: st.mtimeMs };
|
|
41
|
+
} catch { return null; } finally { await handle?.close(); }
|
|
42
|
+
}
|
|
@@ -134,18 +134,32 @@ export function createDeliveryStates(hub, bus, { now: clock = Date.now } = {}) {
|
|
|
134
134
|
* waited BOUNCE_GRACE_MS for a recipient nobody can read: its sender gets one UNDELIVERABLE notice per recipient,
|
|
135
135
|
* from the manager, with an id derived from the message and recipient. A notice is never itself bounced.
|
|
136
136
|
*/
|
|
137
|
-
|
|
138
|
-
const rows = db.prepare(`SELECT d.message_id AS id, d.recipient, m.sender, m.type, m.body FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
137
|
+
const BOUNCE_FROM = `FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
139
138
|
WHERE d.state = 'queued' AND m.expires_at > ? AND m.created_at <= ? AND m.created_at > ?
|
|
140
139
|
AND m.sender <> 'manager' AND m.type <> 'UNDELIVERABLE' AND d.recipient NOT IN ('user', 'manager')
|
|
141
140
|
AND NOT EXISTS (SELECT 1 FROM pulse_reported r WHERE r.message_id = m.id)
|
|
142
|
-
AND NOT EXISTS (SELECT 1 FROM pulse_bounced b WHERE b.message_id = d.message_id AND b.recipient = d.recipient)
|
|
143
|
-
|
|
141
|
+
AND NOT EXISTS (SELECT 1 FROM pulse_bounced b WHERE b.message_id = d.message_id AND b.recipient = d.recipient)`;
|
|
142
|
+
const bounceRows = db.prepare(`SELECT d.message_id AS id, d.recipient, m.sender, m.type, m.body ${BOUNCE_FROM} ORDER BY m.created_at LIMIT ?`);
|
|
143
|
+
const stillBounceable = db.prepare(`SELECT 1 AS found ${BOUNCE_FROM} AND d.message_id = ? AND d.recipient = ?`);
|
|
144
|
+
const bounceWindow = (now) => [now, now - BOUNCE_GRACE_MS, now - DEFAULT_TTL_MS];
|
|
145
|
+
|
|
146
|
+
function bounce(now = clock()) {
|
|
144
147
|
const sent = [];
|
|
148
|
+
for (const step of bounceSteps({ now, sent })) step();
|
|
149
|
+
return sent;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The bounce as one step per candidate row, for capture's chunks: the candidates are read up front, and each step
|
|
154
|
+
* checks its row is still queued and not yet bounced or reported in the transaction it runs in. Pushes to `sent`.
|
|
155
|
+
*/
|
|
156
|
+
function bounceSteps({ now = clock(), sent = [] } = {}) {
|
|
157
|
+
const rows = bounceRows.all(...bounceWindow(now), BOUNCE_BATCH);
|
|
145
158
|
let dir = null;
|
|
146
|
-
|
|
159
|
+
return rows.map((r) => () => {
|
|
160
|
+
if (!stillBounceable.get(...bounceWindow(now), r.id, r.recipient)) return;
|
|
147
161
|
const why = bus.reach(r.recipient, now);
|
|
148
|
-
if (why.state !== 'undeliverable')
|
|
162
|
+
if (why.state !== 'undeliverable') return;
|
|
149
163
|
dir ??= registry.directory(now);
|
|
150
164
|
const id = edgeId('u', r.id, r.recipient);
|
|
151
165
|
const reason = why.reason === 'stale' ? `its session ${why.session} has had no activity for ${why.idleMin} min`
|
|
@@ -157,8 +171,7 @@ export function createDeliveryStates(hub, bus, { now: clock = Date.now } = {}) {
|
|
|
157
171
|
}, dir);
|
|
158
172
|
q.bounced.run(r.id, r.recipient, env.error ? `error:${env.error}` : id, now);
|
|
159
173
|
if (!env.error) sent.push({ id: r.id, to: r.recipient, notice: id });
|
|
160
|
-
}
|
|
161
|
-
return sent;
|
|
174
|
+
});
|
|
162
175
|
}
|
|
163
176
|
|
|
164
177
|
/**
|
|
@@ -216,5 +229,5 @@ export function createDeliveryStates(hub, bus, { now: clock = Date.now } = {}) {
|
|
|
216
229
|
return [...open].filter(([, settled]) => settled).map(([id]) => id);
|
|
217
230
|
}
|
|
218
231
|
|
|
219
|
-
return { readOnHook, managerFyi, reports, honest, withDelivery, status, senderOf, report, bounce, postedSince, settleNudges, settledNudgeRecipients };
|
|
232
|
+
return { readOnHook, managerFyi, reports, honest, withDelivery, status, senderOf, report, bounce, bounceSteps, postedSince, settleNudges, settledNudgeRecipients };
|
|
220
233
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ONLINE_MS } from '../hub/core/ids.mjs';
|
|
2
|
+
|
|
3
|
+
const WATCHED = ['actors', 'presence'];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `registry.directory(now)` for the runner's connection, built again only when it could come out different: after a
|
|
7
|
+
* write to actors or presence on this connection (temp triggers set a new random token, which a rollback puts back),
|
|
8
|
+
* after a commit on another connection (`PRAGMA data_version`), or once `now` crosses a presence's online edge
|
|
9
|
+
* (`last_seen + ONLINE_MS`), the only way the clock changes it. Callers share the array and must not change it.
|
|
10
|
+
*/
|
|
11
|
+
export function cachedDirectory(db, registry) {
|
|
12
|
+
db.prepare('CREATE TEMP TABLE IF NOT EXISTS pulse_directory_token (one INTEGER PRIMARY KEY CHECK (one = 1), token TEXT NOT NULL)').run();
|
|
13
|
+
db.prepare('INSERT OR IGNORE INTO temp.pulse_directory_token (one, token) VALUES (1, hex(randomblob(8)))').run();
|
|
14
|
+
for (const table of WATCHED) {
|
|
15
|
+
for (const op of ['INSERT', 'UPDATE', 'DELETE']) {
|
|
16
|
+
db.prepare(`CREATE TEMP TRIGGER IF NOT EXISTS pulse_directory_${table}_${op.toLowerCase()} AFTER ${op} ON main.${table}
|
|
17
|
+
BEGIN UPDATE temp.pulse_directory_token SET token = hex(randomblob(8)) WHERE one = 1; END`).run();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const token = db.prepare('SELECT token FROM temp.pulse_directory_token WHERE one = 1');
|
|
21
|
+
const version = db.prepare('PRAGMA data_version');
|
|
22
|
+
const lastEdge = db.prepare('SELECT MAX(last_seen) AS t FROM presence WHERE last_seen <= ?');
|
|
23
|
+
const nextEdge = db.prepare('SELECT MIN(last_seen) AS t FROM presence WHERE last_seen > ?');
|
|
24
|
+
let kept = null;
|
|
25
|
+
return (now = Date.now()) => {
|
|
26
|
+
const key = `${version.get().data_version}:${token.get().token}`;
|
|
27
|
+
if (kept && kept.key === key && now >= kept.from && now < kept.until) return kept.dir;
|
|
28
|
+
const dir = registry.directory(now);
|
|
29
|
+
const before = lastEdge.get(now - ONLINE_MS).t;
|
|
30
|
+
const after = nextEdge.get(now - ONLINE_MS).t;
|
|
31
|
+
kept = { key, dir, from: before == null ? -Infinity : before + ONLINE_MS, until: after == null ? Infinity : after + ONLINE_MS };
|
|
32
|
+
return dir;
|
|
33
|
+
};
|
|
34
|
+
}
|
package/src/runner/facade.js
CHANGED
|
@@ -39,6 +39,8 @@ import { execFileBounded } from '../lib/subprocess.js';
|
|
|
39
39
|
import { createBusIdentity, routedMessaging } from './busIdentity.js';
|
|
40
40
|
import { createChannelDiscipline } from './channelDiscipline.js';
|
|
41
41
|
import { createDeliveryStates } from './deliveryStates.js';
|
|
42
|
+
import { UNMONITORED } from './loopMonitor.js';
|
|
43
|
+
import { serial } from './serial.js';
|
|
42
44
|
import { statusPage } from './statusPage.js';
|
|
43
45
|
|
|
44
46
|
const WORKSPACE = path.resolve(HUB, '..');
|
|
@@ -133,6 +135,7 @@ export function plainCapture(db) {
|
|
|
133
135
|
tx: (fn, { guard = null, label = 'write' } = {}) => withTx(db, () => { const out = fn(); if (guard) guard(); return out; }, label),
|
|
134
136
|
applying: (fn) => fn(),
|
|
135
137
|
pump: () => 0,
|
|
138
|
+
chunks: async (steps, { label = 'write' } = {}) => { withTx(db, () => { for (const step of steps) step(); }, label); return 1; },
|
|
136
139
|
};
|
|
137
140
|
}
|
|
138
141
|
|
|
@@ -292,13 +295,31 @@ const hasTable = (db, name) => Boolean(db.prepare("SELECT 1 AS found FROM sqlite
|
|
|
292
295
|
/** Without a cloud lease (tests, a bare hub) this runner is the only one, as the local hub was. */
|
|
293
296
|
const SOLE_LEASE = Object.freeze({ held: () => true, view: () => ({ holder: 'local', here: true }), onChange: () => {} });
|
|
294
297
|
|
|
298
|
+
/**
|
|
299
|
+
* A lease that lapses here only while this process stalled (a long runner.db transaction holds the event loop) comes back
|
|
300
|
+
* as soon as the queued report is read. The manager pauses meanwhile and is stopped, killing its runs, only past this.
|
|
301
|
+
*/
|
|
302
|
+
export const MANAGER_STOP_GRACE_MS = 60e3;
|
|
303
|
+
const MANAGER_LOG_MAX_BYTES = 1024 * 1024;
|
|
304
|
+
|
|
305
|
+
/** state/manager.log: what the manager did wrong, with its time, also on stderr; one older file is kept. */
|
|
306
|
+
export function managerLog(file, { echo = console.error } = {}) {
|
|
307
|
+
return (line) => {
|
|
308
|
+
echo(line);
|
|
309
|
+
try {
|
|
310
|
+
if ((fs.statSync(file, { throwIfNoEntry: false })?.size || 0) > MANAGER_LOG_MAX_BYTES) fs.renameSync(file, `${file}.1`);
|
|
311
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
|
|
312
|
+
} catch { /* stderr has it */ }
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
295
316
|
export function serve({
|
|
296
317
|
hub, capture = plainCapture(hub.db), port = 8890, listenHost = '127.0.0.1', stateDir = path.join(HUB, 'state'), dbFile = null,
|
|
297
318
|
enableCollector = true, enableManager = true, collect = null, channelRoots = CHANNEL_ROOTS,
|
|
298
319
|
transcriptsDir = path.join(os.homedir(), '.cursor', 'projects'), watchDir = watchRecursive, identity = null,
|
|
299
320
|
lease = SOLE_LEASE, pulseUrl = null, health = () => ({ state: 'online' }), metrics = () => ({}), drivers = null,
|
|
300
321
|
hookBudgetMs = HOOK_BUDGET_MS, writeBudgetMs = WRITE_BUDGET_MS, onWrite = () => {}, managerOptions = {}, workspaceRoot = WORKSPACE,
|
|
301
|
-
discipline: disciplineOptions = {},
|
|
322
|
+
discipline: disciplineOptions = {}, loop = UNMONITORED,
|
|
302
323
|
} = {}) {
|
|
303
324
|
const me = () => {
|
|
304
325
|
const m = identity ? identity.current() : NOBODY;
|
|
@@ -320,8 +341,9 @@ export function serve({
|
|
|
320
341
|
channelRoots, workspaceRoot, append: appendNoFollow, ...thresholds,
|
|
321
342
|
publish: (message) => hub.messaging.publish(message, hub.registry.directory()),
|
|
322
343
|
});
|
|
344
|
+
const logManager = managerOptions.log || managerLog(path.join(stateDir, 'manager.log'));
|
|
323
345
|
const manager = enableManager
|
|
324
|
-
? createManager({ hub, state: stateDir, onChange: () => graphs.clear(), held: () => lease.held(), fyi: (m) => delivery.managerFyi(m), ...managerOptions })
|
|
346
|
+
? createManager({ hub, state: stateDir, onChange: () => graphs.clear(), held: () => lease.held(), fyi: (m) => delivery.managerFyi(m), ...managerOptions, log: logManager })
|
|
325
347
|
: { start() {}, stop() {}, view() { return { state: 'offline', online: false, pending: 0 }; } };
|
|
326
348
|
/** The manager's status, with the DONE and STATUS posts it has read (F2). */
|
|
327
349
|
const managerView = () => ({ ...manager.view(), reports: delivery.reports() });
|
|
@@ -334,30 +356,65 @@ export function serve({
|
|
|
334
356
|
}
|
|
335
357
|
if (serverLease.error) console.error(`another hub server holds the lease (${serverLease.holder}); taking over`);
|
|
336
358
|
const timers = [];
|
|
337
|
-
const every = (ms, fn) => { const t = setInterval(fn, ms); t.unref(); timers.push(t); };
|
|
359
|
+
const every = (ms, fn, name = 'timer') => { const t = setInterval(() => loop.tick(name, fn), ms); t.unref(); timers.push(t); };
|
|
338
360
|
if (serverLease.token) {
|
|
339
|
-
every(10e3, () => { try { hub.leases.renew('server', serverLease.token, { ttlMs: 30e3 }); } catch (e) { console.error(`server lease renewal failed: ${e.message}`); } });
|
|
361
|
+
every(10e3, () => { try { hub.leases.renew('server', serverLease.token, { ttlMs: 30e3 }); } catch (e) { console.error(`server lease renewal failed: ${e.message}`); } }, 'server lease');
|
|
340
362
|
}
|
|
341
363
|
const releaseLease = () => { if (serverLease.token) { try { hub.leases.release('server', serverLease.token); } catch { /* closing */ } } };
|
|
342
364
|
const wrote = () => { feed.dbWrite(); onWrite(); };
|
|
343
365
|
/** The manager, the wake sweep and the scheduled nudge belong to the runner holding the user's manager lease (Q9). */
|
|
344
366
|
const leaseHeld = () => lease.held();
|
|
367
|
+
const deliverySweep = serial(async () => {
|
|
368
|
+
try {
|
|
369
|
+
const settled = capture.tx(() => delivery.settleNudges(), { label: 'delivery sweep' });
|
|
370
|
+
const bounced = [];
|
|
371
|
+
await capture.chunks(delivery.bounceSteps({ sent: bounced }), { label: 'delivery bounce' });
|
|
372
|
+
if (settled + bounced.length) { caches.clear(); wrote(); }
|
|
373
|
+
} catch (e) { console.error(`delivery sweep failed: ${e.message}`); }
|
|
374
|
+
});
|
|
375
|
+
const staleSweep = serial(async () => {
|
|
376
|
+
try {
|
|
377
|
+
const seen = await discipline.scan();
|
|
378
|
+
if (leaseHeld() && capture.tx(() => discipline.sweep({ seen }).length, { label: 'stale sweep' })) { caches.clear(); wrote(); }
|
|
379
|
+
} catch (e) { console.error(`stale sweep failed: ${e.message}`); }
|
|
380
|
+
});
|
|
345
381
|
if (enableManager) {
|
|
346
382
|
if (leaseHeld()) manager.start();
|
|
347
|
-
|
|
383
|
+
let lapsedAt = null;
|
|
384
|
+
let stopTimer = null;
|
|
385
|
+
lease.onChange((held) => {
|
|
386
|
+
const { fence } = lease.view();
|
|
387
|
+
if (held) {
|
|
388
|
+
clearTimeout(stopTimer);
|
|
389
|
+
stopTimer = null;
|
|
390
|
+
if (lapsedAt) {
|
|
391
|
+
logManager(`manager lease held again (fence ${fence})${fence !== lapsedAt.fence ? `; it moved from fence ${lapsedAt.fence}, so runs from before are stopped` : ''}`);
|
|
392
|
+
if (fence !== lapsedAt.fence) manager.stop();
|
|
393
|
+
}
|
|
394
|
+
lapsedAt = null;
|
|
395
|
+
manager.start();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (lapsedAt) return;
|
|
399
|
+
lapsedAt = { fence };
|
|
400
|
+
logManager(`manager lease lapsed here (fence ${fence}); runs in flight keep going, and are stopped if it is not back in ${MANAGER_STOP_GRACE_MS / 1000} s`);
|
|
401
|
+
stopTimer = setTimeout(() => {
|
|
402
|
+
stopTimer = null;
|
|
403
|
+
if (lease.held()) return;
|
|
404
|
+
logManager('manager lease still not held: the manager is stopped');
|
|
405
|
+
manager.stop();
|
|
406
|
+
}, MANAGER_STOP_GRACE_MS);
|
|
407
|
+
stopTimer.unref();
|
|
408
|
+
});
|
|
348
409
|
every(60e3, () => {
|
|
349
410
|
if (!leaseHeld()) return;
|
|
350
411
|
try { if (capture.tx(() => sweepWakes(hub), { label: 'wake sweep' }).length) { caches.clear(); wrote(); } } catch (e) { console.error(`wake sweep failed: ${e.message}`); }
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
} catch (e) { console.error(`delivery sweep failed: ${e.message}`); }
|
|
354
|
-
});
|
|
412
|
+
return deliverySweep();
|
|
413
|
+
}, 'wake and delivery sweep');
|
|
355
414
|
every(disciplineSweepMs, () => {
|
|
356
415
|
if (!leaseHeld()) return;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
} catch (e) { console.error(`stale sweep failed: ${e.message}`); }
|
|
360
|
-
});
|
|
416
|
+
return staleSweep();
|
|
417
|
+
}, 'stale sweep');
|
|
361
418
|
}
|
|
362
419
|
const runner = createRunner({ hub, drivers: drivers || defaultDrivers(), workspaceRoot: WORKSPACE, onChange: () => { graphs.clear(); wrote(); } });
|
|
363
420
|
if (enableManager) runner.begin();
|
|
@@ -369,7 +426,7 @@ export function serve({
|
|
|
369
426
|
});
|
|
370
427
|
if (capture.tx(nudge, { label: 'scheduled nudge' })) { caches.clear(); wrote(); }
|
|
371
428
|
} catch (e) { console.error(`scheduled nudge failed: ${e.message}`); }
|
|
372
|
-
});
|
|
429
|
+
}, 'scheduled nudge');
|
|
373
430
|
|
|
374
431
|
const registeredAt = hasTable(hub.db, 'pulse_registered')
|
|
375
432
|
? hub.db.prepare('INSERT INTO pulse_registered (id, at) VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET at = excluded.at')
|
package/src/runner/hubCapture.js
CHANGED
|
@@ -46,52 +46,89 @@ export function createHubCapture({ store, hubDir, roots, home, approvedRoots = [
|
|
|
46
46
|
return { message: { ...message, meta: parse(message.meta) ?? {} }, to: snapshot.recipients([id]).get(id) || [] };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (recent && !snapshot.messageExists(change.k1)) scan.messageGone(context, { id: recent.id, group: recent.grp });
|
|
59
|
-
return [];
|
|
60
|
-
}
|
|
61
|
-
const found = messageWithRecipients(snapshot, db, change.k1);
|
|
62
|
-
if (found) scan.messageRow(context, found.message, found.to);
|
|
63
|
-
return [];
|
|
64
|
-
}
|
|
65
|
-
case 'deliveries': {
|
|
66
|
-
if (change.op === 'delete') return [];
|
|
67
|
-
if (!store.known(change.k1)) {
|
|
68
|
-
const found = messageWithRecipients(snapshot, db, change.k1);
|
|
69
|
-
if (found) scan.messageRow(context, found.message, found.to);
|
|
70
|
-
return [];
|
|
71
|
-
}
|
|
72
|
-
const row = snapshot.receipt(change.k1, change.k2);
|
|
73
|
-
scan.receiptRow(context, { messageId: change.k1, recipient: change.k2, state: store.receiptState(change.k1, change.k2), row });
|
|
74
|
-
return [];
|
|
75
|
-
}
|
|
76
|
-
case 'actors': {
|
|
77
|
-
const row = db.prepare('SELECT * FROM actors WHERE id = ?').get(change.k1);
|
|
78
|
-
if (row) scan.actorRow(context, rowActor(row));
|
|
79
|
-
return [];
|
|
80
|
-
}
|
|
81
|
-
case 'facts': {
|
|
82
|
-
const row = db.prepare('SELECT kind, key, payload, updated_at FROM facts WHERE kind = ? AND key = ?').get(change.k1, change.k2);
|
|
83
|
-
if (row) scan.factRow(context, { ...row });
|
|
84
|
-
else if (REMOVABLE_KINDS.includes(change.k1)) scan.factRemoved(context, change.k1, change.k2);
|
|
49
|
+
const actorById = (db, id) => db.prepare('SELECT * FROM actors WHERE id = ?').get(id);
|
|
50
|
+
const factByKey = (db, kind, key) => db.prepare('SELECT kind, key, payload, updated_at FROM facts WHERE kind = ? AND key = ?').get(kind, key);
|
|
51
|
+
|
|
52
|
+
function commandsIn(snapshot, context, change, db) {
|
|
53
|
+
switch (change.tbl) {
|
|
54
|
+
case 'messages': {
|
|
55
|
+
if (change.op === 'delete') {
|
|
56
|
+
const recent = db.prepare('SELECT id, grp FROM recent_message WHERE id = ?').get(change.k1);
|
|
57
|
+
if (recent && !snapshot.messageExists(change.k1)) scan.messageGone(context, { id: recent.id, group: recent.grp });
|
|
85
58
|
return [];
|
|
86
59
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
60
|
+
const found = messageWithRecipients(snapshot, db, change.k1);
|
|
61
|
+
if (found) scan.messageRow(context, found.message, found.to);
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
case 'deliveries': {
|
|
65
|
+
if (change.op === 'delete') return [];
|
|
66
|
+
if (!store.known(change.k1)) {
|
|
67
|
+
const found = messageWithRecipients(snapshot, db, change.k1);
|
|
68
|
+
if (found) scan.messageRow(context, found.message, found.to);
|
|
90
69
|
return [];
|
|
91
70
|
}
|
|
92
|
-
|
|
93
|
-
|
|
71
|
+
const row = snapshot.receipt(change.k1, change.k2);
|
|
72
|
+
scan.receiptRow(context, { messageId: change.k1, recipient: change.k2, state: store.receiptState(change.k1, change.k2), row });
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
case 'actors': {
|
|
76
|
+
const row = actorById(db, change.k1);
|
|
77
|
+
if (row) scan.actorRow(context, rowActor(row));
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
case 'facts': {
|
|
81
|
+
const row = factByKey(db, change.k1, change.k2);
|
|
82
|
+
if (row) scan.factRow(context, { ...row });
|
|
83
|
+
else if (REMOVABLE_KINDS.includes(change.k1)) scan.factRemoved(context, change.k1, change.k2);
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
case 'events': {
|
|
87
|
+
const event = db.prepare('SELECT id, at, kind, actor, payload FROM events WHERE id = ?').get(change.k1);
|
|
88
|
+
if (event) scan.eventRow(context, { ...event, payload: parse(event.payload) ?? {} });
|
|
89
|
+
return [];
|
|
94
90
|
}
|
|
91
|
+
default:
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The keys of every removable fact the hub still has, as the sweep's fact pass would count them present. */
|
|
97
|
+
function presentFacts(db) {
|
|
98
|
+
const present = new Map(REMOVABLE_KINDS.map((kind) => [kind, new Set()]));
|
|
99
|
+
const statement = db.prepare('SELECT key, payload FROM facts WHERE kind = ?');
|
|
100
|
+
for (const kind of REMOVABLE_KINDS.filter((k) => FACT_KINDS.includes(k))) {
|
|
101
|
+
for (const row of statement.all(kind)) {
|
|
102
|
+
const fact = parse(row.payload);
|
|
103
|
+
if (!fact) continue;
|
|
104
|
+
present.get(kind).add(row.key);
|
|
105
|
+
if (kind === 'delivery') for (const stream of fact.streams || []) present.get('stream').add(stream.key);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return present;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function removeDropped(context, db) {
|
|
112
|
+
const present = presentFacts(db);
|
|
113
|
+
for (const kind of REMOVABLE_KINDS) {
|
|
114
|
+
for (const entity of store.entities(`fact:${kind}:`)) {
|
|
115
|
+
const key = entity.slice(`fact:${kind}:`.length);
|
|
116
|
+
if (!present.get(kind).has(key)) scan.factRemoved(context, kind, key);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
scan,
|
|
123
|
+
commands(change, db) {
|
|
124
|
+
return commandsIn(new HubSnapshot(db), contextFor(db), change, db);
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
/** `commands` for one drained page of journal rows, which share one scan context. */
|
|
128
|
+
batch(db) {
|
|
129
|
+
const snapshot = new HubSnapshot(db);
|
|
130
|
+
let context = null;
|
|
131
|
+
return (change) => commandsIn(snapshot, (context ??= contextFor(db)), change, db);
|
|
95
132
|
},
|
|
96
133
|
|
|
97
134
|
/**
|
|
@@ -103,7 +140,7 @@ export function createHubCapture({ store, hubDir, roots, home, approvedRoots = [
|
|
|
103
140
|
const context = contextFor(db);
|
|
104
141
|
switch (change.tbl) {
|
|
105
142
|
case 'actors': {
|
|
106
|
-
const row = db
|
|
143
|
+
const row = actorById(db, change.k1);
|
|
107
144
|
if (!row) return;
|
|
108
145
|
const actor = rowActor(row);
|
|
109
146
|
const entity = `actor:${actor.id}`;
|
|
@@ -116,7 +153,7 @@ export function createHubCapture({ store, hubDir, roots, home, approvedRoots = [
|
|
|
116
153
|
return;
|
|
117
154
|
}
|
|
118
155
|
case 'facts': {
|
|
119
|
-
const row = db
|
|
156
|
+
const row = factByKey(db, change.k1, change.k2);
|
|
120
157
|
const fact = row && parse(row.payload);
|
|
121
158
|
if (!fact || !FACT_KINDS.includes(row.kind)) return;
|
|
122
159
|
scan.factCommands(context, { ...row }, fact).forEach((item, index) => {
|
|
@@ -141,20 +178,32 @@ export function createHubCapture({ store, hubDir, roots, home, approvedRoots = [
|
|
|
141
178
|
const snapshot = new HubSnapshot(db);
|
|
142
179
|
const context = contextFor(db);
|
|
143
180
|
for (const actor of snapshot.actors()) scan.actorRow(context, actor);
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
const fact = scan.factRow(context, row);
|
|
147
|
-
if (!fact) continue;
|
|
148
|
-
present.get(row.kind)?.add(row.key);
|
|
149
|
-
if (row.kind === 'delivery') for (const stream of fact.streams || []) present.get('stream').add(stream.key);
|
|
150
|
-
}
|
|
151
|
-
for (const kind of REMOVABLE_KINDS) {
|
|
152
|
-
for (const entity of store.entities(`fact:${kind}:`)) {
|
|
153
|
-
const key = entity.slice(`fact:${kind}:`.length);
|
|
154
|
-
if (!present.get(kind).has(key)) scan.factRemoved(context, kind, key);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
181
|
+
for (const row of snapshot.facts(ALL_FACT_KINDS)) scan.factRow(context, row);
|
|
182
|
+
removeDropped(context, db);
|
|
157
183
|
return context.report;
|
|
158
184
|
},
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The sweep as steps for capture's chunks, in the sweep's own order: each actor and fact read again when its step
|
|
188
|
+
* runs (a row written between chunks is swept as it is then), and the removed facts last, from the keys the hub
|
|
189
|
+
* holds at that moment, so a fact added mid-sweep is never taken for a removed one.
|
|
190
|
+
*/
|
|
191
|
+
sweepSteps(db) {
|
|
192
|
+
let context = null;
|
|
193
|
+
const current = () => (context ??= contextFor(db));
|
|
194
|
+
const ids = db.prepare('SELECT id FROM actors ORDER BY rowid').all().map((row) => row.id);
|
|
195
|
+
const facts = new HubSnapshot(db).factKeys(ALL_FACT_KINDS);
|
|
196
|
+
return [
|
|
197
|
+
...ids.map((id) => () => {
|
|
198
|
+
const row = actorById(db, id);
|
|
199
|
+
if (row) scan.actorRow(current(), rowActor(row));
|
|
200
|
+
}),
|
|
201
|
+
...facts.map(({ kind, key }) => () => {
|
|
202
|
+
const row = factByKey(db, kind, key);
|
|
203
|
+
if (row) scan.factRow(current(), { ...row });
|
|
204
|
+
}),
|
|
205
|
+
() => removeDropped(current(), db),
|
|
206
|
+
];
|
|
207
|
+
},
|
|
159
208
|
};
|
|
160
209
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
|
2
|
+
|
|
3
|
+
/** The window `runner.loopDelay` in daemon-health.json covers, in one-minute histograms. */
|
|
4
|
+
export const LOOP_WINDOW_MS = 5 * 60e3;
|
|
5
|
+
/** A tick that holds the event loop this long is logged as `loop_stall`, with its name. */
|
|
6
|
+
export const STALL_MS = 2_000;
|
|
7
|
+
const MINUTE_MS = 60e3;
|
|
8
|
+
const PROBE_MS = 100;
|
|
9
|
+
const RESOLUTION_MS = 20;
|
|
10
|
+
|
|
11
|
+
const ms = (ns) => Math.round(ns / 1e5) / 10;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The runner's event-loop delay (perf_hooks.monitorEventLoopDelay), kept as one histogram per minute for the last five:
|
|
15
|
+
* `view()` gives the window's exact max, the worst minute's p99 and the median of the minutes' p50s (histograms of
|
|
16
|
+
* this kind can't be merged). `tick(name, fn)` runs a tick under its name: one whose synchronous part runs past
|
|
17
|
+
* stallMs is logged as `loop_stall` with that name, and any other stall the probe sees is logged with the name of the
|
|
18
|
+
* tick that started last.
|
|
19
|
+
*/
|
|
20
|
+
export function createLoopMonitor({ log, windowMs = LOOP_WINDOW_MS, stallMs = STALL_MS, now = () => performance.now() }) {
|
|
21
|
+
const minutes = [];
|
|
22
|
+
const open = () => {
|
|
23
|
+
const histogram = monitorEventLoopDelay({ resolution: RESOLUTION_MS });
|
|
24
|
+
histogram.enable();
|
|
25
|
+
return { at: now(), histogram };
|
|
26
|
+
};
|
|
27
|
+
let live = open();
|
|
28
|
+
let current = null;
|
|
29
|
+
let claimed = false;
|
|
30
|
+
let last = now();
|
|
31
|
+
|
|
32
|
+
const probe = setInterval(() => {
|
|
33
|
+
const at = now();
|
|
34
|
+
const gap = at - last - PROBE_MS;
|
|
35
|
+
last = at;
|
|
36
|
+
if (gap > stallMs && !claimed) log('loop_stall', { ms: Math.round(gap), tick: current?.name ?? 'untracked', ...(current ? { running: current.running } : {}) });
|
|
37
|
+
claimed = false;
|
|
38
|
+
if (at - live.at >= MINUTE_MS) {
|
|
39
|
+
live.histogram.disable();
|
|
40
|
+
minutes.push(live);
|
|
41
|
+
while (minutes.length && at - minutes[0].at >= windowMs) minutes.shift();
|
|
42
|
+
live = open();
|
|
43
|
+
}
|
|
44
|
+
}, PROBE_MS);
|
|
45
|
+
probe.unref();
|
|
46
|
+
|
|
47
|
+
function tick(name, fn) {
|
|
48
|
+
const entry = { name, running: true };
|
|
49
|
+
current = entry;
|
|
50
|
+
const started = now();
|
|
51
|
+
const settle = () => { entry.running = false; };
|
|
52
|
+
let out;
|
|
53
|
+
try {
|
|
54
|
+
out = fn();
|
|
55
|
+
} finally {
|
|
56
|
+
const held = now() - started;
|
|
57
|
+
if (held > stallMs) {
|
|
58
|
+
claimed = true;
|
|
59
|
+
log('loop_stall', { ms: Math.round(held), tick: name });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (out && typeof out.then === 'function') out.then(settle, settle);
|
|
63
|
+
else settle();
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function view() {
|
|
68
|
+
const all = [...minutes, live].filter((m) => m.histogram.count > 0);
|
|
69
|
+
if (!all.length) return { p50Ms: null, p99Ms: null, maxMs: null, windowMs, minutes: 0 };
|
|
70
|
+
const p50s = all.map((m) => m.histogram.percentile(50)).sort((a, b) => a - b);
|
|
71
|
+
return {
|
|
72
|
+
p50Ms: ms(p50s[Math.floor(p50s.length / 2)]),
|
|
73
|
+
p99Ms: ms(Math.max(...all.map((m) => m.histogram.percentile(99)))),
|
|
74
|
+
maxMs: ms(Math.max(...all.map((m) => m.histogram.max))),
|
|
75
|
+
windowMs,
|
|
76
|
+
minutes: all.length,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function close() {
|
|
81
|
+
clearInterval(probe);
|
|
82
|
+
live.histogram.disable();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { tick, view, close };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** For a facade served without a runner process (tests): ticks run as they are. */
|
|
89
|
+
export const UNMONITORED = Object.freeze({ tick: (name, fn) => fn(), view: () => null, close() {} });
|
package/src/runner/runnerHub.js
CHANGED
|
@@ -4,6 +4,7 @@ import { openHub } from '../hub/core/hub.mjs';
|
|
|
4
4
|
import { HubError } from '../lib/errors.js';
|
|
5
5
|
import { OUTBOX_MAX_PENDING, RunnerStore, SEED_META } from '../lib/runnerStore.js';
|
|
6
6
|
import { createCapture, installCapture } from './capture.js';
|
|
7
|
+
import { cachedDirectory } from './directoryCache.js';
|
|
7
8
|
import { installRunnerSchema } from './runnerSchema.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -73,7 +74,8 @@ export async function openRunnerHub({ directory, stateDir, mapping, now = Date.n
|
|
|
73
74
|
const init = await RunnerStore.open(directory, { now, maxPending });
|
|
74
75
|
const { file } = init;
|
|
75
76
|
init.close();
|
|
76
|
-
const
|
|
77
|
+
const opened = openHub(stateDir, file);
|
|
78
|
+
const hub = { ...opened, registry: { ...opened.registry, directory: cachedDirectory(opened.db, opened.registry) } };
|
|
77
79
|
installCapture(hub.db);
|
|
78
80
|
installRunnerSchema(hub.db);
|
|
79
81
|
const store = new RunnerStore({ db: hub.db, file, now, maxPending });
|