@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.
- package/README.md +46 -0
- package/package.json +7 -4
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +39 -0
- package/src/commands/pulse/doctor.js +1 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/daemon/main.js +19 -12
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/application.js +82 -34
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/cutover.js +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonLock.js +2 -0
- package/src/lib/daemonLoop.js +5 -2
- package/src/lib/daemonProcess.js +16 -2
- package/src/lib/daemonRuntime.js +8 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/errors.js +14 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/mirrorContract.js +1 -1
- package/src/lib/mirrorService.js +8 -1
- package/src/lib/mirrorSource.js +13 -38
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/pairingLifecycle.js +1 -1
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/serviceManager.js +61 -0
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/tokenStore.js +43 -4
- package/src/lib/transport.js +53 -5
- package/src/lib/transportContract.js +3 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +140 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
|
@@ -0,0 +1,376 @@
|
|
|
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 { readOwnerOnlyJson, writeOwnerOnlyJson } from './storage.js';
|
|
9
|
+
|
|
10
|
+
export const CUTOVER_FILE = 'cutover.json';
|
|
11
|
+
/** The oldest daemon that finds the sign-in under its per-config Keychain name, which step 0 moves it to (M1). */
|
|
12
|
+
export const MIN_DAEMON_VERSION = '1.1.0';
|
|
13
|
+
const HEALTH_FRESH_MS = 60_000;
|
|
14
|
+
export const OLD_HUB_LABELS = Object.freeze(['com.agentpulse.agents-hub', 'com.agentpulse.keepawake']);
|
|
15
|
+
/** Rollback's wait for the restored hub: a hub busy with discovery right after it loads answers slowly. */
|
|
16
|
+
export const ROLLBACK_PROBE_MS = 10_000;
|
|
17
|
+
export const BACKUP_SUFFIX = '.pre-cutover';
|
|
18
|
+
/** The steps of a cutover (ADR-4 and the architect's 10:44 order), in order. */
|
|
19
|
+
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']);
|
|
20
|
+
|
|
21
|
+
const sha256File = async (file) => createHash('sha256').update(await readFile(file)).digest('hex');
|
|
22
|
+
|
|
23
|
+
/** `version >= floor` on the numeric x.y.z core; anything unparsable is older. */
|
|
24
|
+
function atLeast(version, floor) {
|
|
25
|
+
const parse = (v) => String(v ?? '').match(/^(\d+)\.(\d+)\.(\d+)/)?.slice(1).map(Number) ?? null;
|
|
26
|
+
const [have, need] = [parse(version), parse(floor)];
|
|
27
|
+
if (!have) return false;
|
|
28
|
+
for (let i = 0; i < 3; i += 1) if (have[i] !== need[i]) return have[i] > need[i];
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `agentpulse pulse cutover`: the local hub's work moves into the Pulse daemon on this machine, in a short freeze, with
|
|
34
|
+
* a backup kept; `--rollback` restores that backup and the old hub. Every effect goes through an injected seam:
|
|
35
|
+
* `services` (launchd, by label), `probe` (who holds hub.db), `cloudFirst` (Phase 2's final import and reconcile),
|
|
36
|
+
* `daemon` (the facade's health and the old hub's), `smoke`, `backupDb`, `applySpool` and `signIn` (the TokenStore). Each step is journaled in
|
|
37
|
+
* cutover.json before the next starts; a failure at any step after the freeze began rolls back by itself.
|
|
38
|
+
*/
|
|
39
|
+
export class Cutover {
|
|
40
|
+
constructor({
|
|
41
|
+
configDir, hubHome, port = 8890, services, probe, cloudFirst, daemon, smoke, backupDb, applySpool, signIn,
|
|
42
|
+
labels = OLD_HUB_LABELS, holderWaitMs = 120_000, facadeWaitMs = 90_000, pollMs = 1_000, now = Date.now, wait = sleep, log = () => {},
|
|
43
|
+
}) {
|
|
44
|
+
this.configDir = configDir;
|
|
45
|
+
this.configFile = path.join(configDir, 'config.json');
|
|
46
|
+
this.journalFile = path.join(configDir, CUTOVER_FILE);
|
|
47
|
+
this.hubHome = hubHome;
|
|
48
|
+
this.hubDb = path.join(hubHome, 'state', 'hub.db');
|
|
49
|
+
this.backupFile = `${this.hubDb}${BACKUP_SUFFIX}`;
|
|
50
|
+
Object.assign(this, { port, services, probe, cloudFirst, daemon, smoke, backupDb, applySpool, signIn, labels, holderWaitMs, facadeWaitMs, pollMs, now, wait, log });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async journal() {
|
|
54
|
+
return (await readOwnerOnlyJson(this.journalFile)) || { state: 'none', steps: {} };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async #record(journal, step, detail = {}) {
|
|
58
|
+
journal.steps[step] = { at: new Date(this.now()).toISOString(), ...detail };
|
|
59
|
+
journal.updatedAt = journal.steps[step].at;
|
|
60
|
+
await writeOwnerOnlyJson(this.journalFile, journal);
|
|
61
|
+
this.log('cutover_step', { step, ...detail });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async #config() {
|
|
65
|
+
const config = await readOwnerOnlyJson(this.configFile);
|
|
66
|
+
if (!config) throw new HubError('HUB_NOT_INSTALLED', 'Pulse is not installed here; run `agentpulse pulse install`');
|
|
67
|
+
return config;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Who has hub.db or its WAL open, besides this process. Unknown (lsof failed) is never "nobody" (N1). */
|
|
71
|
+
async #holders() {
|
|
72
|
+
const found = await this.probe.holders([this.hubDb, `${this.hubDb}-wal`]);
|
|
73
|
+
if (found.error) return { known: false, pids: [], error: found.error };
|
|
74
|
+
return { known: true, pids: found.pids.filter((pid) => pid !== process.pid) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 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
|
|
79
|
+
* 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,
|
|
80
|
+
* so being healthy is not required. It fails when the port is free, another process owns it, or the facade serves it.
|
|
81
|
+
*/
|
|
82
|
+
async #oldHubOnPort() {
|
|
83
|
+
const where = `127.0.0.1:${this.port}`;
|
|
84
|
+
if (await this.daemon.oldHubUp(this.port)) return { ok: true, detail: `answers HTTP on ${where}` };
|
|
85
|
+
if (await this.daemon.facadeUp(this.port)) return { ok: false, detail: `the Pulse facade already serves ${where}` };
|
|
86
|
+
const found = await this.probe.portOwners(this.port);
|
|
87
|
+
if (found.error) return { ok: false, detail: `cannot tell who holds ${where} (${found.error})` };
|
|
88
|
+
if (!found.owners.length) return { ok: false, detail: `${where} is free: the old hub is not running` };
|
|
89
|
+
const pids = found.owners.map((o) => o.pid).join(', ');
|
|
90
|
+
const [label] = this.labels;
|
|
91
|
+
const job = await this.services.labelState(label);
|
|
92
|
+
if (!job.loaded || !job.pid) return { ok: false, detail: `${where} is held by pid ${pids}, but ${label} is not running` };
|
|
93
|
+
const owner = found.owners.find((o) => o.pid === job.pid || o.ppid === job.pid);
|
|
94
|
+
if (!owner) return { ok: false, detail: `${where} is held by pid ${pids}, not by ${label} (pid ${job.pid}) or a child of it` };
|
|
95
|
+
return { ok: true, hung: true, pid: owner.pid, detail: `held by pid ${owner.pid}, not answering HTTP (it is replaced anyway)` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The cloud seq this machine's import reached: everything at or below it is already in the seed (N3). */
|
|
99
|
+
#cutoverSeq() {
|
|
100
|
+
const store = RunnerStore.openReadOnly(this.configDir);
|
|
101
|
+
if (!store) return 0;
|
|
102
|
+
try {
|
|
103
|
+
return Number(store.db.prepare('SELECT MAX(CAST(seq AS INTEGER)) AS seq FROM sent').get()?.seq || 0);
|
|
104
|
+
} finally {
|
|
105
|
+
store.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The cutover backup runner.db's hub tables were seeded from, or null. */
|
|
110
|
+
#seed() {
|
|
111
|
+
const store = RunnerStore.openReadOnly(this.configDir);
|
|
112
|
+
if (!store) return null;
|
|
113
|
+
try {
|
|
114
|
+
return store.db.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get() ? store.meta(SEED_META) : null;
|
|
115
|
+
} finally {
|
|
116
|
+
store.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** M1: the running daemon, by its last heartbeat, runs connector code that reads the sign-in where step 0 puts it. */
|
|
121
|
+
async #daemonVersion() {
|
|
122
|
+
const health = await readOwnerOnlyJson(path.join(this.configDir, 'daemon-health.json')).catch(() => null);
|
|
123
|
+
if (!health) return { ok: false, detail: 'no daemon-health.json: the Pulse daemon is not running' };
|
|
124
|
+
const ageMs = this.now() - Date.parse(health.heartbeatAt);
|
|
125
|
+
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` };
|
|
126
|
+
if (!health.connectorVersion) return { ok: false, detail: `pid ${health.pid} reports no connector version: it runs code from before ${MIN_DAEMON_VERSION}` };
|
|
127
|
+
return { ok: atLeast(health.connectorVersion, MIN_DAEMON_VERSION), detail: `connector ${health.connectorVersion}, pid ${health.pid}` };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** B1: a seed is replaced only after a rollback listed what the runner wrote on top of it. */
|
|
131
|
+
#seedCheck() {
|
|
132
|
+
const seed = this.#seed();
|
|
133
|
+
if (!seed) return { ok: true, detail: 'not seeded yet: this cutover seeds it from the new backup' };
|
|
134
|
+
const from = `${seed.sha256 ? `backup ${seed.sha256.slice(0, 12)}` : 'a backup with no sha256 recorded'} (${seed.file})`;
|
|
135
|
+
if (seed.rolledBackAt) return { ok: true, detail: `seeded from ${from}, rolled back at ${seed.rolledBackAt}: this cutover reseeds it from the new backup` };
|
|
136
|
+
return { ok: false, detail: `seeded from ${from} and never rolled back: reseeding would drop what the runner wrote since, unlisted` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Everything the cutover checks before it touches anything, and what it would change. Reads only. */
|
|
140
|
+
async dryRun() {
|
|
141
|
+
const checks = [];
|
|
142
|
+
const check = (name, ok, detail = null, fix = null) => checks.push({ name, ok: Boolean(ok), ...(detail ? { detail } : {}), ...(fix ? { fix } : {}) });
|
|
143
|
+
const config = await this.#config();
|
|
144
|
+
const capabilities = config.capabilities || [];
|
|
145
|
+
check('paired', Boolean(config.paired?.machineId), config.paired?.machineId || 'not paired', 'agentpulse pulse pair');
|
|
146
|
+
check('grant reads and writes the log', ['hub.sync', 'log.write'].every((c) => capabilities.includes(c)), capabilities.join(', '), 'agentpulse pulse log-write on');
|
|
147
|
+
check('not already cut over', config.cloudFirst?.mode !== 'runner', config.cloudFirst?.mode || 'off', 'agentpulse pulse cutover --rollback');
|
|
148
|
+
const daemon = await this.#daemonVersion();
|
|
149
|
+
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`);
|
|
150
|
+
const seed = this.#seedCheck();
|
|
151
|
+
check('runner.db seed', seed.ok, seed.detail, 'agentpulse pulse cutover --rollback');
|
|
152
|
+
let hubDbSize = 0;
|
|
153
|
+
try { hubDbSize = (await stat(this.hubDb)).size; check('hub.db', true, `${hubDbSize} bytes`); } catch { check('hub.db', false, this.hubDb); }
|
|
154
|
+
try {
|
|
155
|
+
const token = await stat(path.join(this.hubHome, 'state', 'hub.token'));
|
|
156
|
+
check('hub token is owner-only', (token.mode & 0o077) === 0, (token.mode & 0o777).toString(8));
|
|
157
|
+
} catch { check('hub token', false, 'missing'); }
|
|
158
|
+
const entry = await clientState({ hubHome: this.hubHome });
|
|
159
|
+
check('entry points are agents-hub 6b2fb0b', Object.values(entry).every((s) => s === 'original'), JSON.stringify(entry));
|
|
160
|
+
for (const label of this.labels) {
|
|
161
|
+
const state = await this.services.labelState(label);
|
|
162
|
+
check(`${label} installed`, state.installed, state.loaded ? `loaded, pid ${state.pid}` : 'not loaded');
|
|
163
|
+
}
|
|
164
|
+
const onPort = await this.#oldHubOnPort();
|
|
165
|
+
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`);
|
|
166
|
+
const holders = await this.#holders();
|
|
167
|
+
check('who holds hub.db now (informative)', true, holders.known ? `${holders.pids.length} process(es): ${holders.pids.join(', ') || 'none'}` : `unknown: ${holders.error}`);
|
|
168
|
+
check('no backup left from an earlier cutover', !(await stat(this.backupFile).catch(() => null)), this.backupFile, 'rename or remove it first');
|
|
169
|
+
const ok = checks.every((c) => c.ok);
|
|
170
|
+
return {
|
|
171
|
+
command: 'cutover',
|
|
172
|
+
dryRun: true,
|
|
173
|
+
ok,
|
|
174
|
+
checks,
|
|
175
|
+
wouldChange: {
|
|
176
|
+
files: Object.keys(entry).map((name) => ({ name, becomes: 'Pulse thin client', keptAs: `<file>${BACKUP_SUFFIX}` })),
|
|
177
|
+
launchAgents: this.labels.map((label) => ({ label, becomes: 'unloaded and disabled' })),
|
|
178
|
+
backup: this.backupFile,
|
|
179
|
+
config: { 'cloudFirst.mode': 'runner', 'cloudFirst.hubHome': this.hubHome, 'cloudFirst.port': this.port, 'mirror.hub': { dir: this.hubHome, port: this.port } },
|
|
180
|
+
cutoverSeq: this.#cutoverSeq(),
|
|
181
|
+
signIn: await this.signIn.secretPlan(),
|
|
182
|
+
},
|
|
183
|
+
outage: {
|
|
184
|
+
what: 'from shims offline (step 1) to shims online (step 8): hooks answer {}, bus listen sees no new mail, writes spool under printed ids',
|
|
185
|
+
boundMs: this.holderWaitMs + 2 * 10 * 60_000 + this.facadeWaitMs,
|
|
186
|
+
},
|
|
187
|
+
message: ok ? 'Ready to cut over. Nothing was changed.' : 'Not ready: see the checks that failed. Nothing was changed.',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The cutover. Resolves with the journal; a failure after the freeze began rolls back and rethrows. */
|
|
192
|
+
async run({ smokeChannel = null } = {}) {
|
|
193
|
+
const preflight = await this.dryRun();
|
|
194
|
+
if (!preflight.ok) throw new HubError('HUB_CUTOVER_NOT_READY', preflight.message, { details: { checks: preflight.checks.filter((c) => !c.ok) } });
|
|
195
|
+
const journal = { state: 'running', startedAt: new Date(this.now()).toISOString(), hubHome: this.hubHome, port: this.port, steps: {}, loadedBefore: [] };
|
|
196
|
+
await writeOwnerOnlyJson(this.journalFile, journal);
|
|
197
|
+
try {
|
|
198
|
+
// Before the freeze: the sign-in moves to this config's own Keychain item (the 12:52 DECISION); no outage.
|
|
199
|
+
const moved = await this.signIn.migrateSecret();
|
|
200
|
+
await this.#record(journal, 'sign_in_moved', moved.moved ? { from: moved.from, to: moved.to } : { to: moved.reference });
|
|
201
|
+
await installClients({ hubHome: this.hubHome, port: this.port, offline: true });
|
|
202
|
+
await this.#record(journal, 'shims_offline');
|
|
203
|
+
for (const label of this.labels) {
|
|
204
|
+
const before = await this.services.labelState(label);
|
|
205
|
+
if (before.loaded) journal.loadedBefore.push(label);
|
|
206
|
+
await this.services.retireLabel(label);
|
|
207
|
+
}
|
|
208
|
+
await this.#record(journal, 'hub_unloaded', { labels: this.labels });
|
|
209
|
+
const deadline = this.now() + this.holderWaitMs;
|
|
210
|
+
let holders = await this.#holders();
|
|
211
|
+
while (!(holders.known && holders.pids.length === 0)) {
|
|
212
|
+
if (this.now() >= deadline) {
|
|
213
|
+
throw new HubError('HUB_CUTOVER_HOLDERS', holders.known
|
|
214
|
+
? `hub.db is still open by ${holders.pids.join(', ')} after ${this.holderWaitMs} ms`
|
|
215
|
+
: `cannot tell who holds hub.db (${holders.error})`);
|
|
216
|
+
}
|
|
217
|
+
await this.wait(this.pollMs);
|
|
218
|
+
holders = await this.#holders();
|
|
219
|
+
}
|
|
220
|
+
await this.#record(journal, 'hub_db_quiet');
|
|
221
|
+
const delta = await this.cloudFirst.import({ wait: true });
|
|
222
|
+
await this.#record(journal, 'final_delta', { message: delta?.message ?? null });
|
|
223
|
+
// CUT-02: channels whose file is gone (retired) and text Pulse holds more redacted (redacted) are listed, not lost.
|
|
224
|
+
const reconcile = await this.cloudFirst.reconcile();
|
|
225
|
+
const outcome = { classes: reconcile.classes ?? null, retired: reconcile.retired ?? [], redacted: reconcile.redacted ?? [] };
|
|
226
|
+
if (reconcile.exitCode !== 0) {
|
|
227
|
+
journal.reconcile = outcome;
|
|
228
|
+
throw new HubError('HUB_CUTOVER_RECONCILE', `reconcile is not all equal: ${reconcile.message || reconcile.status}`);
|
|
229
|
+
}
|
|
230
|
+
await this.#record(journal, 'reconciled', { message: reconcile.message ?? null, ...outcome });
|
|
231
|
+
await this.backupDb(this.hubDb, this.backupFile);
|
|
232
|
+
await chmod(this.backupFile, 0o444);
|
|
233
|
+
journal.backup = { file: this.backupFile, sha256: await sha256File(this.backupFile) };
|
|
234
|
+
await this.#record(journal, 'backed_up', journal.backup);
|
|
235
|
+
const cutoverSeq = this.#cutoverSeq();
|
|
236
|
+
const config = await this.#config();
|
|
237
|
+
await writeOwnerOnlyJson(this.configFile, {
|
|
238
|
+
...config,
|
|
239
|
+
mirror: { ...(config.mirror || {}), hub: { dir: this.hubHome, port: this.port } },
|
|
240
|
+
cloudFirst: { ...(config.cloudFirst || {}), mode: 'runner', hubHome: this.hubHome, port: this.port, cutoverSeq, seedFrom: this.backupFile, seedSha256: journal.backup.sha256, shadow: false },
|
|
241
|
+
});
|
|
242
|
+
journal.cutoverSeq = cutoverSeq;
|
|
243
|
+
const serving = this.now() + this.facadeWaitMs;
|
|
244
|
+
while (!(await this.daemon.facadeUp(this.port))) {
|
|
245
|
+
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`);
|
|
246
|
+
await this.wait(this.pollMs);
|
|
247
|
+
}
|
|
248
|
+
journal.cutoverAt = this.now();
|
|
249
|
+
await this.#record(journal, 'daemon_serving', { cutoverSeq });
|
|
250
|
+
await installClients({ hubHome: this.hubHome, port: this.port, offline: false });
|
|
251
|
+
await this.#record(journal, 'shims_online');
|
|
252
|
+
const smoke = await this.smoke({ hubHome: this.hubHome, port: this.port, channel: smokeChannel });
|
|
253
|
+
if (!smoke.ok) throw new HubError('HUB_CUTOVER_SMOKE', `smoke test failed: ${smoke.reason}`);
|
|
254
|
+
await this.#record(journal, 'smoke', smoke);
|
|
255
|
+
journal.state = 'done';
|
|
256
|
+
await writeOwnerOnlyJson(this.journalFile, journal);
|
|
257
|
+
return journal;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
journal.failure = { step: STEPS.find((s) => !journal.steps[s]) ?? null, code: error.code || 'error', message: String(error.message).slice(0, 300) };
|
|
260
|
+
await writeOwnerOnlyJson(this.journalFile, journal);
|
|
261
|
+
this.log('cutover_failed', journal.failure);
|
|
262
|
+
const rolled = await this.rollback({ reason: journal.failure.code });
|
|
263
|
+
throw Object.assign(error, { rollback: rolled });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Back to the local hub from any point (ADR-4): the daemon leaves the port, what was written since the switch is
|
|
269
|
+
* exported, the entry points are restored byte for byte, hub.db comes back from the backup, the freeze's spool is
|
|
270
|
+
* applied to it by edge id, and the old LaunchAgents load again. Loses only what was written after the switch.
|
|
271
|
+
*/
|
|
272
|
+
async rollback({ reason = 'asked' } = {}) {
|
|
273
|
+
const journal = await this.journal();
|
|
274
|
+
const report = { command: 'cutover', rollback: true, reason, steps: [] };
|
|
275
|
+
const step = (name, detail = {}) => { report.steps.push({ name, ...detail }); this.log('rollback_step', { name, ...detail }); };
|
|
276
|
+
const config = await this.#config();
|
|
277
|
+
if (config.cloudFirst?.mode === 'runner') {
|
|
278
|
+
await writeOwnerOnlyJson(this.configFile, { ...config, cloudFirst: { ...config.cloudFirst, mode: 'rolled_back' } });
|
|
279
|
+
const deadline = this.now() + this.facadeWaitMs;
|
|
280
|
+
while (await this.daemon.facadeUp(this.port)) {
|
|
281
|
+
if (this.now() >= deadline) throw new HubError('HUB_ROLLBACK_FACADE', `the Pulse daemon still serves 127.0.0.1:${this.port}`);
|
|
282
|
+
await this.wait(this.pollMs);
|
|
283
|
+
}
|
|
284
|
+
step('daemon_left_port');
|
|
285
|
+
}
|
|
286
|
+
if (journal.cutoverAt) {
|
|
287
|
+
const lost = await this.#exportLost(journal.cutoverAt);
|
|
288
|
+
report.lost = lost;
|
|
289
|
+
step('lost_exported', lost);
|
|
290
|
+
}
|
|
291
|
+
const marked = await this.#markSeedRolledBack();
|
|
292
|
+
if (marked) step('runner_db_marked', marked);
|
|
293
|
+
await restoreClients({ hubHome: this.hubHome });
|
|
294
|
+
step('entry_points_restored');
|
|
295
|
+
if (journal.steps?.backed_up && (await stat(this.backupFile).catch(() => null))) {
|
|
296
|
+
for (const suffix of ['-wal', '-shm']) await rm(`${this.hubDb}${suffix}`, { force: true });
|
|
297
|
+
const restored = `${this.hubDb}.pulse-restore`;
|
|
298
|
+
await copyFile(this.backupFile, restored);
|
|
299
|
+
await chmod(restored, 0o600);
|
|
300
|
+
await rm(this.hubDb, { force: true });
|
|
301
|
+
await copyFile(restored, this.hubDb);
|
|
302
|
+
await rm(restored, { force: true });
|
|
303
|
+
step('hub_db_restored', { from: this.backupFile, sha256: await sha256File(this.backupFile) });
|
|
304
|
+
}
|
|
305
|
+
const spool = await this.applySpool(this.hubDb);
|
|
306
|
+
step('spool_applied', spool);
|
|
307
|
+
const loadedBefore = journal.loadedBefore?.length ? journal.loadedBefore : this.labels;
|
|
308
|
+
for (const label of loadedBefore) await this.services.restoreLabel(label);
|
|
309
|
+
step('hub_loaded', { labels: loadedBefore });
|
|
310
|
+
const deadline = this.now() + this.facadeWaitMs;
|
|
311
|
+
while (!(await this.daemon.oldHubUp(this.port, { timeoutMs: ROLLBACK_PROBE_MS }))) {
|
|
312
|
+
if (this.now() >= deadline) {
|
|
313
|
+
const onPort = await this.#oldHubOnPort();
|
|
314
|
+
throw new HubError('HUB_ROLLBACK_HUB', onPort.hung
|
|
315
|
+
? `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`
|
|
316
|
+
: `the local hub did not come back on 127.0.0.1:${this.port}: ${onPort.detail}`);
|
|
317
|
+
}
|
|
318
|
+
await this.wait(this.pollMs);
|
|
319
|
+
}
|
|
320
|
+
step('hub_serving');
|
|
321
|
+
report.message = report.lost?.count
|
|
322
|
+
? `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}.`
|
|
323
|
+
: 'Rolled back to the local hub. Nothing written after the switch was lost.';
|
|
324
|
+
await writeOwnerOnlyJson(this.journalFile, { ...journal, state: 'rolled_back', rolledBackAt: new Date(this.now()).toISOString(), rollback: report });
|
|
325
|
+
return report;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* After the runner left the port: the seed is marked rolled back, so the next cutover replaces it with its own
|
|
330
|
+
* backup instead of serving this attempt's state (B1). Null when runner.db was never seeded.
|
|
331
|
+
*/
|
|
332
|
+
async #markSeedRolledBack() {
|
|
333
|
+
const seed = this.#seed();
|
|
334
|
+
if (!seed) return null;
|
|
335
|
+
const rolledBackAt = seed.rolledBackAt || new Date(this.now()).toISOString();
|
|
336
|
+
const store = await RunnerStore.open(this.configDir);
|
|
337
|
+
try {
|
|
338
|
+
store.setMeta(SEED_META, { ...seed, rolledBackAt });
|
|
339
|
+
} finally {
|
|
340
|
+
store.close();
|
|
341
|
+
}
|
|
342
|
+
return { sha256: seed.sha256 ?? null, rolledBackAt };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* What runner.db holds that was written after the switch: it doesn't reach hub.db, so it's listed for the user.
|
|
347
|
+
* Messages, acks, delivery receipts and registrations (M2), in time order.
|
|
348
|
+
*/
|
|
349
|
+
async #exportLost(cutoverAt) {
|
|
350
|
+
const store = RunnerStore.openReadOnly(this.configDir);
|
|
351
|
+
if (!store) return { count: 0, kinds: {}, file: null };
|
|
352
|
+
const rows = [];
|
|
353
|
+
try {
|
|
354
|
+
const has = (name) => store.db.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
|
|
355
|
+
const all = (kind, sql, ...args) => rows.push(...store.db.prepare(sql).all(...args).map((row) => ({ kind, ...row })));
|
|
356
|
+
if (has('messages')) all('message', 'SELECT id, sender, topic, type, body, created_at AS at FROM messages WHERE created_at >= ?', cutoverAt);
|
|
357
|
+
if (has('deliveries')) {
|
|
358
|
+
all('ack', 'SELECT message_id, recipient, acked_at AS at FROM deliveries WHERE acked_at >= ?', cutoverAt);
|
|
359
|
+
all('delivered', `SELECT message_id, recipient, delivered_via AS via, delivered_at AS at FROM deliveries
|
|
360
|
+
WHERE delivered_at >= ? AND NOT COALESCE(acked_at >= ?, 0)`, cutoverAt, cutoverAt);
|
|
361
|
+
}
|
|
362
|
+
if (has('actors') && has('pulse_registered')) {
|
|
363
|
+
all('registration', `SELECT a.id, a.role, a.node_type, a.topics, COALESCE(r.at, a.started_at) AS at
|
|
364
|
+
FROM actors a LEFT JOIN pulse_registered r ON r.id = a.id WHERE a.started_at >= ? OR r.at >= ?`, cutoverAt, cutoverAt);
|
|
365
|
+
}
|
|
366
|
+
} finally {
|
|
367
|
+
store.close();
|
|
368
|
+
}
|
|
369
|
+
rows.sort((a, b) => Number(a.at) - Number(b.at));
|
|
370
|
+
const kinds = {};
|
|
371
|
+
for (const row of rows) kinds[row.kind] = (kinds[row.kind] || 0) + 1;
|
|
372
|
+
const file = path.join(this.configDir, `rollback-lost-${this.now()}.jsonl`);
|
|
373
|
+
await writeFile(file, rows.map((row) => JSON.stringify(row)).join('\n') + (rows.length ? '\n' : ''), { mode: 0o600 });
|
|
374
|
+
return { count: rows.length, kinds, file };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { CloudFirstCommands } from './cloudFirstCommands.js';
|
|
6
|
+
import { Cutover } from './cutover.js';
|
|
7
|
+
import { HubError } from './errors.js';
|
|
8
|
+
import { discoverLocalHub } from './localHub.js';
|
|
9
|
+
import { ServiceManager } from './serviceManager.js';
|
|
10
|
+
import { defaultSecretStore } from './secretStore.js';
|
|
11
|
+
import { backupDatabase } from './sqlite.js';
|
|
12
|
+
import { readOwnerOnlyJson } from './storage.js';
|
|
13
|
+
import { execFileBounded, spawnBounded } from './subprocess.js';
|
|
14
|
+
import { TokenStore } from './tokenStore.js';
|
|
15
|
+
|
|
16
|
+
const RUNNER = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'runner');
|
|
17
|
+
const LSOF = '/usr/sbin/lsof';
|
|
18
|
+
|
|
19
|
+
async function hubToken(hubHome) {
|
|
20
|
+
return (await readFile(path.join(hubHome, 'state', 'hub.token'), 'utf8')).trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function local(port, route, token, { method = 'GET', body = null, timeoutMs = 3_000 } = {}) {
|
|
24
|
+
const res = await fetch(`http://127.0.0.1:${port}${route}`, {
|
|
25
|
+
method, signal: AbortSignal.timeout(timeoutMs),
|
|
26
|
+
headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
27
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
28
|
+
});
|
|
29
|
+
return { status: res.status, body: await res.json().catch(() => null) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Who has these files open, by `lsof -t`. Exit 1 with no output means nobody; anything else unexpected is unknown. */
|
|
33
|
+
export async function lsofHolders(files, { run = execFileBounded } = {}) {
|
|
34
|
+
try {
|
|
35
|
+
const { stdout } = await run(LSOF, ['-t', ...files], { timeout: 10_000 });
|
|
36
|
+
return { pids: String(stdout).split(/\s+/).filter(Boolean).map(Number).filter(Number.isInteger) };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.code === 1 && !String(error.stdout || '').trim()) return { pids: [] };
|
|
39
|
+
return { error: String(error.message || error).slice(0, 200) };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Who owns the LISTEN socket on 127.0.0.1:port, each with its parent, by `lsof -FpR` (ps is not allowed in an agent's
|
|
45
|
+
* sandbox; lsof is). Exit 1 with no output means nobody; anything else unexpected is unknown.
|
|
46
|
+
*/
|
|
47
|
+
export async function lsofPortOwners(port, { run = execFileBounded } = {}) {
|
|
48
|
+
try {
|
|
49
|
+
const { stdout } = await run(LSOF, ['-nP', `-iTCP@127.0.0.1:${Number(port)}`, '-sTCP:LISTEN', '-FpR'], { timeout: 10_000 });
|
|
50
|
+
const owners = [];
|
|
51
|
+
for (const line of String(stdout).split('\n')) {
|
|
52
|
+
const value = Number(line.slice(1));
|
|
53
|
+
if (!Number.isInteger(value)) continue;
|
|
54
|
+
if (line.startsWith('p') && !owners.some((o) => o.pid === value)) owners.push({ pid: value, ppid: null });
|
|
55
|
+
else if (line.startsWith('R') && owners.length) owners.at(-1).ppid ??= value;
|
|
56
|
+
}
|
|
57
|
+
return { owners };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error.code === 1 && !String(error.stdout || '').trim()) return { owners: [] };
|
|
60
|
+
return { error: String(error.message || error).slice(0, 200) };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* After the switch: `bus ask` to the manager through the installed shim reaches the runner, a hook delivers a probe
|
|
66
|
+
* message, and (with a channel) one post lands through the facade. Nothing here talks to hub.db.
|
|
67
|
+
*/
|
|
68
|
+
export async function smokeTest({ hubHome, port, channel = null, run = spawnBounded, node = process.execPath }) {
|
|
69
|
+
const token = await hubToken(hubHome);
|
|
70
|
+
const probe = `cutover-probe-${randomBytes(4).toString('hex')}`;
|
|
71
|
+
const cli = (...argv) => run(node, [path.join(hubHome, 'hub.mjs'), ...argv], { timeoutMs: 15_000 }).then((r) => JSON.parse(r.stdout));
|
|
72
|
+
const checks = [];
|
|
73
|
+
const ask = await cli('bus', 'ask', '--as', probe, '--topic', 'manager', 'cutover smoke test: no answer needed');
|
|
74
|
+
if (ask.spooled || !ask.to?.includes('manager')) return { ok: false, reason: `bus ask did not reach the runner: ${JSON.stringify(ask).slice(0, 200)}` };
|
|
75
|
+
checks.push('ask');
|
|
76
|
+
await cli('bus', 'register', '--as', probe, '--role', 'CHECK');
|
|
77
|
+
// The bus refuses mail to an address it has never seen, so the probe's first hook makes it a session; the second shows the mail.
|
|
78
|
+
const hook = () => run(node, [path.join(hubHome, 'hooks', 'hub-bridge.mjs')], {
|
|
79
|
+
timeoutMs: 5_000, input: JSON.stringify({ hook_event_name: 'postToolUse', conversation_id: probe, tool_name: 'Read' }),
|
|
80
|
+
});
|
|
81
|
+
await hook();
|
|
82
|
+
const sent = await cli('bus', 'publish', '--as', 'user', '--topic', `agent/${probe}`, 'cutover hook check');
|
|
83
|
+
if (!sent.id) return { ok: false, reason: `the probe message was refused: ${JSON.stringify(sent).slice(0, 200)}` };
|
|
84
|
+
if (!String((await hook()).stdout).includes(sent.id)) return { ok: false, reason: 'the hook did not deliver the probe message' };
|
|
85
|
+
checks.push('hook');
|
|
86
|
+
if (channel) {
|
|
87
|
+
const posted = await local(port, '/api/chat/post', token, { method: 'POST', body: { ch: `task:${channel}`, text: 'Cutover smoke test: Pulse now serves this hub.' } });
|
|
88
|
+
if (posted.status !== 200) return { ok: false, reason: `the channel post failed: ${posted.status}` };
|
|
89
|
+
checks.push('channel');
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, checks, probe };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Who answers on the port: the Pulse facade (it has /runner/health) or the old hub (it doesn't, and serves the API).
|
|
96
|
+
* `timeoutMs` bounds each request; rollback waits longer for a hub that is busy right after it loads.
|
|
97
|
+
*/
|
|
98
|
+
export function portProbes(tokenFor) {
|
|
99
|
+
return {
|
|
100
|
+
facadeUp: async (p) => {
|
|
101
|
+
try { return (await local(p, '/runner/health', await tokenFor())).status === 200; } catch { return false; }
|
|
102
|
+
},
|
|
103
|
+
oldHubUp: async (p, { timeoutMs = 3_000 } = {}) => {
|
|
104
|
+
try {
|
|
105
|
+
const token = await tokenFor();
|
|
106
|
+
return (await local(p, '/runner/health', token, { timeoutMs })).status === 404 && (await local(p, '/api/settings', token, { timeoutMs })).status === 200;
|
|
107
|
+
} catch { return false; }
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The production seams of `pulse cutover`: launchd, lsof, the Phase 2 import and reconcile, and the local port. */
|
|
113
|
+
export async function createCutover({ configDir, log = () => {}, port = 8890 }) {
|
|
114
|
+
const config = await readOwnerOnlyJson(path.join(configDir, 'config.json'));
|
|
115
|
+
if (!config) throw new HubError('HUB_NOT_INSTALLED', 'Pulse is not installed here; run `agentpulse pulse install`');
|
|
116
|
+
const hubHome = config.cloudFirst?.hubHome || config.mirror?.hub?.dir || (await discoverLocalHub({ config })).dir;
|
|
117
|
+
const tokenFor = () => hubToken(hubHome);
|
|
118
|
+
return new Cutover({
|
|
119
|
+
configDir, hubHome, port, log,
|
|
120
|
+
services: new ServiceManager({}),
|
|
121
|
+
probe: { holders: (files) => lsofHolders(files), portOwners: (p) => lsofPortOwners(p) },
|
|
122
|
+
cloudFirst: new CloudFirstCommands({ configDir }),
|
|
123
|
+
daemon: portProbes(tokenFor),
|
|
124
|
+
smoke: (options) => smokeTest(options),
|
|
125
|
+
backupDb: backupDatabase,
|
|
126
|
+
applySpool: (file) => spoolIntoHubDb(file, { hubHome }),
|
|
127
|
+
signIn: new TokenStore({ secretStore: defaultSecretStore(), configDir }),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Rollback's spool step: the freeze's spooled writes applied to the restored hub.db, in a child running the hub core. */
|
|
132
|
+
export async function spoolIntoHubDb(file, { hubHome, env = {} }) {
|
|
133
|
+
const { stdout } = await execFileBounded(process.execPath, [path.join(RUNNER, 'spoolInto.js'), file], {
|
|
134
|
+
timeout: 120_000, env: { ...process.env, ...env, PULSE_HUB_HOME: hubHome },
|
|
135
|
+
});
|
|
136
|
+
return JSON.parse(stdout);
|
|
137
|
+
}
|
package/src/lib/daemonLock.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { HubError } from './errors.js';
|
|
3
3
|
import { acquireOwnerLock } from './ownerLock.js';
|
|
4
|
+
import { CONNECTOR_VERSION } from './pairingLifecycle.js';
|
|
4
5
|
import { ProcessIdentityProvider } from './processOwnership.js';
|
|
5
6
|
import { ensureOwnerOnlyDirectory, writeOwnerOnlyJson } from './storage.js';
|
|
6
7
|
|
|
@@ -41,6 +42,7 @@ export class DaemonLock {
|
|
|
41
42
|
pid: this.pid,
|
|
42
43
|
heartbeatAt: new Date().toISOString(),
|
|
43
44
|
...status,
|
|
45
|
+
connectorVersion: CONNECTOR_VERSION,
|
|
44
46
|
});
|
|
45
47
|
}
|
|
46
48
|
|
package/src/lib/daemonLoop.js
CHANGED
|
@@ -10,7 +10,7 @@ export const MIRROR_UPDATE_STEP = 'mirror_update';
|
|
|
10
10
|
* adds fields such as the restart state to every health write.
|
|
11
11
|
*/
|
|
12
12
|
export function createHeartbeat({
|
|
13
|
-
readConfig, runtime, mirror, lock, ledger, ownerStore, progress = () => {}, extraHealth = () => ({}),
|
|
13
|
+
readConfig, runtime, mirror, shadow = null, runner = null, lock, ledger, ownerStore, progress = () => {}, extraHealth = () => ({}),
|
|
14
14
|
}) {
|
|
15
15
|
let last = null;
|
|
16
16
|
const health = (config, status) => ({
|
|
@@ -56,7 +56,10 @@ export function createHeartbeat({
|
|
|
56
56
|
}
|
|
57
57
|
progress(MIRROR_UPDATE_STEP);
|
|
58
58
|
// A cloud that refused the contract would refuse every batch too, so the mirror waits for the next session.
|
|
59
|
-
|
|
59
|
+
const usable = !(TERMINAL_STATES.has(status.state) || status.code === 'contract_unsupported');
|
|
60
|
+
await mirror.update(usable ? config : null);
|
|
61
|
+
shadow?.update(usable ? config : null);
|
|
62
|
+
runner?.update(usable ? config : null);
|
|
60
63
|
progress('health');
|
|
61
64
|
last = { config, status };
|
|
62
65
|
await write();
|
package/src/lib/daemonProcess.js
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
Watchdog,
|
|
18
18
|
watchSource,
|
|
19
19
|
} from './selfRestart.js';
|
|
20
|
+
import { ShadowService } from './shadowService.js';
|
|
21
|
+
import { RunnerService } from '../runner/runnerService.js';
|
|
20
22
|
import { readOwnerOnlyJson } from './storage.js';
|
|
21
23
|
|
|
22
24
|
// The longest a healthy cycle goes between two steps: a transport retry backoff (capped at 60 s) or a held claim (35 s).
|
|
@@ -26,6 +28,8 @@ const CYCLE_INTERVAL_MS = 60_000;
|
|
|
26
28
|
const MIRROR_INTERVAL_MS = 60_000;
|
|
27
29
|
const STALL_FACTOR = 3;
|
|
28
30
|
const MIRROR_LOOPS = ['mirror', 'mirror windows'];
|
|
31
|
+
// The shadow feed's calls run on their own timer; they never count as progress of the job cycle.
|
|
32
|
+
const SHADOW_PATHS = ['/commands', '/cloud-first', '/parity'];
|
|
29
33
|
/** The failure code of a job a restart had to abort: a drain that ran out of time, or a stalled loop. */
|
|
30
34
|
export const RESTART_ABORT_CODE = 'HUB_DAEMON_RESTARTING';
|
|
31
35
|
const DEFAULT_TIMING = Object.freeze({
|
|
@@ -68,6 +72,8 @@ export async function runDaemon({
|
|
|
68
72
|
signals = process,
|
|
69
73
|
createDependencies = createProductionDependencies,
|
|
70
74
|
createMirror = (options) => new MirrorController(options),
|
|
75
|
+
createShadow = (options) => new ShadowService(options),
|
|
76
|
+
createRunner = (options) => new RunnerService(options),
|
|
71
77
|
dependencyOptions = {},
|
|
72
78
|
runtimeOptions = {},
|
|
73
79
|
lockOptions = {},
|
|
@@ -91,6 +97,8 @@ export async function runDaemon({
|
|
|
91
97
|
let failure = null;
|
|
92
98
|
let heartbeat = null;
|
|
93
99
|
let runtime = null;
|
|
100
|
+
let shadow = null;
|
|
101
|
+
let runnerService = null;
|
|
94
102
|
let stopServices = async () => {};
|
|
95
103
|
let timer = null;
|
|
96
104
|
let watcher = null;
|
|
@@ -212,6 +220,8 @@ export async function runDaemon({
|
|
|
212
220
|
return {
|
|
213
221
|
...(loop ? { degraded: loop } : {}),
|
|
214
222
|
...(restarting ? { restarting: { ...restarting, draining: runtime?.activeJobIds() ?? [] } } : {}),
|
|
223
|
+
...(shadow ? { shadow: shadow.health() } : {}),
|
|
224
|
+
...(runnerService ? { runner: runnerService.health() } : {}),
|
|
215
225
|
};
|
|
216
226
|
};
|
|
217
227
|
|
|
@@ -277,7 +287,7 @@ export async function runDaemon({
|
|
|
277
287
|
onTransportAttempt: ({ path: requestPath, phase }) => {
|
|
278
288
|
const call = `${phase} ${requestPath}`;
|
|
279
289
|
if (requestPath === MIRROR_PATH) mirrorProgress(call);
|
|
280
|
-
else watchdog.progress('cycle', { call });
|
|
290
|
+
else if (!SHADOW_PATHS.some((suffix) => requestPath.split('?')[0].endsWith(suffix))) watchdog.progress('cycle', { call });
|
|
281
291
|
},
|
|
282
292
|
...dependencyOptions,
|
|
283
293
|
});
|
|
@@ -312,10 +322,14 @@ export async function runDaemon({
|
|
|
312
322
|
},
|
|
313
323
|
},
|
|
314
324
|
});
|
|
325
|
+
shadow = createShadow({ directory, transportFactory: production.transportFactory, tokenStore: production.tokenStore, log: mirrorLog });
|
|
326
|
+
runnerService = createRunner({ directory, transportFactory: production.transportFactory, log });
|
|
315
327
|
heartbeat = createHeartbeat({
|
|
316
328
|
readConfig: () => readOwnerOnlyJson(configFile),
|
|
317
329
|
runtime,
|
|
318
330
|
mirror,
|
|
331
|
+
shadow,
|
|
332
|
+
runner: runnerService,
|
|
319
333
|
lock,
|
|
320
334
|
ledger,
|
|
321
335
|
ownerStore,
|
|
@@ -323,7 +337,7 @@ export async function runDaemon({
|
|
|
323
337
|
extraHealth,
|
|
324
338
|
});
|
|
325
339
|
stopServices = async ({ abortCode }) => {
|
|
326
|
-
const results = await Promise.allSettled([mirror.stop(), runtime.shutdown({ abortCode })]);
|
|
340
|
+
const results = await Promise.allSettled([mirror.stop(), shadow.stop(), runnerService.stop(), runtime.shutdown({ abortCode })]);
|
|
327
341
|
const failed = results.find((result) => result.status === 'rejected');
|
|
328
342
|
if (failed) throw failed.reason;
|
|
329
343
|
};
|
package/src/lib/daemonRuntime.js
CHANGED
|
@@ -49,6 +49,8 @@ const SETTLE_GIVE_UP_MS = 30_000;
|
|
|
49
49
|
// 20 s Retry-After. An open session is reused, so these two set the steady call rate.
|
|
50
50
|
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
51
51
|
const CLAIM_SPACING_MS = 10_000;
|
|
52
|
+
// With claims off a cycle is only the heartbeat, so it runs at the heartbeat's own pace.
|
|
53
|
+
const CLAIMS_OFF_WAIT_MS = HEARTBEAT_INTERVAL_MS;
|
|
52
54
|
// A cloud that refuses this connector's contract version is asked again this late, or at the next daemon start.
|
|
53
55
|
export const CONTRACT_RETRY_MS = 60 * 60 * 1_000;
|
|
54
56
|
|
|
@@ -610,6 +612,12 @@ export class DaemonRuntime {
|
|
|
610
612
|
await this.#flush(transport);
|
|
611
613
|
if (this.stopping) return { state: 'stopping', processed: 0 };
|
|
612
614
|
if (this.draining) return { state: 'draining', processed: 0 };
|
|
615
|
+
// `jobs.claim: false` keeps the session, heartbeat and mirror but takes no work, so a test connector beside the
|
|
616
|
+
// live hub can never run a hub.* job into it.
|
|
617
|
+
if (this.config.jobs?.claim === false) {
|
|
618
|
+
this.state = 'running';
|
|
619
|
+
return { state: this.state, processed: 0, claims: 'off', retryAfterMs: CLAIMS_OFF_WAIT_MS };
|
|
620
|
+
}
|
|
613
621
|
this.progress('claim');
|
|
614
622
|
const claimedAt = this.now();
|
|
615
623
|
const claimed = await transport.poll({
|