@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.
@@ -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
- function bounce(now = clock()) {
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
- ORDER BY m.created_at LIMIT ?`).all(now, now - BOUNCE_GRACE_MS, now - DEFAULT_TTL_MS, BOUNCE_BATCH);
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
- for (const r of rows) {
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') continue;
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
+ }
@@ -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
- lease.onChange((held) => { if (held) manager.start(); else manager.stop(); });
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
- try {
352
- if (capture.tx(() => delivery.settleNudges() + delivery.bounce().length, { label: 'delivery sweep' })) { caches.clear(); wrote(); }
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
- try {
358
- if (capture.tx(() => discipline.sweep().length, { label: 'stale sweep' })) { caches.clear(); wrote(); }
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')
@@ -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
- return {
50
- scan,
51
- commands(change, db) {
52
- const snapshot = new HubSnapshot(db);
53
- const context = contextFor(db);
54
- switch (change.tbl) {
55
- case 'messages': {
56
- if (change.op === 'delete') {
57
- const recent = db.prepare('SELECT id, grp FROM recent_message WHERE id = ?').get(change.k1);
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
- case 'events': {
88
- const event = db.prepare('SELECT id, at, kind, actor, payload FROM events WHERE id = ?').get(change.k1);
89
- if (event) scan.eventRow(context, { ...event, payload: parse(event.payload) ?? {} });
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
- default:
93
- return [];
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.prepare('SELECT * FROM actors WHERE id = ?').get(change.k1);
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.prepare('SELECT kind, key, payload, updated_at FROM facts WHERE kind = ? AND key = ?').get(change.k1, change.k2);
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 present = new Map(REMOVABLE_KINDS.map((kind) => [kind, new Set()]));
145
- for (const row of snapshot.facts(ALL_FACT_KINDS)) {
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() {} });
@@ -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 hub = openHub(stateDir, file);
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 });