@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.1.2

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.
Files changed (101) hide show
  1. package/README.md +46 -0
  2. package/package.json +7 -4
  3. package/src/clients/hub-bridge.mjs +99 -0
  4. package/src/clients/hub.mjs +324 -0
  5. package/src/commands/pulse/cutover.js +39 -0
  6. package/src/commands/pulse/doctor.js +1 -0
  7. package/src/commands/pulse/import.js +62 -0
  8. package/src/commands/pulse/log-write.js +26 -0
  9. package/src/commands/pulse/parity.js +20 -0
  10. package/src/commands/pulse/shadow.js +19 -0
  11. package/src/daemon/main.js +19 -12
  12. package/src/hub/core/auth.mjs +80 -0
  13. package/src/hub/core/changes.mjs +38 -0
  14. package/src/hub/core/conversations.mjs +121 -0
  15. package/src/hub/core/db.mjs +263 -0
  16. package/src/hub/core/edgeIds.mjs +9 -0
  17. package/src/hub/core/hub.mjs +20 -0
  18. package/src/hub/core/ids.mjs +39 -0
  19. package/src/hub/core/leases.mjs +55 -0
  20. package/src/hub/core/messaging.mjs +322 -0
  21. package/src/hub/core/registry.mjs +281 -0
  22. package/src/hub/core/ticks.mjs +58 -0
  23. package/src/hub/core/wake.mjs +125 -0
  24. package/src/hub/home.mjs +16 -0
  25. package/src/hub/hooks/hub-bridge.mjs +167 -0
  26. package/src/hub/keepawake/agent.mjs +96 -0
  27. package/src/hub/lib/brief.mjs +133 -0
  28. package/src/hub/lib/channels.mjs +268 -0
  29. package/src/hub/lib/chat.mjs +163 -0
  30. package/src/hub/lib/closure.mjs +92 -0
  31. package/src/hub/lib/collect-worker.mjs +20 -0
  32. package/src/hub/lib/collect.mjs +342 -0
  33. package/src/hub/lib/deliveries.mjs +222 -0
  34. package/src/hub/lib/git.mjs +93 -0
  35. package/src/hub/lib/me.mjs +123 -0
  36. package/src/hub/lib/personas.mjs +143 -0
  37. package/src/hub/lib/status.mjs +147 -0
  38. package/src/hub/lib/store.mjs +106 -0
  39. package/src/hub/lib/titles.mjs +29 -0
  40. package/src/hub/lib/transcript-feed.mjs +72 -0
  41. package/src/hub/lib/transcripts.mjs +364 -0
  42. package/src/hub/lib/writing.mjs +18 -0
  43. package/src/hub/projections/deliveries.mjs +31 -0
  44. package/src/hub/projections/graph.mjs +149 -0
  45. package/src/hub/runners/drivers.mjs +162 -0
  46. package/src/hub/workers/collector.mjs +128 -0
  47. package/src/hub/workers/manager.mjs +366 -0
  48. package/src/hub/workers/runner.mjs +145 -0
  49. package/src/lib/application.js +82 -34
  50. package/src/lib/channelScanner.js +99 -0
  51. package/src/lib/clientInstaller.js +111 -0
  52. package/src/lib/cloudFirstCommands.js +293 -0
  53. package/src/lib/contract.sha256 +1 -1
  54. package/src/lib/cutover.js +376 -0
  55. package/src/lib/cutoverWiring.js +137 -0
  56. package/src/lib/daemonLock.js +2 -0
  57. package/src/lib/daemonLoop.js +5 -2
  58. package/src/lib/daemonProcess.js +16 -2
  59. package/src/lib/daemonRuntime.js +8 -0
  60. package/src/lib/doctorWarnings.js +53 -0
  61. package/src/lib/errors.js +14 -0
  62. package/src/lib/hubDbReader.js +132 -0
  63. package/src/lib/hubMapping.js +108 -0
  64. package/src/lib/hubScan.js +611 -0
  65. package/src/lib/hubSnapshot.js +64 -0
  66. package/src/lib/mirrorContract.js +1 -1
  67. package/src/lib/mirrorService.js +8 -1
  68. package/src/lib/mirrorSource.js +13 -38
  69. package/src/lib/nodeFloor.js +19 -0
  70. package/src/lib/outboxFlusher.js +75 -0
  71. package/src/lib/pairingLifecycle.js +1 -1
  72. package/src/lib/reconcile.js +316 -0
  73. package/src/lib/reconcileView.js +129 -0
  74. package/src/lib/runnerStore.js +418 -0
  75. package/src/lib/serviceManager.js +61 -0
  76. package/src/lib/shadowService.js +435 -0
  77. package/src/lib/sqlite.js +28 -0
  78. package/src/lib/tokenStore.js +43 -4
  79. package/src/lib/transport.js +53 -5
  80. package/src/lib/transportContract.js +3 -0
  81. package/src/lib/uploadRedactor.js +102 -0
  82. package/src/runner/applier.js +297 -0
  83. package/src/runner/bridge.js +289 -0
  84. package/src/runner/busIdentity.js +499 -0
  85. package/src/runner/capture.js +103 -0
  86. package/src/runner/captureMapping.js +21 -0
  87. package/src/runner/channelDiscipline.js +425 -0
  88. package/src/runner/deliveryStates.js +220 -0
  89. package/src/runner/facade.js +876 -0
  90. package/src/runner/hubCapture.js +160 -0
  91. package/src/runner/lease.js +46 -0
  92. package/src/runner/main.js +35 -0
  93. package/src/runner/render.js +91 -0
  94. package/src/runner/runnerChild.js +57 -0
  95. package/src/runner/runnerHub.js +104 -0
  96. package/src/runner/runnerProcess.js +124 -0
  97. package/src/runner/runnerSchema.js +51 -0
  98. package/src/runner/runnerService.js +140 -0
  99. package/src/runner/spool.js +86 -0
  100. package/src/runner/spoolInto.js +22 -0
  101. package/src/runner/statusPage.js +30 -0
@@ -0,0 +1,160 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { HubSnapshot, rowActor } from '../lib/hubDbReader.js';
4
+ import { actorPayload, keyPart, receiptEvents, without } from '../lib/hubMapping.js';
5
+ import { ACTOR_TIME_FIELDS, ALL_FACT_KINDS, FACT_KINDS, HubScan, REMOVABLE_KINDS } from '../lib/hubScan.js';
6
+ import { canonicalJson } from '../lib/planContract.js';
7
+ import { UploadRedactor } from '../lib/uploadRedactor.js';
8
+
9
+ const DELIVERIES_REFRESH_MS = 30_000;
10
+ const sha = (text) => createHash('sha256').update(text).digest('hex');
11
+ const short = (text) => sha(text).slice(0, 12);
12
+
13
+ const parse = (text) => {
14
+ try {
15
+ const value = JSON.parse(text);
16
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ };
21
+
22
+ /**
23
+ * Capture's mapping from a changed hub row to outbox commands: the Phase 2 shadow's own per-row code (HubScan), run
24
+ * on the runner's connection inside the write transaction, so a row captured here gets exactly the key, payload,
25
+ * redaction and version the shadow would give it, and a later shadow pass over runner.db finds nothing new.
26
+ * Presence is not captured (heartbeats are not log events, D10); the daemon sends it as before.
27
+ */
28
+ export function createHubCapture({ store, hubDir, roots, home, approvedRoots = [], secrets = () => [], now = Date.now }) {
29
+ const scan = new HubScan({
30
+ store, reader: null, hubDir, roots, now, secrets,
31
+ redaction: new UploadRedactor({ workspace: path.dirname(hubDir), home, approvedRoots }),
32
+ });
33
+ let deliveries = [];
34
+ let deliveriesAt = -Infinity;
35
+ const contextFor = (db) => {
36
+ if (now() - deliveriesAt >= DELIVERIES_REFRESH_MS) {
37
+ deliveries = db.prepare("SELECT payload FROM facts WHERE kind = 'delivery'").all().map((row) => parse(row.payload)).filter(Boolean);
38
+ deliveriesAt = now();
39
+ }
40
+ return scan.context({ now: now(), shadow: true, deliveries });
41
+ };
42
+
43
+ function messageWithRecipients(snapshot, db, id) {
44
+ const message = db.prepare('SELECT rowid AS _rowid, * FROM messages WHERE id = ?').get(id);
45
+ if (!message) return null;
46
+ return { message: { ...message, meta: parse(message.meta) ?? {} }, to: snapshot.recipients([id]).get(id) || [] };
47
+ }
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);
85
+ return [];
86
+ }
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) ?? {} });
90
+ return [];
91
+ }
92
+ default:
93
+ return [];
94
+ }
95
+ },
96
+
97
+ /**
98
+ * A row the sync applier just wrote from the cloud: remember the version the shadow would have sent for it, so
99
+ * neither capture's sweep nor a later shadow pass sends it back as this machine's own change.
100
+ */
101
+ adopt(change, db) {
102
+ const snapshot = new HubSnapshot(db);
103
+ const context = contextFor(db);
104
+ switch (change.tbl) {
105
+ case 'actors': {
106
+ const row = db.prepare('SELECT * FROM actors WHERE id = ?').get(change.k1);
107
+ if (!row) return;
108
+ const actor = rowActor(row);
109
+ const entity = `actor:${actor.id}`;
110
+ const prepared = scan.prepare(context, 'actors', {
111
+ type: 'actor.updated', group: entity, entity, prefix: keyPart('actor', actor.id), separator: '@', payload: actorPayload(actor),
112
+ });
113
+ if (!prepared.version) return;
114
+ const staticSha = short(canonicalJson(without(prepared.entry.payload.actor, ...ACTOR_TIME_FIELDS)));
115
+ store.setVersion(entity, { ...prepared.version, rawSha: sha(JSON.stringify(actor)), staticSha });
116
+ return;
117
+ }
118
+ case 'facts': {
119
+ const row = db.prepare('SELECT kind, key, payload, updated_at FROM facts WHERE kind = ? AND key = ?').get(change.k1, change.k2);
120
+ const fact = row && parse(row.payload);
121
+ if (!fact || !FACT_KINDS.includes(row.kind)) return;
122
+ scan.factCommands(context, { ...row }, fact).forEach((item, index) => {
123
+ if (item.version) store.setVersion(item.version.entity, { ...item.version, rawSha: index === 0 ? sha(row.payload) : null, staticSha: null });
124
+ });
125
+ return;
126
+ }
127
+ case 'deliveries': {
128
+ const row = snapshot.receipt(change.k1, change.k2);
129
+ if (row) store.setReceiptState(change.k1, change.k2, receiptEvents(row, row.topic, row.expires_at).state);
130
+ return;
131
+ }
132
+ default:
133
+ }
134
+ },
135
+
136
+ /**
137
+ * Everything capture coalesced away, picked up again: actors whose only change was their clock (sent at most
138
+ * once a minute) and facts a delivery dropped. Read and queued in the caller's transaction, row by row.
139
+ */
140
+ sweep(db) {
141
+ const snapshot = new HubSnapshot(db);
142
+ const context = contextFor(db);
143
+ 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
+ }
157
+ return context.report;
158
+ },
159
+ };
160
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The runner process's view of the person's manager lease (Q9). The daemon claims it in the cloud and reports how
3
+ * long it may still count on it, measured from when it asked; this process turns that into a deadline on its own
4
+ * monotonic clock. No wall-clock time from the cloud is ever compared with this machine's (H4), and at the deadline
5
+ * the lease is lost here before the cloud can grant it to anyone else.
6
+ */
7
+ export function createLeaseState({ monotonic = () => performance.now(), log = () => {} } = {}) {
8
+ let held = false;
9
+ let deadline = 0;
10
+ let view = { holder: null, here: false, fence: 0 };
11
+ let timer = null;
12
+ const listeners = new Set();
13
+ const set = (next) => {
14
+ if (next === held) return;
15
+ held = next;
16
+ log(next ? 'lease_held' : 'lease_lost', { fence: view.fence, holder: view.holder });
17
+ for (const fn of listeners) fn(held);
18
+ };
19
+ const live = () => held && monotonic() < deadline;
20
+ return {
21
+ held: () => {
22
+ if (held && !live()) set(false);
23
+ return held;
24
+ },
25
+ view: () => ({ ...view, here: live() }),
26
+ onChange: (fn) => listeners.add(fn),
27
+ /** What the daemon measured: `{ held, remainingMs, holder, fence }`. */
28
+ report({ held: claimed, remainingMs = 0, holder = null, fence = 0 }) {
29
+ view = { holder, here: Boolean(claimed), fence };
30
+ clearTimeout(timer);
31
+ if (claimed && remainingMs > 0) {
32
+ deadline = monotonic() + remainingMs;
33
+ timer = setTimeout(() => set(false), remainingMs);
34
+ timer.unref?.();
35
+ set(true);
36
+ } else {
37
+ deadline = 0;
38
+ set(false);
39
+ }
40
+ },
41
+ close() {
42
+ clearTimeout(timer);
43
+ set(false);
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ // The Pulse runner process: the hub core over runner.db and the facade on 127.0.0.1. The daemon starts it with
3
+ // PULSE_HUB_HOME and PULSE_RUNNER_DB set and one --config file; cloud traffic stays in the daemon (it owns the
4
+ // tokens) and reaches this process over the IPC channel (see bridge.js).
5
+ import { readFileSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { hubHome, runnerDb } from '../hub/home.mjs';
8
+ import { tokenPath } from '../hub/core/auth.mjs';
9
+ import { startRunner } from './runnerProcess.js';
10
+
11
+ function configFrom(argv) {
12
+ const index = argv.indexOf('--config');
13
+ if (index === -1 || !argv[index + 1]) throw new Error('usage: main.js --config <runner.json>');
14
+ return JSON.parse(readFileSync(path.resolve(argv[index + 1]), 'utf8'));
15
+ }
16
+
17
+ const fail = (code, message) => {
18
+ process.stderr.write(`Pulse runner refused to start: ${code}: ${message}\n`);
19
+ process.exit(78);
20
+ };
21
+
22
+ let config;
23
+ try { config = configFrom(process.argv.slice(2)); } catch (error) { fail('bad_config', error.message); }
24
+ const home = hubHome();
25
+ try {
26
+ // The facade answers with the token every agent already holds; a missing token is never replaced by a new one.
27
+ readFileSync(tokenPath(path.join(home, 'state')), 'utf8');
28
+ } catch {
29
+ fail('no_hub_token', `${tokenPath(path.join(home, 'state'))} is missing`);
30
+ }
31
+
32
+ startRunner({ ...config, hubHome: home, dbFile: runnerDb() }).catch((error) => {
33
+ process.stderr.write(`Pulse runner failed: ${error.code || 'runner_failed'}: ${error.stack || error.message}\n`);
34
+ process.exit(1);
35
+ });
@@ -0,0 +1,91 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, statSync } from 'node:fs';
3
+ import { decisionPost } from '../hub/lib/channels.mjs';
4
+ import { splitPosts } from '../lib/channelScanner.js';
5
+ import { appendNoFollow, channelFileFor } from './facade.js';
6
+
7
+ const sha = (text) => createHash('sha256').update(text).digest('hex');
8
+
9
+ /** The block a post appends: its own header and body when it carries one, else a USER post as the hub writes them. */
10
+ function blockOf(payload) {
11
+ const body = String(payload.body || '').replace(/\r/g, '').trimEnd();
12
+ if (/^## \[[A-Z][A-Z0-9 -]*\] /.test(body)) return body;
13
+ return decisionPost(body, { kind: payload.type || 'MESSAGE' }).trim();
14
+ }
15
+
16
+ /**
17
+ * The channel bridge's render side (design §4.6): a post that reached Pulse from elsewhere is appended to this
18
+ * machine's channel.md once, never rewritten. Each one goes through `pending` (recorded first), the append, then
19
+ * `done` together with the Phase 2 ingest record of the block it became, so the channel scanner never sends it back
20
+ * as this machine's own post (M3). A restart finishes what was pending: it appends only if the block isn't there.
21
+ */
22
+ export function createRenderer({ runner, roots, now = Date.now, log = () => {} }) {
23
+ const { db } = runner.hub;
24
+ const q = {
25
+ get: db.prepare('SELECT post_key, channel, file, text, state FROM pulse_rendered WHERE post_key = ?'),
26
+ pending: db.prepare("SELECT post_key, channel, file, text FROM pulse_rendered WHERE state = 'pending' ORDER BY at"),
27
+ record: db.prepare('INSERT OR IGNORE INTO pulse_rendered (post_key, channel, file, text, state, at) VALUES (?, ?, ?, ?, ?, ?)'),
28
+ done: db.prepare("UPDATE pulse_rendered SET state = ? WHERE post_key = ?"),
29
+ };
30
+
31
+ /** Where the block landed in the file: its ordinal and the scanner's id for it (digest of the block, occurrence). */
32
+ function locate(file, text) {
33
+ const content = readFileSync(file, 'utf8');
34
+ const posts = splitPosts(content, statSync(file).mtimeMs);
35
+ const seen = new Map();
36
+ let found = null;
37
+ posts.forEach((post, ordinal) => {
38
+ const digest = sha(post.text).slice(0, 16);
39
+ const n = seen.get(digest) || 0;
40
+ seen.set(digest, n + 1);
41
+ if (post.text === text) found = { ordinal, id: `${digest}:${n}`, at: post.at };
42
+ });
43
+ return found;
44
+ }
45
+
46
+ function finish(row) {
47
+ let place = locate(row.file, row.text);
48
+ if (!place) {
49
+ const content = readFileSync(row.file, 'utf8');
50
+ appendNoFollow(row.file, `${content.endsWith('\n') ? '\n' : '\n\n'}${row.text}\n`);
51
+ place = locate(row.file, row.text);
52
+ }
53
+ if (!place) throw new Error('the appended block is not where it was written');
54
+ runner.capture.applying(() => {
55
+ runner.store.setIngested(row.channel, { ordinal: place.ordinal, sha: place.id, postKey: row.post_key, at: place.at });
56
+ q.done.run('done', row.post_key);
57
+ });
58
+ }
59
+
60
+ return {
61
+ /** Remote `post.created` events the applier accepted, in seq order. */
62
+ render(events) {
63
+ let rendered = 0;
64
+ for (const event of events) {
65
+ const p = event.payload || {};
66
+ const channel = String(p.channel || '');
67
+ const key = channel.startsWith('task:') ? channel.slice(5) : null;
68
+ if (q.get.get(event.key)) continue;
69
+ const file = key ? channelFileFor(key, roots) : null;
70
+ const text = blockOf(p);
71
+ q.record.run(event.key, channel, file, text, file ? 'pending' : 'no_local_channel', now());
72
+ if (!file) continue;
73
+ try {
74
+ finish({ post_key: event.key, channel, file, text });
75
+ rendered += 1;
76
+ } catch (error) {
77
+ log('render_failed', { channel, code: error.code || 'error', message: String(error.message).slice(0, 200) });
78
+ }
79
+ }
80
+ return rendered;
81
+ },
82
+ /** At start: finish every render a crash left pending. */
83
+ resume() {
84
+ let finished = 0;
85
+ for (const row of q.pending.all()) {
86
+ try { finish(row); finished += 1; } catch (error) { log('render_failed', { channel: row.channel, code: error.code || 'error' }); }
87
+ }
88
+ return finished;
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,57 @@
1
+ import { fork } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const MAIN = path.join(path.dirname(fileURLToPath(import.meta.url)), 'main.js');
6
+
7
+ /**
8
+ * The runner process, started by the daemon (or a test) with its hub and runner.db, and an IPC channel for what
9
+ * needs the cloud: sync pages to apply, the manager lease, held credentials to scrub. `request` sends one message
10
+ * and resolves with the runner's reply to it.
11
+ */
12
+ export function forkRunner({ hubHome, dbFile, configFile, env = {}, execPath = process.execPath, execArgv = [], stdio = ['ignore', 'pipe', 'pipe', 'ipc'] }) {
13
+ const child = fork(MAIN, ['--config', configFile], {
14
+ env: { ...process.env, ...env, PULSE_HUB_HOME: hubHome, PULSE_RUNNER_DB: dbFile },
15
+ execPath, execArgv, stdio, serialization: 'advanced',
16
+ });
17
+ let counter = 0;
18
+ const waiting = new Map();
19
+ const listeners = new Map();
20
+ child.on('message', (message) => {
21
+ if (message?.type === 'reply' && waiting.has(message.id)) {
22
+ const { resolve, reject } = waiting.get(message.id);
23
+ waiting.delete(message.id);
24
+ if (message.error) reject(Object.assign(new Error(message.error.message), { code: message.error.code }));
25
+ else resolve(message.result);
26
+ return;
27
+ }
28
+ for (const fn of listeners.get(message?.type) || []) fn(message);
29
+ });
30
+ child.once('exit', (code, signal) => {
31
+ for (const { reject } of waiting.values()) reject(Object.assign(new Error(`runner exited (${signal || code})`), { code: 'runner_exited' }));
32
+ waiting.clear();
33
+ });
34
+ const ready = new Promise((resolve, reject) => {
35
+ const on = (message) => { if (message?.type === 'ready') { child.off('message', on); resolve(message); } };
36
+ child.on('message', on);
37
+ child.once('exit', (code, signal) => reject(Object.assign(new Error(`runner exited before it was ready (${signal || code})`), { code: 'runner_exited' })));
38
+ });
39
+ ready.catch(() => {});
40
+ return {
41
+ child,
42
+ ready,
43
+ send: (message) => child.connected && child.send(message),
44
+ request(message) {
45
+ const id = (counter += 1);
46
+ return new Promise((resolve, reject) => {
47
+ waiting.set(id, { resolve, reject });
48
+ if (!child.connected) { waiting.delete(id); reject(Object.assign(new Error('runner not connected'), { code: 'runner_exited' })); return; }
49
+ child.send({ ...message, id });
50
+ });
51
+ },
52
+ on(type, fn) {
53
+ if (!listeners.has(type)) listeners.set(type, new Set());
54
+ listeners.get(type).add(fn);
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,104 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { openHub } from '../hub/core/hub.mjs';
4
+ import { HubError } from '../lib/errors.js';
5
+ import { OUTBOX_MAX_PENDING, RunnerStore, SEED_META } from '../lib/runnerStore.js';
6
+ import { createCapture, installCapture } from './capture.js';
7
+ import { installRunnerSchema } from './runnerSchema.js';
8
+
9
+ /**
10
+ * runner.db opened as the hub: the 6b2fb0b ledger tables run the ported core unchanged, the Phase 2 outbox shares
11
+ * the same connection, and capture turns every local write into outbox commands in the same transaction.
12
+ */
13
+ /** The hub ledger tables a cutover seed copies from hub.db.pre-cutover, rowids kept; leases stay with the old hub. */
14
+ const SEEDED_TABLES = ['actors', 'presence', 'messages', 'deliveries', 'events', 'quarantine', 'facts', 'imports', 'settings'];
15
+ /** Children first, so a reseed's deletes never trip the hub's foreign keys. */
16
+ const CLEARED_TABLES = ['deliveries', 'presence', 'messages', 'actors', 'events', 'quarantine', 'facts', 'imports', 'settings'];
17
+ /**
18
+ * What the runner applied on top of a seed, cleared with it: a reseed leaves runner.db as a first cutover from that
19
+ * backup finds it. Phase 2's tables (outbox, sent from the import and capture, dead, cursor, version, ingested) stay,
20
+ * and so do the records of effects outside runner.db that must not repeat: pulse_spool, pulse_rendered, pulse_jobs_local.
21
+ */
22
+ const RESEED_RESETS = [
23
+ 'DELETE FROM leases',
24
+ 'DELETE FROM pulse_capture',
25
+ 'DELETE FROM pulse_events',
26
+ 'DELETE FROM pulse_quarantine',
27
+ 'DELETE FROM pulse_deferred',
28
+ 'DELETE FROM pulse_fence',
29
+ 'DELETE FROM pulse_registered',
30
+ "DELETE FROM sent WHERE source = 'sync'",
31
+ 'UPDATE pulse_sync SET applied_seq = 0, head = 0, floor = 0, presence_stamp = 0, last_ok_at = NULL, last_error = NULL, resync = NULL WHERE one = 1',
32
+ ];
33
+
34
+ const sha256Of = async (file) => createHash('sha256').update(await readFile(file)).digest('hex');
35
+
36
+ /**
37
+ * Copy the old hub's rows into runner.db, with capture off: Pulse already has them (the import). The copy is keyed to
38
+ * the backup's sha256: the same backup is copied once; another backup, or one a rollback marked, replaces the hub
39
+ * tables and the sync state built on them (B1). ATTACH can't run inside a transaction, so the copy runs in `applying`
40
+ * between the attach and the detach.
41
+ */
42
+ function seedFrom(db, store, capture, { file, sha256 }) {
43
+ const previous = store.meta(SEED_META);
44
+ if (previous?.sha256 === sha256 && !previous.rolledBackAt) return null;
45
+ db.prepare('ATTACH DATABASE ? AS seed').run(file);
46
+ try {
47
+ return capture.applying(() => {
48
+ if (previous) {
49
+ for (const table of CLEARED_TABLES) db.prepare(`DELETE FROM main.${table}`).run();
50
+ for (const sql of RESEED_RESETS) db.prepare(sql).run();
51
+ }
52
+ const counts = {};
53
+ for (const table of SEEDED_TABLES) {
54
+ const columns = db.prepare(`SELECT name FROM pragma_table_info('${table}')`).all().map((c) => c.name).join(', ');
55
+ counts[table] = Number(db.prepare(`INSERT OR IGNORE INTO main.${table} (rowid, ${columns}) SELECT rowid, ${columns} FROM seed.${table}`).run().changes);
56
+ }
57
+ store.setMeta(SEED_META, {
58
+ file, sha256, at: Date.now(), counts,
59
+ ...(previous ? { previous: { file: previous.file, sha256: previous.sha256 ?? null, rolledBackAt: previous.rolledBackAt ?? null } } : {}),
60
+ });
61
+ return { counts, reseeded: Boolean(previous) };
62
+ });
63
+ } finally {
64
+ db.prepare('DETACH DATABASE seed').run();
65
+ }
66
+ }
67
+
68
+ export async function openRunnerHub({ directory, stateDir, mapping, now = Date.now, maxPending = OUTBOX_MAX_PENDING, log, seed = null, seedSha256 = null }) {
69
+ const seedSha = seed ? await sha256Of(seed) : null;
70
+ if (seed && seedSha256 && seedSha !== seedSha256) {
71
+ throw new HubError('HUB_RUNNER_SEED_MISMATCH', `${seed} is not the backup the cutover recorded (sha256 ${seedSha.slice(0, 12)}, expected ${seedSha256.slice(0, 12)})`);
72
+ }
73
+ const init = await RunnerStore.open(directory, { now, maxPending });
74
+ const { file } = init;
75
+ init.close();
76
+ const hub = openHub(stateDir, file);
77
+ installCapture(hub.db);
78
+ installRunnerSchema(hub.db);
79
+ const store = new RunnerStore({ db: hub.db, file, now, maxPending });
80
+ const mapped = typeof mapping === 'function' ? mapping(store) : mapping;
81
+ const capture = createCapture({ db: hub.db, store, mapping: mapped, log });
82
+ if (seed) {
83
+ const seeded = seedFrom(hub.db, store, capture, { file: seed, sha256: seedSha });
84
+ if (seeded) log?.('runner_seeded', { from: seed, sha256: seedSha, ...seeded });
85
+ }
86
+ // Rows written before the triggers existed (openHub's own `user` and `manager`, a seed) are picked up once here.
87
+ // A full outbox (pulse_backlog) must not stop the runner from starting (I4); the pump and the next sweep go on.
88
+ if (mapped.sweep) {
89
+ try { capture.tx(() => mapped.sweep(hub.db), { label: 'capture sweep at open' }); } catch (error) {
90
+ if (error.code !== 'pulse_backlog') throw error;
91
+ log?.('capture_backlog', { at: 'open' });
92
+ }
93
+ }
94
+ return {
95
+ hub,
96
+ store,
97
+ capture,
98
+ mapping: mapped,
99
+ file,
100
+ close() {
101
+ hub.db.close();
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,124 @@
1
+ import { mkdirSync, watch } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { RUNNER_DB_FILE } from '../lib/runnerStore.js';
4
+ import { createApplier } from './applier.js';
5
+ import { createLeaseState } from './lease.js';
6
+ import { createRenderer } from './render.js';
7
+ import { mappingFor } from './captureMapping.js';
8
+ import { serve } from './facade.js';
9
+ import { openRunnerHub } from './runnerHub.js';
10
+ import { importSpool } from './spool.js';
11
+
12
+ const PUMP_MS = 250;
13
+ const SPOOL_SCAN_MS = 5000;
14
+ /** Actors coalesced by capture (a clock-only change goes up at most once a minute) and dropped facts. */
15
+ const SWEEP_MS = 30_000;
16
+
17
+ /** Runs the runner in this process: runner.db as the hub, capture, the facade. Resolves once it is listening. */
18
+ export async function startRunner({
19
+ hubHome, dbFile, port = 8890, host = '127.0.0.1', mapping = 'hub', channelRoots, approvedRoots = [], transcriptsDir, enableCollector = true,
20
+ enableManager = true, pulseUrl = null, hookBudgetMs, writeBudgetMs, machineId = null, cutoverSeq = 0, lease: leaseMode = 'cloud', managerOptions, maxPending,
21
+ seedFrom = null, seedSha256 = null, workspaceRoot = path.dirname(hubHome), discipline, send = process.send ? (message) => process.send(message) : null, log = (event, details) => {
22
+ process.stdout.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...details })}\n`);
23
+ },
24
+ }) {
25
+ if (path.basename(dbFile) !== RUNNER_DB_FILE) throw new Error(`the runner database must be named ${RUNNER_DB_FILE}`);
26
+ const stateDir = path.join(hubHome, 'state');
27
+ // The values capture scrubs besides its secret rules: the hub token, and the daemon's held credentials (IPC).
28
+ let heldSecrets = [];
29
+ let hubToken = null;
30
+ const roots = channelRoots || ['AgentPulseApps', 'Google_Cloud'].map((dir) => path.join(path.dirname(hubHome), dir));
31
+ const runner = await openRunnerHub({
32
+ directory: path.dirname(dbFile), stateDir, log, seed: seedFrom, seedSha256, ...(maxPending ? { maxPending } : {}),
33
+ mapping: mappingFor(mapping, { hubDir: hubHome, roots, approvedRoots, secrets: () => [hubToken, ...heldSecrets] }),
34
+ });
35
+ hubToken = runner.hub.token;
36
+ const applier = createApplier({ runner, machineId, cutoverSeq: () => cutoverSeq, log });
37
+ const renderer = createRenderer({ runner, roots, log });
38
+ renderer.resume();
39
+ /** What the daemon last measured (§9.4); the facade shows it. Until the first report the runner is starting. */
40
+ let health = { state: 'starting', reason: 'waiting for the first sync' };
41
+ // With a daemon, the manager waits for the cloud lease; alone (no IPC), this runner is the only one, as the hub was.
42
+ const lease = send && leaseMode === 'cloud' ? createLeaseState({ log }) : null;
43
+ const handlers = {
44
+ apply: (message) => {
45
+ const result = applier.apply(message.page);
46
+ const rendered = result.posts.length ? renderer.render(result.posts) : 0;
47
+ if (result.applied) onWrite();
48
+ return { ...result, posts: result.posts.length, rendered };
49
+ },
50
+ retryDeferred: () => applier.retryDeferred(),
51
+ health: (message) => { health = message.health; return null; },
52
+ lease: (message) => { lease?.report(message); return lease ? lease.view() : null; },
53
+ secrets: (message) => { heldSecrets = (message.values || []).filter((v) => typeof v === 'string' && v); return null; },
54
+ ping: () => ({ pid: process.pid }),
55
+ };
56
+ if (send) {
57
+ process.on('message', (message) => {
58
+ const handler = handlers[message?.type];
59
+ if (!handler) return;
60
+ let reply;
61
+ try {
62
+ reply = { type: 'reply', id: message.id, result: handler(message) };
63
+ } catch (error) {
64
+ log('ipc_failed', { type: message.type, code: error.code || 'error', message: String(error.message).slice(0, 200) });
65
+ reply = { type: 'reply', id: message.id, error: { code: error.code || 'runner_error', message: String(error.message).slice(0, 300) } };
66
+ }
67
+ if (message.id !== undefined) send(reply);
68
+ });
69
+ }
70
+ let wroteTimer = null;
71
+ const onWrite = () => {
72
+ if (!send || wroteTimer) return;
73
+ wroteTimer = setTimeout(() => { wroteTimer = null; send({ type: 'wrote' }); }, 20);
74
+ };
75
+ const facade = serve({
76
+ hub: runner.hub, capture: runner.capture, port, listenHost: host, stateDir, dbFile, enableCollector, enableManager,
77
+ pulseUrl, onWrite, health: () => health, ...(lease ? { lease } : {}), ...(managerOptions ? { managerOptions } : {}), ...(hookBudgetMs !== undefined ? { hookBudgetMs } : {}), ...(writeBudgetMs !== undefined ? { writeBudgetMs } : {}),
78
+ channelRoots: roots, workspaceRoot, ...(discipline ? { discipline } : {}), ...(transcriptsDir !== undefined ? { transcriptsDir } : {}),
79
+ });
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);
83
+ 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);
87
+ sweep.unref();
88
+ const spoolDir = path.join(stateDir, 'spool');
89
+ mkdirSync(spoolDir, { recursive: true, mode: 0o700 });
90
+ const drainSpool = () => {
91
+ try {
92
+ const { imported } = importSpool({ hub: runner.hub, capture: runner.capture, spoolDir, log });
93
+ 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());
98
+ spoolWatch.on('error', () => {});
99
+ const spoolScan = setInterval(drainSpool, SPOOL_SCAN_MS);
100
+ spoolScan.unref();
101
+ await new Promise((resolve, reject) => {
102
+ facade.server.once('listening', resolve);
103
+ facade.server.once('error', reject);
104
+ });
105
+ const address = facade.server.address();
106
+ log('runner_ready', { port: address.port, pid: process.pid });
107
+ if (send) send({ type: 'ready', port: address.port, pid: process.pid });
108
+ let closing = false;
109
+ const close = () => {
110
+ if (closing) return;
111
+ closing = true;
112
+ clearInterval(pump);
113
+ clearInterval(sweep);
114
+ clearInterval(spoolScan);
115
+ spoolWatch.close();
116
+ facade.server.close();
117
+ facade.server.closeAllConnections?.();
118
+ try { runner.capture.pump(); } catch { /* the journal stays for the next start */ }
119
+ runner.close();
120
+ };
121
+ process.once('SIGTERM', () => { close(); process.exit(0); });
122
+ process.once('SIGINT', () => { close(); process.exit(0); });
123
+ return { runner, facade, port: address.port, close };
124
+ }