@agentpulselabs/cli-plugin-pulse 1.1.2 → 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/README.md +9 -0
- package/package.json +3 -2
- package/src/commands/pulse/cutover.js +34 -10
- package/src/daemon/main.js +1 -0
- 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/cutover.js +51 -16
- package/src/lib/cutoverRequest.js +485 -0
- package/src/lib/cutoverWiring.js +6 -3
- package/src/lib/daemonProcess.js +43 -6
- 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/lib/serviceManager.js +3 -1
- 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 +29 -4
- package/src/runner/serial.js +5 -0
- package/src/runner/spool.js +46 -11
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 });
|
|
@@ -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
|
|
85
|
-
try {
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
|
|
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';
|
|
@@ -38,13 +51,22 @@ export class RunnerService {
|
|
|
38
51
|
this.log = log;
|
|
39
52
|
this.now = now;
|
|
40
53
|
this.key = null;
|
|
54
|
+
this.generation = -Infinity;
|
|
41
55
|
this.current = null;
|
|
42
56
|
this.restarts = 0;
|
|
43
57
|
this.state = { state: 'off' };
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Start, keep or stop the runner for this config. Never waits on the runner. `generation` orders the reads of
|
|
62
|
+
* config.json: a config read before the last one applied is ignored, so a heartbeat that read it before a cutover's
|
|
63
|
+
* `serve` cannot switch the runner back (CUT-03).
|
|
64
|
+
*/
|
|
65
|
+
update(config, { generation = null } = {}) {
|
|
66
|
+
if (generation !== null) {
|
|
67
|
+
if (generation < this.generation) return this.state;
|
|
68
|
+
this.generation = generation;
|
|
69
|
+
}
|
|
48
70
|
const on = runnerMode(config) && pairedStatus(config).ok;
|
|
49
71
|
const key = on ? JSON.stringify({ c: config.cloudFirst, m: config.paired.machineId, v: config.paired.policyVersion }) : null;
|
|
50
72
|
if (key === this.key) return this.state;
|
|
@@ -67,7 +89,8 @@ export class RunnerService {
|
|
|
67
89
|
|
|
68
90
|
health() {
|
|
69
91
|
const bridge = this.current?.bridge;
|
|
70
|
-
|
|
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 } : {}) };
|
|
71
94
|
}
|
|
72
95
|
|
|
73
96
|
async #start(config) {
|
|
@@ -85,11 +108,13 @@ export class RunnerService {
|
|
|
85
108
|
const store = await this.openStore(this.directory);
|
|
86
109
|
const transport = await this.transportFactory(config);
|
|
87
110
|
const held = () => transport.auth?.heldSecrets?.() ?? [];
|
|
88
|
-
const current = { store, transport, stopping: false };
|
|
111
|
+
const current = { store, transport, stopping: false, managerStatusFile: path.join(hubHome, 'state', 'manager-status.json') };
|
|
89
112
|
this.current = current;
|
|
90
113
|
const launch = async () => {
|
|
91
114
|
const runner = this.fork({ hubHome, dbFile, configFile });
|
|
92
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 }; });
|
|
93
118
|
const ready = await runner.ready;
|
|
94
119
|
const hubToken = (await readFile(path.join(hubHome, 'state', 'hub.token'), 'utf8').catch(() => '')).trim() || null;
|
|
95
120
|
const fileScan = new HubScan({
|
package/src/runner/spool.js
CHANGED
|
@@ -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
|
|
63
|
-
|
|
64
|
-
|
|
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))
|
|
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
|
}
|