@agentpulselabs/cli-plugin-pulse 1.1.3 → 1.1.6

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.
@@ -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 });
@@ -2,17 +2,22 @@ import { mkdirSync, watch } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { RUNNER_DB_FILE } from '../lib/runnerStore.js';
4
4
  import { createApplier } from './applier.js';
5
+ import { CHUNK_MS } from './capture.js';
5
6
  import { createLeaseState } from './lease.js';
7
+ import { createLoopMonitor } from './loopMonitor.js';
6
8
  import { createRenderer } from './render.js';
7
9
  import { mappingFor } from './captureMapping.js';
8
10
  import { serve } from './facade.js';
9
11
  import { openRunnerHub } from './runnerHub.js';
12
+ import { serial } from './serial.js';
10
13
  import { importSpool } from './spool.js';
11
14
 
12
15
  const PUMP_MS = 250;
13
16
  const SPOOL_SCAN_MS = 5000;
14
17
  /** Actors coalesced by capture (a clock-only change goes up at most once a minute) and dropped facts. */
15
18
  const SWEEP_MS = 30_000;
19
+ /** How often the runner tells the daemon its event-loop delay (daemon-health.json `runner.loopDelay`). */
20
+ const LOOP_REPORT_MS = 5_000;
16
21
 
17
22
  /** Runs the runner in this process: runner.db as the hub, capture, the facade. Resolves once it is listening. */
18
23
  export async function startRunner({
@@ -20,9 +25,16 @@ export async function startRunner({
20
25
  enableManager = true, pulseUrl = null, hookBudgetMs, writeBudgetMs, machineId = null, cutoverSeq = 0, lease: leaseMode = 'cloud', managerOptions, maxPending,
21
26
  seedFrom = null, seedSha256 = null, workspaceRoot = path.dirname(hubHome), discipline, send = process.send ? (message) => process.send(message) : null, log = (event, details) => {
22
27
  process.stdout.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...details })}\n`);
23
- },
28
+ }, loopStallMs,
24
29
  }) {
25
30
  if (path.basename(dbFile) !== RUNNER_DB_FILE) throw new Error(`the runner database must be named ${RUNNER_DB_FILE}`);
31
+ const loop = createLoopMonitor({
32
+ ...(loopStallMs ? { stallMs: loopStallMs } : {}),
33
+ log: (event, details) => {
34
+ log(event, details);
35
+ if (send) send({ type: event, details });
36
+ },
37
+ });
26
38
  const stateDir = path.join(hubHome, 'state');
27
39
  // The values capture scrubs besides its secret rules: the hub token, and the daemon's held credentials (IPC).
28
40
  let heldSecrets = [];
@@ -75,29 +87,58 @@ export async function startRunner({
75
87
  const facade = serve({
76
88
  hub: runner.hub, capture: runner.capture, port, listenHost: host, stateDir, dbFile, enableCollector, enableManager,
77
89
  pulseUrl, onWrite, health: () => health, ...(lease ? { lease } : {}), ...(managerOptions ? { managerOptions } : {}), ...(hookBudgetMs !== undefined ? { hookBudgetMs } : {}), ...(writeBudgetMs !== undefined ? { writeBudgetMs } : {}),
78
- channelRoots: roots, workspaceRoot, ...(discipline ? { discipline } : {}), ...(transcriptsDir !== undefined ? { transcriptsDir } : {}),
90
+ channelRoots: roots, workspaceRoot, loop, ...(discipline ? { discipline } : {}), ...(transcriptsDir !== undefined ? { transcriptsDir } : {}),
91
+ });
92
+ let closing = false;
93
+ const failed = (event, error) => log(event, { code: error.code || 'error', message: String(error.message).slice(0, 200) });
94
+ /** What other connections (the collect worker) left in the capture journal, a short transaction at a time. */
95
+ const drainJournal = serial(async () => {
96
+ try {
97
+ while (!closing) {
98
+ if (runner.capture.pump({ budgetMs: CHUNK_MS })) onWrite();
99
+ if (!runner.capture.pending()) return;
100
+ await runner.capture.yieldLock();
101
+ }
102
+ } catch (error) { failed('capture_pump_failed', error); }
79
103
  });
80
- const pump = setInterval(() => {
81
- try { if (runner.capture.pump()) onWrite(); } catch (error) { log('capture_pump_failed', { code: error.code || 'error', message: String(error.message).slice(0, 200) }); }
82
- }, PUMP_MS);
104
+ const pump = setInterval(() => loop.tick('capture pump', drainJournal), PUMP_MS);
83
105
  pump.unref();
84
- const sweep = setInterval(() => {
85
- try { runner.capture.tx(() => runner.mapping.sweep(runner.hub.db), { label: 'capture sweep' }); onWrite(); } catch (error) { log('capture_sweep_failed', { code: error.code || 'error', message: String(error.message).slice(0, 200) }); }
86
- }, SWEEP_MS);
106
+ const sweepAll = serial(async () => {
107
+ try {
108
+ const { db } = runner.hub;
109
+ const steps = runner.mapping.sweepSteps ? runner.mapping.sweepSteps(db) : [() => runner.mapping.sweep(db)];
110
+ await runner.capture.chunks(steps, { label: 'capture sweep' });
111
+ onWrite();
112
+ } catch (error) { failed('capture_sweep_failed', error); }
113
+ });
114
+ const sweep = setInterval(() => loop.tick('capture sweep', sweepAll), SWEEP_MS);
87
115
  sweep.unref();
88
116
  const spoolDir = path.join(stateDir, 'spool');
89
117
  mkdirSync(spoolDir, { recursive: true, mode: 0o700 });
90
- const drainSpool = () => {
118
+ const spoolMarks = new Map();
119
+ const spoolPass = (budgetMs) => importSpool({ hub: runner.hub, capture: runner.capture, spoolDir, log, marks: spoolMarks, budgetMs });
120
+ const drainSpool = serial(async () => {
91
121
  try {
92
- const { imported } = importSpool({ hub: runner.hub, capture: runner.capture, spoolDir, log });
122
+ let imported = 0;
123
+ while (!closing) {
124
+ const pass = spoolPass(CHUNK_MS);
125
+ imported += pass.imported;
126
+ if (!pass.more) break;
127
+ await runner.capture.yieldLock();
128
+ }
93
129
  if (imported) { log('spool_imported', { imported }); onWrite(); }
94
- } catch (error) { log('spool_import_failed', { code: error.code || 'error', message: String(error.message).slice(0, 200) }); }
95
- };
96
- drainSpool();
97
- const spoolWatch = watch(spoolDir, () => drainSpool());
130
+ } catch (error) { failed('spool_import_failed', error); }
131
+ });
132
+ try {
133
+ const { imported } = spoolPass(Infinity);
134
+ if (imported) { log('spool_imported', { imported }); onWrite(); }
135
+ } catch (error) { failed('spool_import_failed', error); }
136
+ const spoolWatch = watch(spoolDir, () => loop.tick('spool import', drainSpool));
98
137
  spoolWatch.on('error', () => {});
99
- const spoolScan = setInterval(drainSpool, SPOOL_SCAN_MS);
138
+ const spoolScan = setInterval(() => loop.tick('spool import', drainSpool), SPOOL_SCAN_MS);
100
139
  spoolScan.unref();
140
+ const loopReport = send ? setInterval(() => send({ type: 'loop', loopDelay: loop.view() }), LOOP_REPORT_MS) : null;
141
+ loopReport?.unref();
101
142
  await new Promise((resolve, reject) => {
102
143
  facade.server.once('listening', resolve);
103
144
  facade.server.once('error', reject);
@@ -105,13 +146,14 @@ export async function startRunner({
105
146
  const address = facade.server.address();
106
147
  log('runner_ready', { port: address.port, pid: process.pid });
107
148
  if (send) send({ type: 'ready', port: address.port, pid: process.pid });
108
- let closing = false;
109
149
  const close = () => {
110
150
  if (closing) return;
111
151
  closing = true;
112
152
  clearInterval(pump);
113
153
  clearInterval(sweep);
114
154
  clearInterval(spoolScan);
155
+ clearInterval(loopReport);
156
+ loop.close();
115
157
  spoolWatch.close();
116
158
  facade.server.close();
117
159
  facade.server.closeAllConnections?.();
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { readFile, writeFile } from 'node:fs/promises';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
@@ -15,6 +16,18 @@ export const RUNNER_CONFIG_FILE = 'runner.json';
15
16
  const RESTART_MIN_MS = 1_000;
16
17
  const RESTART_MAX_MS = 30_000;
17
18
  const SECRETS_EVERY_MS = 60_000;
19
+ /** What daemon-health.json shows of the runner's manager (state/manager-status.json): its state and what last went wrong. */
20
+ const MANAGER_FIELDS = ['state', 'error', 'messageId', 'lastFailure', 'gaveUp', 'lastReplyAt', 'at'];
21
+
22
+ /** The manager's status for daemon-health.json, or null while it has written none. */
23
+ export function managerHealth(file) {
24
+ let status;
25
+ try { status = JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
26
+ if (!status || typeof status !== 'object') return null;
27
+ const out = Object.fromEntries(MANAGER_FIELDS.filter((key) => status[key] != null).map((key) => [key, status[key]]));
28
+ if (status.queue) out.queue = { depth: status.queue.depth, oldestAgeMs: status.queue.oldestAgeMs };
29
+ return out;
30
+ }
18
31
 
19
32
  /** Whether this machine has cut over: the daemon runs the hub (the runner) and serves the local API (design §11). */
20
33
  export const runnerMode = (config) => config?.cloudFirst?.mode === 'runner';
@@ -76,7 +89,8 @@ export class RunnerService {
76
89
 
77
90
  health() {
78
91
  const bridge = this.current?.bridge;
79
- return { ...this.state, ...(bridge ? { runner: bridge.health(), lease: bridge.lease ?? null } : {}) };
92
+ const manager = this.current?.managerStatusFile ? managerHealth(this.current.managerStatusFile) : null;
93
+ return { ...this.state, ...(bridge ? { runner: bridge.health(), lease: bridge.lease ?? null } : {}), ...(manager ? { manager } : {}) };
80
94
  }
81
95
 
82
96
  async #start(config) {
@@ -94,11 +108,13 @@ export class RunnerService {
94
108
  const store = await this.openStore(this.directory);
95
109
  const transport = await this.transportFactory(config);
96
110
  const held = () => transport.auth?.heldSecrets?.() ?? [];
97
- const current = { store, transport, stopping: false };
111
+ const current = { store, transport, stopping: false, managerStatusFile: path.join(hubHome, 'state', 'manager-status.json') };
98
112
  this.current = current;
99
113
  const launch = async () => {
100
114
  const runner = this.fork({ hubHome, dbFile, configFile });
101
115
  runner.child.stderr?.on('data', (chunk) => this.log('runner_stderr', { line: String(chunk).slice(0, 300) }));
116
+ // The runner's event-loop delay (runner/loopMonitor.js), as `runner.loopDelay` in daemon-health.json.
117
+ runner.on('loop', (message) => { if (this.current === current) this.state = { ...this.state, loopDelay: message.loopDelay ?? null }; });
102
118
  const ready = await runner.ready;
103
119
  const hubToken = (await readFile(path.join(hubHome, 'state', 'hub.token'), 'utf8').catch(() => '')).trim() || null;
104
120
  const fileScan = new HubScan({
@@ -0,0 +1,5 @@
1
+ /** An async tick that never runs twice at once: called while it still runs, it returns the run in progress. */
2
+ export function serial(fn) {
3
+ let running = null;
4
+ return () => (running ??= Promise.resolve().then(fn).finally(() => { running = null; }));
5
+ }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFileSync, readdirSync } from 'node:fs';
2
+ import { closeSync, fstatSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
5
  const SPOOL_FILE = /^\d{8}\.jsonl$/;
@@ -42,28 +42,61 @@ function completeLines(text) {
42
42
  return end < 0 ? [] : text.slice(0, end).split('\n');
43
43
  }
44
44
 
45
+ /** A spool file from byte `offset` on: the whole file when there is no watermark. */
46
+ function readFrom(file, offset) {
47
+ if (!offset) return readFileSync(file, 'utf8');
48
+ const fd = openSync(file, 'r');
49
+ try {
50
+ const size = fstatSync(fd).size;
51
+ const buffer = Buffer.alloc(Math.max(0, size - offset));
52
+ readSync(fd, buffer, 0, buffer.length, offset);
53
+ return buffer.toString('utf8');
54
+ } finally { closeSync(fd); }
55
+ }
56
+
45
57
  /**
46
58
  * Imports every spool line not imported before, oldest file first, each in its own capture transaction with its
47
59
  * pulse_spool row, so a crash between lines imports the rest exactly once on the next pass.
60
+ *
61
+ * `marks` (a Map the caller keeps) remembers how far each file was read: a file whose size and mtime have not moved
62
+ * is not read, and one that grew is read from where the last pass stopped. A file that shrank or was replaced is read
63
+ * from the start again (its lines are known by their sha256). Once `budgetMs` has passed and the pass has applied a
64
+ * line, it stops before its next line with `more` set, so the caller can yield the event loop and go on from there.
48
65
  */
49
- export function importSpool({ hub, capture, spoolDir, now = Date.now, log = () => {} }) {
66
+ export function importSpool({ hub, capture, spoolDir, now = Date.now, log = () => {}, marks = null, budgetMs = Infinity }) {
50
67
  let files;
51
68
  try {
52
69
  files = readdirSync(spoolDir).filter((f) => SPOOL_FILE.test(f)).sort();
53
70
  } catch (error) {
54
- if (error.code === 'ENOENT') return { imported: 0, refused: 0 };
71
+ if (error.code === 'ENOENT') return { imported: 0, refused: 0, more: false };
55
72
  throw error;
56
73
  }
74
+ const started = performance.now();
57
75
  const seen = hub.db.prepare('SELECT 1 AS found FROM pulse_spool WHERE sha256 = ?');
58
76
  const record = hub.db.prepare('INSERT INTO pulse_spool (sha256, file, line, outcome, imported_at) VALUES (?, ?, ?, ?, ?)');
59
77
  let imported = 0;
60
78
  let refused = 0;
79
+ let applied = 0;
80
+ if (marks) for (const file of marks.keys()) if (!files.includes(file)) marks.delete(file);
61
81
  for (const file of files) {
62
- const lines = completeLines(readFileSync(path.join(spoolDir, file), 'utf8'));
63
- lines.forEach((line, index) => {
64
- if (!line.trim()) return;
82
+ const full = path.join(spoolDir, file);
83
+ const st = marks ? statSync(full) : null;
84
+ let mark = marks?.get(file);
85
+ if (mark && mark.ino === st.ino && mark.size === st.size && mark.mtimeMs === st.mtimeMs) continue;
86
+ if (mark && (mark.ino !== st.ino || st.size < mark.offset)) mark = null;
87
+ const lines = completeLines(readFrom(full, mark?.offset ?? 0));
88
+ let offset = mark?.offset ?? 0;
89
+ let line0 = mark?.lines ?? 0;
90
+ for (const [index, line] of lines.entries()) {
91
+ if (applied && performance.now() - started >= budgetMs) {
92
+ marks?.set(file, { ino: st.ino, size: null, mtimeMs: null, offset, lines: line0 + index });
93
+ return { imported, refused, more: true };
94
+ }
95
+ offset += Buffer.byteLength(line, 'utf8') + 1;
96
+ if (!line.trim()) continue;
65
97
  const sha = createHash('sha256').update(line).digest('hex');
66
- if (seen.get(sha)) return;
98
+ if (seen.get(sha)) continue;
99
+ applied += 1;
67
100
  capture.tx(() => {
68
101
  let outcome;
69
102
  try {
@@ -73,14 +106,16 @@ export function importSpool({ hub, capture, spoolDir, now = Date.now, log = () =
73
106
  } catch {
74
107
  outcome = 'refused: unreadable line';
75
108
  }
76
- record.run(sha, file, index + 1, outcome, now());
109
+ record.run(sha, file, line0 + index + 1, outcome, now());
77
110
  if (outcome === 'applied') imported += 1;
78
111
  else {
79
112
  refused += 1;
80
- log('spool_line_refused', { file, line: index + 1, outcome });
113
+ log('spool_line_refused', { file, line: line0 + index + 1, outcome });
81
114
  }
82
115
  }, { label: 'spool import' });
83
- });
116
+ }
117
+ line0 += lines.length;
118
+ marks?.set(file, { ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, offset, lines: line0 });
84
119
  }
85
- return { imported, refused };
120
+ return { imported, refused, more: false };
86
121
  }