@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.1.3

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 (102) hide show
  1. package/README.md +55 -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 +63 -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 +20 -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 +411 -0
  55. package/src/lib/cutoverRequest.js +485 -0
  56. package/src/lib/cutoverWiring.js +140 -0
  57. package/src/lib/daemonLock.js +2 -0
  58. package/src/lib/daemonLoop.js +5 -2
  59. package/src/lib/daemonProcess.js +57 -6
  60. package/src/lib/daemonRuntime.js +8 -0
  61. package/src/lib/doctorWarnings.js +53 -0
  62. package/src/lib/errors.js +14 -0
  63. package/src/lib/hubDbReader.js +132 -0
  64. package/src/lib/hubMapping.js +108 -0
  65. package/src/lib/hubScan.js +611 -0
  66. package/src/lib/hubSnapshot.js +64 -0
  67. package/src/lib/mirrorContract.js +1 -1
  68. package/src/lib/mirrorService.js +8 -1
  69. package/src/lib/mirrorSource.js +13 -38
  70. package/src/lib/nodeFloor.js +19 -0
  71. package/src/lib/outboxFlusher.js +75 -0
  72. package/src/lib/pairingLifecycle.js +1 -1
  73. package/src/lib/reconcile.js +316 -0
  74. package/src/lib/reconcileView.js +129 -0
  75. package/src/lib/runnerStore.js +418 -0
  76. package/src/lib/serviceManager.js +64 -1
  77. package/src/lib/shadowService.js +435 -0
  78. package/src/lib/sqlite.js +28 -0
  79. package/src/lib/tokenStore.js +43 -4
  80. package/src/lib/transport.js +53 -5
  81. package/src/lib/transportContract.js +3 -0
  82. package/src/lib/uploadRedactor.js +102 -0
  83. package/src/runner/applier.js +297 -0
  84. package/src/runner/bridge.js +289 -0
  85. package/src/runner/busIdentity.js +499 -0
  86. package/src/runner/capture.js +103 -0
  87. package/src/runner/captureMapping.js +21 -0
  88. package/src/runner/channelDiscipline.js +425 -0
  89. package/src/runner/deliveryStates.js +220 -0
  90. package/src/runner/facade.js +876 -0
  91. package/src/runner/hubCapture.js +160 -0
  92. package/src/runner/lease.js +46 -0
  93. package/src/runner/main.js +35 -0
  94. package/src/runner/render.js +91 -0
  95. package/src/runner/runnerChild.js +57 -0
  96. package/src/runner/runnerHub.js +104 -0
  97. package/src/runner/runnerProcess.js +124 -0
  98. package/src/runner/runnerSchema.js +51 -0
  99. package/src/runner/runnerService.js +149 -0
  100. package/src/runner/spool.js +86 -0
  101. package/src/runner/spoolInto.js +22 -0
  102. package/src/runner/statusPage.js +30 -0
@@ -0,0 +1,411 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmod, copyFile, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
5
+ import { clientState, installClients, restoreClients } from './clientInstaller.js';
6
+ import { HubError } from './errors.js';
7
+ import { RunnerStore, SEED_META } from './runnerStore.js';
8
+ import { SERVICE_LABEL } from './serviceManager.js';
9
+ import { readOwnerOnlyJson, writeOwnerOnlyJson } from './storage.js';
10
+
11
+ export const CUTOVER_FILE = 'cutover.json';
12
+ /** The oldest daemon that finds the sign-in under its per-config Keychain name, which step 0 moves it to (M1). */
13
+ export const MIN_DAEMON_VERSION = '1.1.0';
14
+ const HEALTH_FRESH_MS = 60_000;
15
+ export const OLD_HUB_LABELS = Object.freeze(['com.agentpulse.agents-hub', 'com.agentpulse.keepawake']);
16
+ /** Rollback's wait for the restored hub: a hub busy with discovery right after it loads answers slowly. */
17
+ export const ROLLBACK_PROBE_MS = 10_000;
18
+ export const BACKUP_SUFFIX = '.pre-cutover';
19
+ /** The steps of a cutover (ADR-4 and the architect's 10:44 order), in order. */
20
+ export const STEPS = Object.freeze(['sign_in_moved', 'shims_offline', 'hub_unloaded', 'hub_db_quiet', 'final_delta', 'reconciled', 'backed_up', 'daemon_serving', 'shims_online', 'smoke']);
21
+
22
+ const sha256File = async (file) => createHash('sha256').update(await readFile(file)).digest('hex');
23
+
24
+ /** What a fresh journal keeps from the last one: the daemon's request history (CUT-03). */
25
+ const carried = (previous) => Object.fromEntries(['requests', 'refusals'].filter((key) => previous?.[key]).map((key) => [key, previous[key]]));
26
+
27
+ /** `version >= floor` on the numeric x.y.z core; anything unparsable is older. */
28
+ function atLeast(version, floor) {
29
+ const parse = (v) => String(v ?? '').match(/^(\d+)\.(\d+)\.(\d+)/)?.slice(1).map(Number) ?? null;
30
+ const [have, need] = [parse(version), parse(floor)];
31
+ if (!have) return false;
32
+ for (let i = 0; i < 3; i += 1) if (have[i] !== need[i]) return have[i] > need[i];
33
+ return true;
34
+ }
35
+
36
+ /**
37
+ * `agentpulse pulse cutover`: the local hub's work moves into the Pulse daemon on this machine, in a short freeze, with
38
+ * a backup kept; `--rollback` restores that backup and the old hub. Every effect goes through an injected seam:
39
+ * `services` (launchd, by label), `probe` (who holds hub.db), `cloudFirst` (Phase 2's final import and reconcile),
40
+ * `daemon` (the facade's health and the old hub's), `smoke`, `backupDb`, `applySpool` and `signIn` (the TokenStore). Each step is journaled in
41
+ * cutover.json before the next starts; a failure at any step after the freeze began rolls back by itself. `serve` is
42
+ * called after each change of `cloudFirst.mode`: run inside the daemon (CUT-03) it starts or stops the daemon's own
43
+ * runner at once instead of on its next heartbeat.
44
+ */
45
+ export class Cutover {
46
+ constructor({
47
+ configDir, hubHome, port = 8890, services, probe, cloudFirst, daemon, smoke, backupDb, applySpool, signIn, serve = async () => {},
48
+ labels = OLD_HUB_LABELS, holderWaitMs = 120_000, facadeWaitMs = 90_000, pollMs = 1_000, now = Date.now, wait = sleep, log = () => {},
49
+ }) {
50
+ if (labels.includes(SERVICE_LABEL)) throw new HubError('HUB_CUTOVER_LABELS', `The cutover never unloads the Pulse daemon itself (${SERVICE_LABEL})`);
51
+ this.configDir = configDir;
52
+ this.configFile = path.join(configDir, 'config.json');
53
+ this.journalFile = path.join(configDir, CUTOVER_FILE);
54
+ this.hubHome = hubHome;
55
+ this.hubDb = path.join(hubHome, 'state', 'hub.db');
56
+ this.backupFile = `${this.hubDb}${BACKUP_SUFFIX}`;
57
+ Object.assign(this, { port, services, probe, cloudFirst, daemon, smoke, backupDb, applySpool, signIn, serve, labels, holderWaitMs, facadeWaitMs, pollMs, now, wait, log });
58
+ }
59
+
60
+ async journal() {
61
+ return (await readOwnerOnlyJson(this.journalFile)) || { state: 'none', steps: {} };
62
+ }
63
+
64
+ async #record(journal, step, detail = {}) {
65
+ journal.steps[step] = { at: new Date(this.now()).toISOString(), ...detail };
66
+ journal.updatedAt = journal.steps[step].at;
67
+ await writeOwnerOnlyJson(this.journalFile, journal);
68
+ this.log('cutover_step', { step, ...detail });
69
+ }
70
+
71
+ async #config() {
72
+ const config = await readOwnerOnlyJson(this.configFile);
73
+ if (!config) throw new HubError('HUB_NOT_INSTALLED', 'Pulse is not installed here; run `agentpulse pulse install`');
74
+ return config;
75
+ }
76
+
77
+ /** Who has hub.db or its WAL open, besides this process. Unknown (lsof failed) is never "nobody" (N1). */
78
+ async #holders() {
79
+ const found = await this.probe.holders([this.hubDb, `${this.hubDb}-wal`]);
80
+ if (found.error) return { known: false, pids: [], error: found.error };
81
+ return { known: true, pids: found.pids.filter((pid) => pid !== process.pid) };
82
+ }
83
+
84
+ /**
85
+ * CUT-01: the old hub holds the port. It answers HTTP, or it is hung: its launchd job is loaded, the job's pid (or a
86
+ * child of it, by lsof's parent pid) owns the LISTEN socket, and the facade does not answer there. The cutover replaces a hung hub anyway,
87
+ * so being healthy is not required. It fails when the port is free, another process owns it, or the facade serves it.
88
+ */
89
+ async #oldHubOnPort() {
90
+ const where = `127.0.0.1:${this.port}`;
91
+ if (await this.daemon.oldHubUp(this.port)) return { ok: true, detail: `answers HTTP on ${where}` };
92
+ if (await this.daemon.facadeUp(this.port)) return { ok: false, detail: `the Pulse facade already serves ${where}` };
93
+ const found = await this.probe.portOwners(this.port);
94
+ if (found.error) return { ok: false, detail: `cannot tell who holds ${where} (${found.error})` };
95
+ if (!found.owners.length) return { ok: false, detail: `${where} is free: the old hub is not running` };
96
+ const pids = found.owners.map((o) => o.pid).join(', ');
97
+ const [label] = this.labels;
98
+ const job = await this.services.labelState(label);
99
+ if (!job.loaded || !job.pid) return { ok: false, detail: `${where} is held by pid ${pids}, but ${label} is not running` };
100
+ const owner = found.owners.find((o) => o.pid === job.pid || o.ppid === job.pid);
101
+ if (!owner) return { ok: false, detail: `${where} is held by pid ${pids}, not by ${label} (pid ${job.pid}) or a child of it` };
102
+ return { ok: true, hung: true, pid: owner.pid, detail: `held by pid ${owner.pid}, not answering HTTP (it is replaced anyway)` };
103
+ }
104
+
105
+ /** The cloud seq this machine's import reached: everything at or below it is already in the seed (N3). */
106
+ #cutoverSeq() {
107
+ const store = RunnerStore.openReadOnly(this.configDir);
108
+ if (!store) return 0;
109
+ try {
110
+ return Number(store.db.prepare('SELECT MAX(CAST(seq AS INTEGER)) AS seq FROM sent').get()?.seq || 0);
111
+ } finally {
112
+ store.close();
113
+ }
114
+ }
115
+
116
+ /** The cutover backup runner.db's hub tables were seeded from, or null. */
117
+ #seed() {
118
+ const store = RunnerStore.openReadOnly(this.configDir);
119
+ if (!store) return null;
120
+ try {
121
+ return store.db.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get() ? store.meta(SEED_META) : null;
122
+ } finally {
123
+ store.close();
124
+ }
125
+ }
126
+
127
+ /** M1: the running daemon, by its last heartbeat, runs connector code that reads the sign-in where step 0 puts it. */
128
+ async #daemonVersion() {
129
+ const health = await readOwnerOnlyJson(path.join(this.configDir, 'daemon-health.json')).catch(() => null);
130
+ if (!health) return { ok: false, detail: 'no daemon-health.json: the Pulse daemon is not running' };
131
+ const ageMs = this.now() - Date.parse(health.heartbeatAt);
132
+ if (!(ageMs <= HEALTH_FRESH_MS)) return { ok: false, detail: `the daemon's last heartbeat was ${Number.isFinite(ageMs) ? `${Math.round(ageMs / 1000)} s` : 'never'} ago` };
133
+ if (!health.connectorVersion) return { ok: false, detail: `pid ${health.pid} reports no connector version: it runs code from before ${MIN_DAEMON_VERSION}` };
134
+ return { ok: atLeast(health.connectorVersion, MIN_DAEMON_VERSION), detail: `connector ${health.connectorVersion}, pid ${health.pid}` };
135
+ }
136
+
137
+ /** B1: a seed is replaced only after a rollback listed what the runner wrote on top of it. */
138
+ #seedCheck() {
139
+ const seed = this.#seed();
140
+ if (!seed) return { ok: true, detail: 'not seeded yet: this cutover seeds it from the new backup' };
141
+ const from = `${seed.sha256 ? `backup ${seed.sha256.slice(0, 12)}` : 'a backup with no sha256 recorded'} (${seed.file})`;
142
+ if (seed.rolledBackAt) return { ok: true, detail: `seeded from ${from}, rolled back at ${seed.rolledBackAt}: this cutover reseeds it from the new backup` };
143
+ return { ok: false, detail: `seeded from ${from} and never rolled back: reseeding would drop what the runner wrote since, unlisted` };
144
+ }
145
+
146
+ /** Everything the cutover checks before it touches anything, and what it would change. Reads only. */
147
+ async dryRun() {
148
+ const checks = [];
149
+ const check = (name, ok, detail = null, fix = null) => checks.push({ name, ok: Boolean(ok), ...(detail ? { detail } : {}), ...(fix ? { fix } : {}) });
150
+ const config = await this.#config();
151
+ const capabilities = config.capabilities || [];
152
+ check('paired', Boolean(config.paired?.machineId), config.paired?.machineId || 'not paired', 'agentpulse pulse pair');
153
+ check('grant reads and writes the log', ['hub.sync', 'log.write'].every((c) => capabilities.includes(c)), capabilities.join(', '), 'agentpulse pulse log-write on');
154
+ check('not already cut over', config.cloudFirst?.mode !== 'runner', config.cloudFirst?.mode || 'off', 'agentpulse pulse cutover --rollback');
155
+ const daemon = await this.#daemonVersion();
156
+ check(`the daemon runs connector ${MIN_DAEMON_VERSION} or later`, daemon.ok, daemon.detail, `link the Pulse ${MIN_DAEMON_VERSION} plugin, then agentpulse pulse restart`);
157
+ const seed = this.#seedCheck();
158
+ check('runner.db seed', seed.ok, seed.detail, 'agentpulse pulse cutover --rollback');
159
+ let hubDbSize = 0;
160
+ try { hubDbSize = (await stat(this.hubDb)).size; check('hub.db', true, `${hubDbSize} bytes`); } catch { check('hub.db', false, this.hubDb); }
161
+ try {
162
+ const token = await stat(path.join(this.hubHome, 'state', 'hub.token'));
163
+ check('hub token is owner-only', (token.mode & 0o077) === 0, (token.mode & 0o777).toString(8));
164
+ } catch { check('hub token', false, 'missing'); }
165
+ const entry = await clientState({ hubHome: this.hubHome });
166
+ check('entry points are agents-hub 6b2fb0b', Object.values(entry).every((s) => s === 'original'), JSON.stringify(entry));
167
+ for (const label of this.labels) {
168
+ const state = await this.services.labelState(label);
169
+ check(`${label} installed`, state.installed, state.loaded ? `loaded, pid ${state.pid}` : 'not loaded');
170
+ }
171
+ const onPort = await this.#oldHubOnPort();
172
+ check('the old hub holds the port', onPort.ok, onPort.detail, `start the old hub, or see what holds the port: lsof -nP -iTCP@127.0.0.1:${this.port} -sTCP:LISTEN`);
173
+ const holders = await this.#holders();
174
+ check('who holds hub.db now (informative)', true, holders.known ? `${holders.pids.length} process(es): ${holders.pids.join(', ') || 'none'}` : `unknown: ${holders.error}`);
175
+ check('no backup left from an earlier cutover', !(await stat(this.backupFile).catch(() => null)), this.backupFile, 'rename or remove it first');
176
+ const ok = checks.every((c) => c.ok);
177
+ return {
178
+ command: 'cutover',
179
+ dryRun: true,
180
+ ok,
181
+ checks,
182
+ wouldChange: {
183
+ files: Object.keys(entry).map((name) => ({ name, becomes: 'Pulse thin client', keptAs: `<file>${BACKUP_SUFFIX}` })),
184
+ launchAgents: this.labels.map((label) => ({ label, becomes: 'unloaded and disabled' })),
185
+ backup: this.backupFile,
186
+ config: { 'cloudFirst.mode': 'runner', 'cloudFirst.hubHome': this.hubHome, 'cloudFirst.port': this.port, 'mirror.hub': { dir: this.hubHome, port: this.port } },
187
+ cutoverSeq: this.#cutoverSeq(),
188
+ signIn: await this.signIn.secretPlan(),
189
+ },
190
+ outage: {
191
+ what: 'from shims offline (step 1) to shims online (step 8): hooks answer {}, bus listen sees no new mail, writes spool under printed ids',
192
+ boundMs: this.holderWaitMs + 2 * 10 * 60_000 + this.facadeWaitMs,
193
+ },
194
+ message: ok ? 'Ready to cut over. Nothing was changed.' : 'Not ready: see the checks that failed. Nothing was changed.',
195
+ };
196
+ }
197
+
198
+ /**
199
+ * The cutover. Resolves with the journal; a failure after the freeze began rolls back and rethrows. `request` is the
200
+ * daemon's accepted cutover.request (CUT-03), kept on the journal so its CLI can follow it.
201
+ */
202
+ async run({ smokeChannel = null, request = null } = {}) {
203
+ const preflight = await this.dryRun();
204
+ if (!preflight.ok) throw new HubError('HUB_CUTOVER_NOT_READY', preflight.message, { details: { checks: preflight.checks.filter((c) => !c.ok) } });
205
+ const journal = {
206
+ state: 'running', startedAt: new Date(this.now()).toISOString(), hubHome: this.hubHome, port: this.port, steps: {}, loadedBefore: [],
207
+ runBy: request ? 'daemon' : 'cli', pid: process.pid, ...carried(await this.journal()), ...(request ? { request } : {}),
208
+ };
209
+ await writeOwnerOnlyJson(this.journalFile, journal);
210
+ try {
211
+ // Before the freeze: the sign-in moves to this config's own Keychain item (the 12:52 DECISION); no outage.
212
+ const moved = await this.signIn.migrateSecret();
213
+ await this.#record(journal, 'sign_in_moved', moved.moved ? { from: moved.from, to: moved.to } : { to: moved.reference });
214
+ await installClients({ hubHome: this.hubHome, port: this.port, offline: true });
215
+ await this.#record(journal, 'shims_offline');
216
+ for (const label of this.labels) {
217
+ const before = await this.services.labelState(label);
218
+ if (before.loaded) journal.loadedBefore.push(label);
219
+ await this.services.retireLabel(label);
220
+ }
221
+ await this.#record(journal, 'hub_unloaded', { labels: this.labels });
222
+ const deadline = this.now() + this.holderWaitMs;
223
+ let holders = await this.#holders();
224
+ while (!(holders.known && holders.pids.length === 0)) {
225
+ if (this.now() >= deadline) {
226
+ throw new HubError('HUB_CUTOVER_HOLDERS', holders.known
227
+ ? `hub.db is still open by ${holders.pids.join(', ')} after ${this.holderWaitMs} ms`
228
+ : `cannot tell who holds hub.db (${holders.error})`);
229
+ }
230
+ await this.wait(this.pollMs);
231
+ holders = await this.#holders();
232
+ }
233
+ await this.#record(journal, 'hub_db_quiet');
234
+ const delta = await this.cloudFirst.import({ wait: true });
235
+ await this.#record(journal, 'final_delta', { message: delta?.message ?? null });
236
+ // CUT-02: channels whose file is gone (retired) and text Pulse holds more redacted (redacted) are listed, not lost.
237
+ const reconcile = await this.cloudFirst.reconcile();
238
+ const outcome = { classes: reconcile.classes ?? null, retired: reconcile.retired ?? [], redacted: reconcile.redacted ?? [] };
239
+ if (reconcile.exitCode !== 0) {
240
+ journal.reconcile = outcome;
241
+ throw new HubError('HUB_CUTOVER_RECONCILE', `reconcile is not all equal: ${reconcile.message || reconcile.status}`);
242
+ }
243
+ await this.#record(journal, 'reconciled', { message: reconcile.message ?? null, ...outcome });
244
+ await this.backupDb(this.hubDb, this.backupFile);
245
+ await chmod(this.backupFile, 0o444);
246
+ journal.backup = { file: this.backupFile, sha256: await sha256File(this.backupFile) };
247
+ await this.#record(journal, 'backed_up', journal.backup);
248
+ const cutoverSeq = this.#cutoverSeq();
249
+ const config = await this.#config();
250
+ await writeOwnerOnlyJson(this.configFile, {
251
+ ...config,
252
+ mirror: { ...(config.mirror || {}), hub: { dir: this.hubHome, port: this.port } },
253
+ cloudFirst: { ...(config.cloudFirst || {}), mode: 'runner', hubHome: this.hubHome, port: this.port, cutoverSeq, seedFrom: this.backupFile, seedSha256: journal.backup.sha256, shadow: false },
254
+ });
255
+ journal.cutoverSeq = cutoverSeq;
256
+ await this.serve();
257
+ const serving = this.now() + this.facadeWaitMs;
258
+ while (!(await this.daemon.facadeUp(this.port))) {
259
+ if (this.now() >= serving) throw new HubError('HUB_CUTOVER_FACADE', `the Pulse daemon did not serve 127.0.0.1:${this.port} within ${this.facadeWaitMs} ms`);
260
+ await this.wait(this.pollMs);
261
+ }
262
+ journal.cutoverAt = this.now();
263
+ await this.#record(journal, 'daemon_serving', { cutoverSeq });
264
+ await installClients({ hubHome: this.hubHome, port: this.port, offline: false });
265
+ await this.#record(journal, 'shims_online');
266
+ const smoke = await this.smoke({ hubHome: this.hubHome, port: this.port, channel: smokeChannel });
267
+ if (!smoke.ok) throw new HubError('HUB_CUTOVER_SMOKE', `smoke test failed: ${smoke.reason}`);
268
+ await this.#record(journal, 'smoke', smoke);
269
+ journal.state = 'done';
270
+ await writeOwnerOnlyJson(this.journalFile, journal);
271
+ return journal;
272
+ } catch (error) {
273
+ journal.failure = { step: STEPS.find((s) => !journal.steps[s]) ?? null, code: error.code || 'error', message: String(error.message).slice(0, 300) };
274
+ await writeOwnerOnlyJson(this.journalFile, journal);
275
+ this.log('cutover_failed', journal.failure);
276
+ const rolled = await this.rollback({ reason: journal.failure.code });
277
+ throw Object.assign(error, { rollback: rolled });
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Back to the local hub from any point (ADR-4): the daemon leaves the port, what was written since the switch is
283
+ * exported, the entry points are restored byte for byte, hub.db comes back from the backup, the freeze's spool is
284
+ * applied to it by edge id, and the old LaunchAgents load again. Loses only what was written after the switch.
285
+ */
286
+ async rollback({ reason = 'asked' } = {}) {
287
+ const journal = await this.journal();
288
+ const report = { command: 'cutover', rollback: true, reason, startedAt: new Date(this.now()).toISOString(), steps: [] };
289
+ // Each step is journaled as it ends, so a CLI following a daemon's rollback (CUT-03) prints it.
290
+ const step = async (name, detail = {}) => {
291
+ report.steps.push({ name, ...detail });
292
+ await writeOwnerOnlyJson(this.journalFile, { ...journal, rollback: report });
293
+ this.log('rollback_step', { name, ...detail });
294
+ };
295
+ const config = await this.#config();
296
+ if (config.cloudFirst?.mode === 'runner') {
297
+ await writeOwnerOnlyJson(this.configFile, { ...config, cloudFirst: { ...config.cloudFirst, mode: 'rolled_back' } });
298
+ await this.serve();
299
+ const deadline = this.now() + this.facadeWaitMs;
300
+ while (await this.daemon.facadeUp(this.port)) {
301
+ if (this.now() >= deadline) throw new HubError('HUB_ROLLBACK_FACADE', `the Pulse daemon still serves 127.0.0.1:${this.port}`);
302
+ await this.wait(this.pollMs);
303
+ }
304
+ await step('daemon_left_port');
305
+ }
306
+ if (journal.cutoverAt) {
307
+ const lost = await this.#exportLost(journal.cutoverAt);
308
+ report.lost = lost;
309
+ await step('lost_exported', lost);
310
+ }
311
+ const marked = await this.#markSeedRolledBack();
312
+ if (marked) await step('runner_db_marked', marked);
313
+ await restoreClients({ hubHome: this.hubHome });
314
+ await step('entry_points_restored');
315
+ if (journal.steps?.backed_up && (await stat(this.backupFile).catch(() => null))) {
316
+ for (const suffix of ['-wal', '-shm']) await rm(`${this.hubDb}${suffix}`, { force: true });
317
+ const restored = `${this.hubDb}.pulse-restore`;
318
+ await copyFile(this.backupFile, restored);
319
+ await chmod(restored, 0o600);
320
+ await rm(this.hubDb, { force: true });
321
+ await copyFile(restored, this.hubDb);
322
+ await rm(restored, { force: true });
323
+ await step('hub_db_restored', { from: this.backupFile, sha256: await sha256File(this.backupFile) });
324
+ }
325
+ const spool = await this.applySpool(this.hubDb);
326
+ await step('spool_applied', spool);
327
+ const loadedBefore = journal.loadedBefore?.length ? journal.loadedBefore : this.labels;
328
+ // Journaled before the first load: from here the old hub may have hub.db open, so it is never restored again.
329
+ await step('hub_loading', { labels: loadedBefore });
330
+ for (const label of loadedBefore) await this.services.restoreLabel(label);
331
+ await step('hub_loaded', { labels: loadedBefore });
332
+ const deadline = this.now() + this.facadeWaitMs;
333
+ while (!(await this.daemon.oldHubUp(this.port, { timeoutMs: ROLLBACK_PROBE_MS }))) {
334
+ if (this.now() >= deadline) {
335
+ const onPort = await this.#oldHubOnPort();
336
+ throw new HubError('HUB_ROLLBACK_HUB', onPort.hung
337
+ ? `the local hub is back and holds 127.0.0.1:${this.port} (pid ${onPort.pid}), but did not answer HTTP within ${this.facadeWaitMs} ms; it may still be busy, so check http://127.0.0.1:${this.port}/api/settings before restarting it`
338
+ : `the local hub did not come back on 127.0.0.1:${this.port}: ${onPort.detail}`);
339
+ }
340
+ await this.wait(this.pollMs);
341
+ }
342
+ await step('hub_serving');
343
+ report.message = report.lost?.count
344
+ ? `Rolled back to the local hub. ${report.lost.count} write(s) made after the switch (${Object.entries(report.lost.kinds).map(([kind, n]) => `${kind} ${n}`).join(', ')}) are not in hub.db; they are listed in ${report.lost.file}.`
345
+ : 'Rolled back to the local hub. Nothing written after the switch was lost.';
346
+ await writeOwnerOnlyJson(this.journalFile, { ...journal, state: 'rolled_back', rolledBackAt: new Date(this.now()).toISOString(), rollback: report });
347
+ return report;
348
+ }
349
+
350
+ /**
351
+ * CUT-03 boot recovery of a rollback that had begun loading the old hub: whether the old hub holds the port again
352
+ * (it answers, or it is hung but owned by its job), waiting up to facadeWaitMs. Reads only.
353
+ */
354
+ async verifyRolledBack() {
355
+ const deadline = this.now() + this.facadeWaitMs;
356
+ for (;;) {
357
+ const onPort = await this.#oldHubOnPort();
358
+ if (onPort.ok || this.now() >= deadline) return onPort;
359
+ await this.wait(this.pollMs);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * After the runner left the port: the seed is marked rolled back, so the next cutover replaces it with its own
365
+ * backup instead of serving this attempt's state (B1). Null when runner.db was never seeded.
366
+ */
367
+ async #markSeedRolledBack() {
368
+ const seed = this.#seed();
369
+ if (!seed) return null;
370
+ const rolledBackAt = seed.rolledBackAt || new Date(this.now()).toISOString();
371
+ const store = await RunnerStore.open(this.configDir);
372
+ try {
373
+ store.setMeta(SEED_META, { ...seed, rolledBackAt });
374
+ } finally {
375
+ store.close();
376
+ }
377
+ return { sha256: seed.sha256 ?? null, rolledBackAt };
378
+ }
379
+
380
+ /**
381
+ * What runner.db holds that was written after the switch: it doesn't reach hub.db, so it's listed for the user.
382
+ * Messages, acks, delivery receipts and registrations (M2), in time order.
383
+ */
384
+ async #exportLost(cutoverAt) {
385
+ const store = RunnerStore.openReadOnly(this.configDir);
386
+ if (!store) return { count: 0, kinds: {}, file: null };
387
+ const rows = [];
388
+ try {
389
+ const has = (name) => store.db.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
390
+ const all = (kind, sql, ...args) => rows.push(...store.db.prepare(sql).all(...args).map((row) => ({ kind, ...row })));
391
+ if (has('messages')) all('message', 'SELECT id, sender, topic, type, body, created_at AS at FROM messages WHERE created_at >= ?', cutoverAt);
392
+ if (has('deliveries')) {
393
+ all('ack', 'SELECT message_id, recipient, acked_at AS at FROM deliveries WHERE acked_at >= ?', cutoverAt);
394
+ all('delivered', `SELECT message_id, recipient, delivered_via AS via, delivered_at AS at FROM deliveries
395
+ WHERE delivered_at >= ? AND NOT COALESCE(acked_at >= ?, 0)`, cutoverAt, cutoverAt);
396
+ }
397
+ if (has('actors') && has('pulse_registered')) {
398
+ all('registration', `SELECT a.id, a.role, a.node_type, a.topics, COALESCE(r.at, a.started_at) AS at
399
+ FROM actors a LEFT JOIN pulse_registered r ON r.id = a.id WHERE a.started_at >= ? OR r.at >= ?`, cutoverAt, cutoverAt);
400
+ }
401
+ } finally {
402
+ store.close();
403
+ }
404
+ rows.sort((a, b) => Number(a.at) - Number(b.at));
405
+ const kinds = {};
406
+ for (const row of rows) kinds[row.kind] = (kinds[row.kind] || 0) + 1;
407
+ const file = path.join(this.configDir, `rollback-lost-${this.now()}.jsonl`);
408
+ await writeFile(file, rows.map((row) => JSON.stringify(row)).join('\n') + (rows.length ? '\n' : ''), { mode: 0o600 });
409
+ return { count: rows.length, kinds, file };
410
+ }
411
+ }