@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.
- package/README.md +55 -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 +63 -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 +20 -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 +411 -0
- package/src/lib/cutoverRequest.js +485 -0
- package/src/lib/cutoverWiring.js +140 -0
- package/src/lib/daemonLock.js +2 -0
- package/src/lib/daemonLoop.js +5 -2
- package/src/lib/daemonProcess.js +57 -6
- 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 +64 -1
- 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 +149 -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,485 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, link, open, rename, rm, unlink } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
5
|
+
import { CUTOVER_FILE, STEPS } from './cutover.js';
|
|
6
|
+
import { HubError } from './errors.js';
|
|
7
|
+
import { readOwnerOnlyJson, writeOwnerOnlyJson } from './storage.js';
|
|
8
|
+
|
|
9
|
+
export const CUTOVER_REQUEST_FILE = 'cutover.request';
|
|
10
|
+
/** A request the daemon renamed off the disk to read it; one a crash leaves behind is refused on start, never run. */
|
|
11
|
+
export const CUTOVER_CLAIMED_FILE = 'cutover.request.claimed';
|
|
12
|
+
/** The request protocol a daemon serves, in every heartbeat; a daemon without it gets the cutover run in-process. */
|
|
13
|
+
export const CUTOVER_PROTOCOL = 1;
|
|
14
|
+
export const REQUEST_STALE_MS = 10 * 60_000;
|
|
15
|
+
/** The exit code of a follow that cannot tell how the request ended (timed out, or the daemon stopped). */
|
|
16
|
+
export const FOLLOW_UNKNOWN_EXIT = 2;
|
|
17
|
+
const FUTURE_SLACK_MS = 60_000;
|
|
18
|
+
const HEALTH_FRESH_MS = 60_000;
|
|
19
|
+
const MODES = new Set(['cutover', 'rollback']);
|
|
20
|
+
const SMOKE_CHANNEL = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
|
|
21
|
+
const HISTORY = 50;
|
|
22
|
+
const TICK_MS = 2_000;
|
|
23
|
+
const ACCEPT_MS = 2 * 60_000;
|
|
24
|
+
// The worst case of one request: holders (2 min), the final import (its 60 min wait), the reconcile (10 min), the
|
|
25
|
+
// facade (90 s) and smoke, then the rollback that follows a failure (facade 90 s, spool 2 min, hub 90 s): about 80 min.
|
|
26
|
+
const FOLLOW_MS = 2 * 3_600_000;
|
|
27
|
+
const POLL_MS = 1_000;
|
|
28
|
+
|
|
29
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
30
|
+
const atOrAfter = (when, since) => Date.parse(when) >= Date.parse(since);
|
|
31
|
+
|
|
32
|
+
function pidAlive(pid) {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
return true;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return error.code === 'EPERM';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Rollback steps from which the old hub may have hub.db open: a rollback that reached one is never run again. */
|
|
42
|
+
const RELOADING = new Set(['hub_loading', 'hub_loaded', 'hub_serving']);
|
|
43
|
+
|
|
44
|
+
/** The steps of the rollback journaled since `since` (this request's), by name. */
|
|
45
|
+
function rollbackProgress(journal, since) {
|
|
46
|
+
const rollback = journal.rollback;
|
|
47
|
+
return rollback?.startedAt && atOrAfter(rollback.startedAt, since) ? rollback.steps.map((entry) => entry.name) : [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The health state a finished request left. */
|
|
51
|
+
function endState(request) {
|
|
52
|
+
const { outcome } = request;
|
|
53
|
+
if (outcome.state === 'done') return request.mode === 'cutover' ? 'serving' : 'rolled_back';
|
|
54
|
+
return outcome.rolledBack ? 'rolled_back' : 'failed';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The daemon's side of `pulse cutover` (CUT-03). It serves cutover.request on its own timer, beside the heartbeat, so
|
|
59
|
+
* the runner a cutover switches on can start while the cutover waits for it. One request runs at a time, in this
|
|
60
|
+
* process, with the real Cutover from `createCutover({ log })`, and only while `canTake()` (no restart is pending).
|
|
61
|
+
* A request is claimed by renaming it off the disk, checked, then recorded in cutover.json (`request`, and its id in
|
|
62
|
+
* `requests`) before the claimed file is deleted, so a crash never runs it twice; a stale, duplicate, malformed or
|
|
63
|
+
* pointless one is refused into `refusals`. On start, a request a crash cut off is finished as 1.1.2 would after a
|
|
64
|
+
* failure (the 20:05:57 DECISION): a cutover that had begun rolls back, a rollback runs again, unless either had begun
|
|
65
|
+
* loading the old hub (`hub_loading`): restoring hub.db under it would lose its writes, so boot only checks that the old
|
|
66
|
+
* hub holds the port and records rolled_back, or HUB_ROLLBACK_INTERRUPTED for a human. Each boot action is recorded in
|
|
67
|
+
* the journal's `recovery` and in health.
|
|
68
|
+
*/
|
|
69
|
+
export class CutoverService {
|
|
70
|
+
constructor({ directory, createCutover, canTake = () => true, now = Date.now, tickMs = TICK_MS, log = () => {} }) {
|
|
71
|
+
this.directory = directory;
|
|
72
|
+
this.createCutover = createCutover;
|
|
73
|
+
this.canTake = canTake;
|
|
74
|
+
this.now = now;
|
|
75
|
+
this.tickMs = tickMs;
|
|
76
|
+
this.log = log;
|
|
77
|
+
this.journalFile = path.join(directory, CUTOVER_FILE);
|
|
78
|
+
this.requestFile = path.join(directory, CUTOVER_REQUEST_FILE);
|
|
79
|
+
this.claimedFile = path.join(directory, CUTOVER_CLAIMED_FILE);
|
|
80
|
+
this.timer = null;
|
|
81
|
+
this.running = null;
|
|
82
|
+
this.recovered = false;
|
|
83
|
+
this.stopped = false;
|
|
84
|
+
this.active = false;
|
|
85
|
+
this.state = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
start() {
|
|
89
|
+
this.stopped = false;
|
|
90
|
+
this.#schedule(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Stops taking requests. A cutover running now is not waited for: one cut off is finished by the next start. */
|
|
94
|
+
async stop() {
|
|
95
|
+
this.stopped = true;
|
|
96
|
+
clearTimeout(this.timer);
|
|
97
|
+
this.timer = null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether a request is running: the daemon holds any restart until it ends. */
|
|
101
|
+
busy() {
|
|
102
|
+
return this.active;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `{ state, step, requestId }` for daemon-health.json, or null before any request. */
|
|
106
|
+
health() {
|
|
107
|
+
return this.state;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#schedule(waitMs) {
|
|
111
|
+
clearTimeout(this.timer);
|
|
112
|
+
if (this.stopped) return;
|
|
113
|
+
this.timer = setTimeout(() => {
|
|
114
|
+
this.timer = null;
|
|
115
|
+
void this.tick().finally(() => this.#schedule(this.tickMs));
|
|
116
|
+
}, waitMs);
|
|
117
|
+
this.timer.unref?.();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One pass: finish what a crash cut off (first pass only), then take one request if there is one. Never rejects. */
|
|
121
|
+
tick() {
|
|
122
|
+
this.running ??= this.#pass().finally(() => {
|
|
123
|
+
this.running = null;
|
|
124
|
+
});
|
|
125
|
+
return this.running;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async #pass() {
|
|
129
|
+
try {
|
|
130
|
+
if (!this.recovered) {
|
|
131
|
+
this.recovered = true;
|
|
132
|
+
await this.#recover();
|
|
133
|
+
}
|
|
134
|
+
if (this.stopped || !this.canTake()) return;
|
|
135
|
+
try {
|
|
136
|
+
await rename(this.requestFile, this.claimedFile);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error.code === 'ENOENT') return;
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
await this.#take();
|
|
142
|
+
} catch (error) {
|
|
143
|
+
this.log('cutover_service_failed', { code: error.code || 'HUB_CUTOVER_SERVICE_FAILED', message: String(error.message).slice(0, 200) });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async #journal() {
|
|
148
|
+
return (await readOwnerOnlyJson(this.journalFile)) || { state: 'none', steps: {} };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async #claimed() {
|
|
152
|
+
try {
|
|
153
|
+
return { request: await readOwnerOnlyJson(this.claimedFile) };
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return { request: null, unreadable: error.code === 'HUB_INVALID_CONFIG' ? 'not JSON' : `${error.code || 'unreadable'}: ${String(error.message).slice(0, 120)}` };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Why this request is refused, or null. */
|
|
160
|
+
#refusal(request, journal) {
|
|
161
|
+
if (!request || typeof request !== 'object') return 'invalid: not a JSON object';
|
|
162
|
+
if (typeof request.id !== 'string' || !/^[A-Za-z0-9._:-]{1,100}$/.test(request.id)) return 'invalid: no usable id';
|
|
163
|
+
if (!MODES.has(request.mode)) return `invalid: mode ${JSON.stringify(request.mode ?? null)} is neither cutover nor rollback`;
|
|
164
|
+
if (request.smokeChannel != null && !(typeof request.smokeChannel === 'string' && SMOKE_CHANNEL.test(request.smokeChannel))) {
|
|
165
|
+
return 'invalid: smokeChannel is not <repo>/<task>';
|
|
166
|
+
}
|
|
167
|
+
const at = Date.parse(request.requestedAt);
|
|
168
|
+
if (!Number.isFinite(at)) return 'invalid: no requestedAt';
|
|
169
|
+
if (at - this.now() > FUTURE_SLACK_MS) return `invalid: requestedAt ${request.requestedAt} is in the future`;
|
|
170
|
+
const ageMs = this.now() - at;
|
|
171
|
+
if (ageMs > REQUEST_STALE_MS) return `stale: requested ${Math.round(ageMs / 60_000)} min ago, more than ${REQUEST_STALE_MS / 60_000} min`;
|
|
172
|
+
if (journal.request?.id === request.id || (journal.requests || []).includes(request.id)) return `duplicate: request ${request.id} was already taken`;
|
|
173
|
+
const ended = journal.request?.outcome?.at;
|
|
174
|
+
if (ended && at < Date.parse(ended)) return `arrived while another request ran: requested ${request.requestedAt}, before request ${journal.request.id} ended at ${ended}`;
|
|
175
|
+
if (journal.state === 'running' && journal.runBy === 'cli' && Number.isInteger(journal.pid) && pidAlive(journal.pid)) {
|
|
176
|
+
return `busy: pid ${journal.pid} is running a cutover in-process (started ${journal.startedAt})`;
|
|
177
|
+
}
|
|
178
|
+
if (request.mode === 'rollback') {
|
|
179
|
+
if (journal.state === 'none' || journal.state === 'rolled_back') return `nothing to roll back: cutover.json is ${journal.state}`;
|
|
180
|
+
if ((journal.rollback?.steps || []).some((entry) => RELOADING.has(entry.name))) {
|
|
181
|
+
return 'a rollback already loaded the old hub again: another would restore hub.db under it; check it by hand';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async #take() {
|
|
188
|
+
const { request, unreadable } = await this.#claimed();
|
|
189
|
+
const journal = await this.#journal();
|
|
190
|
+
const reason = unreadable ? `invalid: ${unreadable}` : this.#refusal(request, journal);
|
|
191
|
+
if (reason) {
|
|
192
|
+
await this.#refuse(request, reason, journal);
|
|
193
|
+
await rm(this.claimedFile, { force: true });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// A restart requested while this was read: the request goes back for the next process, or is refused if it can't.
|
|
197
|
+
if (this.stopped || !this.canTake()) {
|
|
198
|
+
const back = await link(this.claimedFile, this.requestFile).then(() => true, () => false);
|
|
199
|
+
if (!back) await this.#refuse(request, 'the Pulse daemon is restarting; ask again once it is back', journal);
|
|
200
|
+
await rm(this.claimedFile, { force: true });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
this.active = true;
|
|
204
|
+
try {
|
|
205
|
+
const accepted = {
|
|
206
|
+
id: request.id, mode: request.mode, requestedAt: request.requestedAt, requestedBy: request.requestedBy ?? null,
|
|
207
|
+
...(request.mode === 'cutover' && request.smokeChannel ? { smokeChannel: request.smokeChannel } : {}),
|
|
208
|
+
acceptedAt: iso(this.now()),
|
|
209
|
+
};
|
|
210
|
+
await writeOwnerOnlyJson(this.journalFile, { ...journal, request: accepted, requests: [...(journal.requests || []), accepted.id].slice(-HISTORY) });
|
|
211
|
+
await rm(this.claimedFile, { force: true });
|
|
212
|
+
this.log('cutover_request_accepted', { id: accepted.id, mode: accepted.mode });
|
|
213
|
+
await this.#perform(accepted, accepted.mode);
|
|
214
|
+
} finally {
|
|
215
|
+
this.active = false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async #refuse(request, reason, journal) {
|
|
220
|
+
const refusal = {
|
|
221
|
+
id: typeof request?.id === 'string' ? request.id : null, mode: request?.mode ?? null, requestedAt: request?.requestedAt ?? null,
|
|
222
|
+
reason, at: iso(this.now()),
|
|
223
|
+
};
|
|
224
|
+
await writeOwnerOnlyJson(this.journalFile, { ...journal, refusals: [...(journal.refusals || []), refusal].slice(-HISTORY) });
|
|
225
|
+
this.state = { state: 'refused', requestId: refusal.id, reason };
|
|
226
|
+
this.log('cutover_request_refused', { id: refusal.id, reason });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Runs `action` (cutover or rollback) for an accepted request and records its outcome on the journal. `asOutcome`
|
|
231
|
+
* is what a rollback that finishes a cut-off cutover reports, rolled back, instead of a plain done; `recovered` names
|
|
232
|
+
* the boot action that started it, for health.
|
|
233
|
+
*/
|
|
234
|
+
async #perform(request, action, { asOutcome = null, recovered = null } = {}) {
|
|
235
|
+
const tag = recovered ? { recovered } : {};
|
|
236
|
+
this.state = { state: 'running', mode: action, step: null, requestId: request.id, since: iso(this.now()), ...tag };
|
|
237
|
+
const log = (event, details = {}) => {
|
|
238
|
+
if (event === 'cutover_step') this.state = { ...this.state, step: details.step };
|
|
239
|
+
if (event === 'rollback_step') this.state = { ...this.state, phase: 'rollback', step: details.name };
|
|
240
|
+
this.log(event, details);
|
|
241
|
+
};
|
|
242
|
+
// Health says how it ended before the journal does, so whoever sees the outcome sees the same in health.
|
|
243
|
+
try {
|
|
244
|
+
const cutover = await this.createCutover({ log });
|
|
245
|
+
if (action === 'cutover') {
|
|
246
|
+
await cutover.run({ smokeChannel: request.smokeChannel ?? null, request });
|
|
247
|
+
this.state = { state: 'serving', step: this.state.step, requestId: request.id, ...tag };
|
|
248
|
+
await this.#finish(request.id, { state: 'done' });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const report = await cutover.rollback({ reason: asOutcome?.code ?? 'asked' });
|
|
252
|
+
this.state = { state: 'rolled_back', step: this.state.step, requestId: request.id, ...tag };
|
|
253
|
+
await this.#finish(request.id, asOutcome ? { ...asOutcome, rolledBack: true } : { state: 'done', message: report.message });
|
|
254
|
+
} catch (error) {
|
|
255
|
+
const code = error.code || 'HUB_CUTOVER_FAILED';
|
|
256
|
+
this.state = { state: error.rollback ? 'rolled_back' : 'failed', step: this.state.step, requestId: request.id, code, ...tag };
|
|
257
|
+
this.log('cutover_request_failed', { id: request.id, code });
|
|
258
|
+
await this.#finish(request.id, {
|
|
259
|
+
state: 'failed', code, message: String(error.message).slice(0, 500), rolledBack: Boolean(error.rollback),
|
|
260
|
+
...(error.details?.checks ? { checks: error.details.checks } : {}),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Records the request's outcome on the journal, with `extra` journal fields (a boot recovery's). */
|
|
266
|
+
async #finish(id, outcome, extra = {}) {
|
|
267
|
+
const journal = await this.#journal();
|
|
268
|
+
const request = journal.request?.id === id ? journal.request : { id };
|
|
269
|
+
await writeOwnerOnlyJson(this.journalFile, { ...journal, ...extra, request: { ...request, outcome: { ...outcome, at: iso(this.now()) } } });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** A boot recovery that ends the request without running anything: the outcome and what boot did, in both files. */
|
|
273
|
+
async #recovered(request, action, outcome, state, extra = {}) {
|
|
274
|
+
const recovery = { at: iso(this.now()), requestId: request.id, action };
|
|
275
|
+
this.state = { state, requestId: request.id, recovered: action, ...(outcome.code ? { code: outcome.code } : {}) };
|
|
276
|
+
this.log('cutover_recovered', { id: request.id, action });
|
|
277
|
+
await this.#finish(request.id, outcome, { ...extra, recovery });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** On start: a claim a crash left is refused; a request that was running is finished (see the class). */
|
|
281
|
+
async #recover() {
|
|
282
|
+
const claimed = await this.#claimed();
|
|
283
|
+
if (claimed.request || claimed.unreadable) {
|
|
284
|
+
const journal = await this.#journal();
|
|
285
|
+
if (!(claimed.request && (journal.requests || []).includes(claimed.request.id))) {
|
|
286
|
+
await this.#refuse(claimed.request, 'interrupted: the daemon stopped while it was reading this request', journal);
|
|
287
|
+
}
|
|
288
|
+
await rm(this.claimedFile, { force: true });
|
|
289
|
+
}
|
|
290
|
+
const journal = await this.#journal();
|
|
291
|
+
const request = journal.request;
|
|
292
|
+
if (!request?.id) return;
|
|
293
|
+
if (request.outcome) {
|
|
294
|
+
this.state = { state: endState(request), requestId: request.id, ...(request.outcome.code ? { code: request.outcome.code } : {}) };
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const since = request.acceptedAt;
|
|
298
|
+
const began = Boolean(journal.startedAt) && atOrAfter(journal.startedAt, since);
|
|
299
|
+
const rolledBack = journal.state === 'rolled_back' && atOrAfter(journal.rolledBackAt, since);
|
|
300
|
+
const reloading = rollbackProgress(journal, since).some((name) => RELOADING.has(name));
|
|
301
|
+
const failure = journal.failure ?? { code: 'HUB_CUTOVER_INTERRUPTED', message: 'The Pulse daemon stopped while it ran this cutover.' };
|
|
302
|
+
// What a cutover that ended rolled back reports; a rollback request that ended so is simply done.
|
|
303
|
+
const rolledBackOutcome = (message) => (request.mode === 'rollback'
|
|
304
|
+
? { state: 'done', message }
|
|
305
|
+
: { state: 'failed', code: failure.code, message: failure.message, rolledBack: true });
|
|
306
|
+
this.active = true;
|
|
307
|
+
try {
|
|
308
|
+
if (request.mode === 'cutover' && journal.state === 'done' && began) return await this.#recovered(request, 'finished_before_stop', { state: 'done' }, 'serving');
|
|
309
|
+
if (rolledBack) return await this.#recovered(request, 'rolled_back_before_stop', rolledBackOutcome(journal.rollback?.message ?? null), 'rolled_back');
|
|
310
|
+
if (request.mode === 'cutover' && !(journal.state === 'running' && began)) {
|
|
311
|
+
return await this.#recovered(request, 'never_began', {
|
|
312
|
+
state: 'failed', code: 'HUB_CUTOVER_INTERRUPTED', message: 'The Pulse daemon stopped before this cutover began; nothing was changed.', rolledBack: false,
|
|
313
|
+
}, 'failed');
|
|
314
|
+
}
|
|
315
|
+
if (reloading) return await this.#verifyReloaded(request, journal, rolledBackOutcome);
|
|
316
|
+
if (request.mode === 'cutover') {
|
|
317
|
+
const cutFailure = journal.failure ?? {
|
|
318
|
+
step: STEPS.find((name) => !journal.steps?.[name]) ?? null, code: 'HUB_CUTOVER_INTERRUPTED',
|
|
319
|
+
message: 'The Pulse daemon stopped while it ran this cutover; it rolled back on start.',
|
|
320
|
+
};
|
|
321
|
+
const recovery = { at: iso(this.now()), requestId: request.id, action: 'rolled_back_on_start' };
|
|
322
|
+
await writeOwnerOnlyJson(this.journalFile, { ...journal, failure: cutFailure, recovery });
|
|
323
|
+
this.log('cutover_interrupted', { id: request.id, mode: 'cutover', step: cutFailure.step });
|
|
324
|
+
return await this.#perform(request, 'rollback', { asOutcome: { state: 'failed', code: 'HUB_CUTOVER_INTERRUPTED', message: cutFailure.message }, recovered: 'rolled_back_on_start' });
|
|
325
|
+
}
|
|
326
|
+
await writeOwnerOnlyJson(this.journalFile, { ...journal, recovery: { at: iso(this.now()), requestId: request.id, action: 'rollback_run_again' } });
|
|
327
|
+
this.log('cutover_interrupted', { id: request.id, mode: 'rollback' });
|
|
328
|
+
return await this.#perform(request, 'rollback', { recovered: 'rollback_run_again' });
|
|
329
|
+
} finally {
|
|
330
|
+
this.active = false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The 20:05:57 DECISION: a rollback that had begun loading the old hub is never run again (it would restore hub.db
|
|
336
|
+
* under the running hub). Boot only checks that the old hub holds the port, and records rolled_back if it does.
|
|
337
|
+
*/
|
|
338
|
+
async #verifyReloaded(request, journal, rolledBackOutcome) {
|
|
339
|
+
this.state = { state: 'running', mode: 'verify', step: 'hub_loading', requestId: request.id, since: iso(this.now()) };
|
|
340
|
+
let onPort;
|
|
341
|
+
try {
|
|
342
|
+
onPort = await (await this.createCutover({ log: this.log })).verifyRolledBack();
|
|
343
|
+
} catch (error) {
|
|
344
|
+
onPort = { ok: false, detail: `the check failed: ${error.code || 'error'}: ${String(error.message).slice(0, 200)}` };
|
|
345
|
+
}
|
|
346
|
+
if (onPort.ok) {
|
|
347
|
+
const at = iso(this.now());
|
|
348
|
+
const message = `Rolled back to the local hub: the Pulse daemon restarted after the old hub was loaded again, and found it on the port (${onPort.detail}).`;
|
|
349
|
+
return this.#recovered(request, 'verified_old_hub', rolledBackOutcome(message), 'rolled_back', {
|
|
350
|
+
state: 'rolled_back', rolledBackAt: at, rollback: { ...journal.rollback, recoveredOnBoot: true, verified: onPort.detail, message },
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return this.#recovered(request, 'old_hub_not_serving', {
|
|
354
|
+
state: 'failed', code: 'HUB_ROLLBACK_INTERRUPTED', rolledBack: false,
|
|
355
|
+
message: `The Pulse daemon stopped after its rollback began loading the old hub, and the old hub does not hold the port now (${onPort.detail}). It is not rolled back twice, which would restore hub.db under a hub that may have it open: check the hub by hand.`,
|
|
356
|
+
}, 'failed');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Whether the daemon behind this health file is alive and serves cutover.request. */
|
|
361
|
+
export function takesCutoverRequests(health, now = Date.now()) {
|
|
362
|
+
const ageMs = now - Date.parse(health?.heartbeatAt);
|
|
363
|
+
return ageMs <= HEALTH_FRESH_MS && Number(health?.cutoverProtocol) >= CUTOVER_PROTOCOL;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Creates `file` owner-only with `value`, whole or not at all, and only if it does not exist yet. */
|
|
367
|
+
async function createExclusive(file, value) {
|
|
368
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
369
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
370
|
+
try {
|
|
371
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
372
|
+
await handle.sync();
|
|
373
|
+
} finally {
|
|
374
|
+
await handle.close();
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
await chmod(temporary, 0o600);
|
|
378
|
+
await link(temporary, file);
|
|
379
|
+
return true;
|
|
380
|
+
} catch (error) {
|
|
381
|
+
if (error.code === 'EEXIST') return false;
|
|
382
|
+
throw error;
|
|
383
|
+
} finally {
|
|
384
|
+
await rm(temporary, { force: true });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Writes an owner-only cutover.request for the daemon, exclusively: one request at a time. */
|
|
389
|
+
export async function writeCutoverRequest({ configDir, mode, smokeChannel = null, health = null, now = Date.now, id = randomUUID(), requestedBy = null }) {
|
|
390
|
+
const pending = (detail) => new HubError('HUB_CUTOVER_PENDING', `Another cutover request is ${detail}; wait for it to finish (see ${path.join(configDir, CUTOVER_FILE)}).`);
|
|
391
|
+
if (health?.cutover?.state === 'running') throw pending(`running in the Pulse daemon (${health.cutover.requestId}${health.cutover.step ? `, at ${health.cutover.step}` : ''})`);
|
|
392
|
+
const request = {
|
|
393
|
+
id, mode, requestedAt: iso(now()),
|
|
394
|
+
requestedBy: requestedBy ?? { pid: process.pid, session: process.env.CURSOR_CONVERSATION_ID || null },
|
|
395
|
+
...(smokeChannel ? { smokeChannel } : {}),
|
|
396
|
+
};
|
|
397
|
+
if (!(await createExclusive(path.join(configDir, CUTOVER_REQUEST_FILE), request))) throw pending('waiting for the Pulse daemon');
|
|
398
|
+
return request;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Follows a request through cutover.json, printing each step as `log(event, details)` exactly as an in-process run
|
|
403
|
+
* logs it. It resolves with the journal (a cutover) or the rollback report, or throws: the journal's code for a run
|
|
404
|
+
* that failed (or rolled back), HUB_CUTOVER_REFUSED, or HUB_CUTOVER_NOT_ACCEPTED when nobody took it within
|
|
405
|
+
* `acceptMs` (it is withdrawn). It ends without an outcome, with `exit` FOLLOW_UNKNOWN_EXIT, when the daemon's heartbeat
|
|
406
|
+
* goes stale (it finishes the request when it starts again) or after `timeoutMs`, the worst case of a run.
|
|
407
|
+
*/
|
|
408
|
+
export async function followCutoverRequest({
|
|
409
|
+
configDir, request, log = () => {}, now = Date.now, wait = sleep, pollMs = POLL_MS, acceptMs = ACCEPT_MS, timeoutMs = FOLLOW_MS,
|
|
410
|
+
healthFreshMs = HEALTH_FRESH_MS,
|
|
411
|
+
}) {
|
|
412
|
+
const journalFile = path.join(configDir, CUTOVER_FILE);
|
|
413
|
+
const requestFile = path.join(configDir, CUTOVER_REQUEST_FILE);
|
|
414
|
+
const healthFile = path.join(configDir, 'daemon-health.json');
|
|
415
|
+
const started = now();
|
|
416
|
+
const printed = { steps: new Set(), failure: false, rollback: 0 };
|
|
417
|
+
let withDaemon = false;
|
|
418
|
+
const print = (journal) => {
|
|
419
|
+
const since = journal.request.acceptedAt;
|
|
420
|
+
if (request.mode === 'cutover' && journal.startedAt && atOrAfter(journal.startedAt, since)) {
|
|
421
|
+
for (const name of STEPS) {
|
|
422
|
+
if (!journal.steps?.[name] || printed.steps.has(name)) continue;
|
|
423
|
+
printed.steps.add(name);
|
|
424
|
+
const { at: _at, ...detail } = journal.steps[name];
|
|
425
|
+
log('cutover_step', { step: name, ...detail });
|
|
426
|
+
}
|
|
427
|
+
if (journal.failure && !printed.failure) {
|
|
428
|
+
printed.failure = true;
|
|
429
|
+
log('cutover_failed', journal.failure);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (rollbackProgress(journal, since).length) {
|
|
433
|
+
for (const entry of journal.rollback.steps.slice(printed.rollback)) log('rollback_step', entry);
|
|
434
|
+
printed.rollback = journal.rollback.steps.length;
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
const unknown = (code, message) => new HubError(code, message, { exit: FOLLOW_UNKNOWN_EXIT });
|
|
438
|
+
for (;;) {
|
|
439
|
+
const journal = await readOwnerOnlyJson(journalFile).catch(() => null);
|
|
440
|
+
const refused = journal?.refusals?.find((entry) => entry.id === request.id);
|
|
441
|
+
if (refused) throw new HubError('HUB_CUTOVER_REFUSED', `The Pulse daemon refused request ${request.id}: ${refused.reason}`, { details: { refused } });
|
|
442
|
+
const health = await readOwnerOnlyJson(healthFile).catch(() => null);
|
|
443
|
+
// A long synchronous stretch (a rescan) can delay a heartbeat; a daemon whose pid is alive gets three windows.
|
|
444
|
+
const ageMs = now() - Date.parse(health?.heartbeatAt);
|
|
445
|
+
const stale = !(ageMs <= healthFreshMs) && (!(ageMs <= 3 * healthFreshMs) || !(Number.isInteger(health?.pid) && pidAlive(health.pid)));
|
|
446
|
+
if (journal?.request?.id === request.id) {
|
|
447
|
+
print(journal);
|
|
448
|
+
const outcome = journal.request.outcome;
|
|
449
|
+
if (outcome?.state === 'done') return request.mode === 'cutover' ? journal : journal.rollback;
|
|
450
|
+
if (outcome) {
|
|
451
|
+
throw Object.assign(
|
|
452
|
+
new HubError(outcome.code || 'HUB_CUTOVER_FAILED', outcome.message || 'the cutover failed', { details: { ...(outcome.checks ? { checks: outcome.checks } : {}), rolledBack: Boolean(outcome.rolledBack) } }),
|
|
453
|
+
outcome.rolledBack ? { rollback: journal.rollback ?? null } : {},
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
if (stale) {
|
|
457
|
+
throw unknown('HUB_CUTOVER_DAEMON_STOPPED', `The Pulse daemon stopped (last heartbeat ${health?.heartbeatAt ?? 'never'}) while it ran request ${request.id}. When it starts again it finishes the request (a cutover that had begun rolls back); follow it in ${journalFile}.`);
|
|
458
|
+
}
|
|
459
|
+
} else if (!withDaemon && (stale || now() - started >= acceptMs)) {
|
|
460
|
+
// Withdrawn only while it is still ours on the disk: gone means the daemon claimed it, so it is followed.
|
|
461
|
+
const pending = await readOwnerOnlyJson(requestFile).catch(() => null);
|
|
462
|
+
let withdrawn = false;
|
|
463
|
+
if (pending?.id === request.id) {
|
|
464
|
+
try {
|
|
465
|
+
await unlink(requestFile);
|
|
466
|
+
withdrawn = true;
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if (error.code !== 'ENOENT') throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (withdrawn) {
|
|
472
|
+
throw new HubError('HUB_CUTOVER_NOT_ACCEPTED', stale
|
|
473
|
+
? `The Pulse daemon is not running (last heartbeat ${health?.heartbeatAt ?? 'never'}), so request ${request.id} was withdrawn. Start it with \`agentpulse pulse start\`.`
|
|
474
|
+
: `The Pulse daemon did not take request ${request.id} within ${Math.round(acceptMs / 1000)} s, so it was withdrawn. Check it with \`agentpulse pulse status\`.`);
|
|
475
|
+
}
|
|
476
|
+
withDaemon = true;
|
|
477
|
+
} else if (withDaemon && stale) {
|
|
478
|
+
throw unknown('HUB_CUTOVER_DAEMON_STOPPED', `The Pulse daemon stopped (last heartbeat ${health?.heartbeatAt ?? 'never'}) after it claimed request ${request.id}; when it starts again it refuses or finishes it. Follow it in ${journalFile}.`);
|
|
479
|
+
}
|
|
480
|
+
if (now() - started >= timeoutMs) {
|
|
481
|
+
throw unknown('HUB_CUTOVER_FOLLOW_TIMEOUT', `Request ${request.id} is still running in the Pulse daemon after ${Math.round(timeoutMs / 60_000)} min; follow it in ${journalFile} or with \`agentpulse pulse status\`.`);
|
|
482
|
+
}
|
|
483
|
+
await wait(pollMs);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
/**
|
|
113
|
+
* The production seams of `pulse cutover`: launchd, lsof, the Phase 2 import and reconcile, and the local port.
|
|
114
|
+
* `serve` is the daemon's own runner switch when the daemon runs it (CUT-03); in-process it is left to the heartbeat.
|
|
115
|
+
*/
|
|
116
|
+
export async function createCutover({ configDir, log = () => {}, port = 8890, serve = undefined }) {
|
|
117
|
+
const config = await readOwnerOnlyJson(path.join(configDir, 'config.json'));
|
|
118
|
+
if (!config) throw new HubError('HUB_NOT_INSTALLED', 'Pulse is not installed here; run `agentpulse pulse install`');
|
|
119
|
+
const hubHome = config.cloudFirst?.hubHome || config.mirror?.hub?.dir || (await discoverLocalHub({ config })).dir;
|
|
120
|
+
const tokenFor = () => hubToken(hubHome);
|
|
121
|
+
return new Cutover({
|
|
122
|
+
configDir, hubHome, port, log, serve,
|
|
123
|
+
services: new ServiceManager({}),
|
|
124
|
+
probe: { holders: (files) => lsofHolders(files), portOwners: (p) => lsofPortOwners(p) },
|
|
125
|
+
cloudFirst: new CloudFirstCommands({ configDir }),
|
|
126
|
+
daemon: portProbes(tokenFor),
|
|
127
|
+
smoke: (options) => smokeTest(options),
|
|
128
|
+
backupDb: backupDatabase,
|
|
129
|
+
applySpool: (file) => spoolIntoHubDb(file, { hubHome }),
|
|
130
|
+
signIn: new TokenStore({ secretStore: defaultSecretStore(), configDir }),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Rollback's spool step: the freeze's spooled writes applied to the restored hub.db, in a child running the hub core. */
|
|
135
|
+
export async function spoolIntoHubDb(file, { hubHome, env = {} }) {
|
|
136
|
+
const { stdout } = await execFileBounded(process.execPath, [path.join(RUNNER, 'spoolInto.js'), file], {
|
|
137
|
+
timeout: 120_000, env: { ...process.env, ...env, PULSE_HUB_HOME: hubHome },
|
|
138
|
+
});
|
|
139
|
+
return JSON.parse(stdout);
|
|
140
|
+
}
|
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();
|