@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,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runner's own tables in runner.db, beside the hub ledger and the Phase 2 outbox (design §6.2).
|
|
3
|
+
* - pulse_sync: the applied cursor, advanced only in the transaction that applies its events (I2).
|
|
4
|
+
* - pulse_events: what sync applied, skipped or set aside, kept 7 days.
|
|
5
|
+
* - pulse_quarantine: events that could not be applied; the cursor still moves past them (I1).
|
|
6
|
+
* - pulse_deferred: foreign changes to an entity whose own newer version is still in the outbox (M1).
|
|
7
|
+
* - pulse_spool: spool lines already imported, by content hash.
|
|
8
|
+
* - pulse_registered: when each address last registered through the facade, so an older spooled register yields.
|
|
9
|
+
* - pulse_rendered: remote posts appended to a channel.md: pending until the file and the ingest record both have it.
|
|
10
|
+
* - pulse_jobs_local: local effects (runs, manager turns) journaled before they start.
|
|
11
|
+
* - pulse_fence: deliveries held for an old-code listener still running at cutover.
|
|
12
|
+
* - Pulse 1.1.1: the bus identity tables (busIdentity.js) and the read, skipped and bounce records (deliveryStates.js).
|
|
13
|
+
* - Pulse 1.1.2: the posts each session wrote, open permission prompts and stale episodes (channelDiscipline.js).
|
|
14
|
+
*/
|
|
15
|
+
import { installBusIdentity } from './busIdentity.js';
|
|
16
|
+
import { installChannelDiscipline } from './channelDiscipline.js';
|
|
17
|
+
import { installDeliveryStates } from './deliveryStates.js';
|
|
18
|
+
|
|
19
|
+
const SCHEMA = [
|
|
20
|
+
`CREATE TABLE IF NOT EXISTS pulse_sync (
|
|
21
|
+
one INTEGER PRIMARY KEY CHECK (one = 1), applied_seq INTEGER NOT NULL DEFAULT 0, head INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
floor INTEGER NOT NULL DEFAULT 0, presence_stamp INTEGER NOT NULL DEFAULT 0, last_ok_at INTEGER, last_error TEXT,
|
|
23
|
+
resync TEXT)`,
|
|
24
|
+
'INSERT OR IGNORE INTO pulse_sync (one) VALUES (1)',
|
|
25
|
+
`CREATE TABLE IF NOT EXISTS pulse_events (
|
|
26
|
+
seq INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE, type TEXT NOT NULL, key TEXT NOT NULL, origin TEXT,
|
|
27
|
+
outcome TEXT NOT NULL, applied_at INTEGER NOT NULL)`,
|
|
28
|
+
'CREATE INDEX IF NOT EXISTS pulse_events_at ON pulse_events (applied_at)',
|
|
29
|
+
`CREATE TABLE IF NOT EXISTS pulse_quarantine (
|
|
30
|
+
seq INTEGER PRIMARY KEY, id TEXT, type TEXT, reason TEXT NOT NULL, payload TEXT, at INTEGER NOT NULL)`,
|
|
31
|
+
`CREATE TABLE IF NOT EXISTS pulse_deferred (
|
|
32
|
+
seq INTEGER PRIMARY KEY, grp TEXT NOT NULL, event TEXT NOT NULL, at INTEGER NOT NULL)`,
|
|
33
|
+
'CREATE INDEX IF NOT EXISTS pulse_deferred_grp ON pulse_deferred (grp, seq)',
|
|
34
|
+
'CREATE TABLE IF NOT EXISTS pulse_registered (id TEXT PRIMARY KEY, at INTEGER NOT NULL)',
|
|
35
|
+
'CREATE TABLE IF NOT EXISTS pulse_spool (sha256 TEXT PRIMARY KEY, file TEXT NOT NULL, line INTEGER NOT NULL, outcome TEXT NOT NULL, imported_at INTEGER NOT NULL)',
|
|
36
|
+
`CREATE TABLE IF NOT EXISTS pulse_rendered (
|
|
37
|
+
post_key TEXT PRIMARY KEY, channel TEXT NOT NULL, file TEXT, text TEXT NOT NULL, state TEXT NOT NULL, at INTEGER NOT NULL)`,
|
|
38
|
+
`CREATE TABLE IF NOT EXISTS pulse_jobs_local (
|
|
39
|
+
job_id TEXT PRIMARY KEY, kind TEXT NOT NULL, fence INTEGER, effect_started_at INTEGER, outcome TEXT,
|
|
40
|
+
settle_attempts INTEGER NOT NULL DEFAULT 0, settle_failed INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL)`,
|
|
41
|
+
`CREATE TABLE IF NOT EXISTS pulse_fence (
|
|
42
|
+
message_id TEXT NOT NULL, recipient TEXT NOT NULL, holder TEXT NOT NULL, since INTEGER NOT NULL,
|
|
43
|
+
PRIMARY KEY (message_id, recipient))`,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function installRunnerSchema(db) {
|
|
47
|
+
for (const sql of SCHEMA) db.prepare(sql).run();
|
|
48
|
+
installBusIdentity(db);
|
|
49
|
+
installDeliveryStates(db);
|
|
50
|
+
installChannelDiscipline(db);
|
|
51
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
5
|
+
import { HubDbReader } from '../lib/hubDbReader.js';
|
|
6
|
+
import { HubScan } from '../lib/hubScan.js';
|
|
7
|
+
import { pairedStatus } from '../lib/pairedConfig.js';
|
|
8
|
+
import { RUNNER_DB_FILE, RunnerStore } from '../lib/runnerStore.js';
|
|
9
|
+
import { channelRoots } from '../lib/shadowService.js';
|
|
10
|
+
import { UploadRedactor } from '../lib/uploadRedactor.js';
|
|
11
|
+
import { RunnerBridge } from './bridge.js';
|
|
12
|
+
import { forkRunner } from './runnerChild.js';
|
|
13
|
+
|
|
14
|
+
export const RUNNER_CONFIG_FILE = 'runner.json';
|
|
15
|
+
const RESTART_MIN_MS = 1_000;
|
|
16
|
+
const RESTART_MAX_MS = 30_000;
|
|
17
|
+
const SECRETS_EVERY_MS = 60_000;
|
|
18
|
+
|
|
19
|
+
/** Whether this machine has cut over: the daemon runs the hub (the runner) and serves the local API (design §11). */
|
|
20
|
+
export const runnerMode = (config) => config?.cloudFirst?.mode === 'runner';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The daemon's runner (cloud-first Phase 3): after cutover it starts the runner process (the hub core over runner.db
|
|
24
|
+
* and the facade on 127.0.0.1:8890), keeps it running, and bridges it to Pulse with the daemon's own credentials.
|
|
25
|
+
* `update(config)` is called with every heartbeat's config, as the mirror and the shadow are.
|
|
26
|
+
*/
|
|
27
|
+
export class RunnerService {
|
|
28
|
+
constructor({
|
|
29
|
+
directory, transportFactory, home = os.homedir(), fork = forkRunner, openStore = (dir) => RunnerStore.open(dir),
|
|
30
|
+
createBridge = (options) => new RunnerBridge(options), log = () => {}, now = Date.now,
|
|
31
|
+
}) {
|
|
32
|
+
this.directory = directory;
|
|
33
|
+
this.transportFactory = transportFactory;
|
|
34
|
+
this.home = home;
|
|
35
|
+
this.fork = fork;
|
|
36
|
+
this.openStore = openStore;
|
|
37
|
+
this.createBridge = createBridge;
|
|
38
|
+
this.log = log;
|
|
39
|
+
this.now = now;
|
|
40
|
+
this.key = null;
|
|
41
|
+
this.current = null;
|
|
42
|
+
this.restarts = 0;
|
|
43
|
+
this.state = { state: 'off' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Start, keep or stop the runner for this config. Never waits on the runner. */
|
|
47
|
+
update(config) {
|
|
48
|
+
const on = runnerMode(config) && pairedStatus(config).ok;
|
|
49
|
+
const key = on ? JSON.stringify({ c: config.cloudFirst, m: config.paired.machineId, v: config.paired.policyVersion }) : null;
|
|
50
|
+
if (key === this.key) return this.state;
|
|
51
|
+
this.key = key;
|
|
52
|
+
this.starting = (this.starting || Promise.resolve()).then(() => this.#stopCurrent()).then(() => (on ? this.#start(config) : null))
|
|
53
|
+
.catch((error) => {
|
|
54
|
+
this.state = { state: 'error', code: error.code || 'runner_start_failed', message: String(error.message).slice(0, 200) };
|
|
55
|
+
this.log('runner_start_failed', { code: this.state.code });
|
|
56
|
+
});
|
|
57
|
+
if (!on) this.state = { state: 'off' };
|
|
58
|
+
return this.state;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async stop() {
|
|
62
|
+
this.key = null;
|
|
63
|
+
await this.starting;
|
|
64
|
+
await this.#stopCurrent();
|
|
65
|
+
this.state = { state: 'off' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
health() {
|
|
69
|
+
const bridge = this.current?.bridge;
|
|
70
|
+
return { ...this.state, ...(bridge ? { runner: bridge.health(), lease: bridge.lease ?? null } : {}) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async #start(config) {
|
|
74
|
+
const { cloudFirst, paired } = config;
|
|
75
|
+
const hubHome = path.resolve(cloudFirst.hubHome);
|
|
76
|
+
const dbFile = path.join(this.directory, RUNNER_DB_FILE);
|
|
77
|
+
const roots = channelRoots(config, hubHome);
|
|
78
|
+
const approvedRoots = (config.localRoots || []).map((root) => root.canonicalRoot).filter(Boolean).sort();
|
|
79
|
+
const configFile = path.join(this.directory, RUNNER_CONFIG_FILE);
|
|
80
|
+
await writeFile(configFile, `${JSON.stringify({
|
|
81
|
+
port: cloudFirst.port ?? 8890, host: '127.0.0.1', mapping: 'hub', machineId: paired.machineId, cutoverSeq: Number(cloudFirst.cutoverSeq) || 0,
|
|
82
|
+
channelRoots: roots, approvedRoots, pulseUrl: cloudFirst.pulseUrl ?? null, enableCollector: true, enableManager: true,
|
|
83
|
+
seedFrom: cloudFirst.seedFrom ?? null, seedSha256: cloudFirst.seedSha256 ?? null,
|
|
84
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
85
|
+
const store = await this.openStore(this.directory);
|
|
86
|
+
const transport = await this.transportFactory(config);
|
|
87
|
+
const held = () => transport.auth?.heldSecrets?.() ?? [];
|
|
88
|
+
const current = { store, transport, stopping: false };
|
|
89
|
+
this.current = current;
|
|
90
|
+
const launch = async () => {
|
|
91
|
+
const runner = this.fork({ hubHome, dbFile, configFile });
|
|
92
|
+
runner.child.stderr?.on('data', (chunk) => this.log('runner_stderr', { line: String(chunk).slice(0, 300) }));
|
|
93
|
+
const ready = await runner.ready;
|
|
94
|
+
const hubToken = (await readFile(path.join(hubHome, 'state', 'hub.token'), 'utf8').catch(() => '')).trim() || null;
|
|
95
|
+
const fileScan = new HubScan({
|
|
96
|
+
store, reader: new HubDbReader({ hubDir: hubHome, file: dbFile }), hubDir: hubHome, roots,
|
|
97
|
+
redaction: new UploadRedactor({ workspace: path.dirname(hubHome), home: this.home, approvedRoots }),
|
|
98
|
+
secrets: () => [hubToken, ...held()],
|
|
99
|
+
});
|
|
100
|
+
const bridge = this.createBridge({ runner, transport, store, machineId: paired.machineId, fileScan, log: this.log });
|
|
101
|
+
bridge.start();
|
|
102
|
+
runner.send({ type: 'secrets', values: held() });
|
|
103
|
+
const secrets = setInterval(() => runner.send({ type: 'secrets', values: held() }), SECRETS_EVERY_MS);
|
|
104
|
+
secrets.unref?.();
|
|
105
|
+
current.runner = runner;
|
|
106
|
+
current.bridge = bridge;
|
|
107
|
+
current.secrets = secrets;
|
|
108
|
+
this.restarts = 0;
|
|
109
|
+
this.state = { state: 'running', port: ready.port, pid: ready.pid, since: new Date(this.now()).toISOString() };
|
|
110
|
+
this.log('runner_started', { port: ready.port, pid: ready.pid });
|
|
111
|
+
runner.child.once('exit', (code, signal) => {
|
|
112
|
+
clearInterval(secrets);
|
|
113
|
+
void bridge.stop();
|
|
114
|
+
if (current.stopping || this.current !== current) return;
|
|
115
|
+
this.restarts += 1;
|
|
116
|
+
const waitMs = Math.min(RESTART_MAX_MS, RESTART_MIN_MS * 2 ** Math.min(5, this.restarts - 1));
|
|
117
|
+
this.state = { state: 'restarting', code: 'runner_exited', exit: signal || code, restarts: this.restarts, retryInMs: waitMs };
|
|
118
|
+
this.log('runner_exited', { exit: signal || code, restarts: this.restarts });
|
|
119
|
+
void sleep(waitMs).then(() => (current.stopping || this.current !== current ? null : launch()))
|
|
120
|
+
.catch((error) => { this.state = { state: 'error', code: error.code || 'runner_start_failed', message: String(error.message).slice(0, 200) }; });
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
await launch();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async #stopCurrent() {
|
|
127
|
+
const current = this.current;
|
|
128
|
+
if (!current) return;
|
|
129
|
+
current.stopping = true;
|
|
130
|
+
this.current = null;
|
|
131
|
+
clearInterval(current.secrets);
|
|
132
|
+
await current.bridge?.stop();
|
|
133
|
+
if (current.runner?.child && current.runner.child.exitCode === null) {
|
|
134
|
+
const exited = new Promise((resolve) => current.runner.child.once('exit', resolve));
|
|
135
|
+
current.runner.child.kill('SIGTERM');
|
|
136
|
+
await Promise.race([exited, sleep(5_000)]);
|
|
137
|
+
}
|
|
138
|
+
current.store?.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const SPOOL_FILE = /^\d{8}\.jsonl$/;
|
|
6
|
+
|
|
7
|
+
/** When the facade last registered this address (runner.db only; a restored hub.db has no such record). */
|
|
8
|
+
function registeredOnline(db, id) {
|
|
9
|
+
const known = db.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = 'pulse_registered'").get();
|
|
10
|
+
return known ? db.prepare('SELECT at FROM pulse_registered WHERE id = ?').get(id) : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Applies one spooled write to a hub, the way the facade route would have, under the id the client printed: a
|
|
15
|
+
* publish keeps its edge id (published again it returns the stored envelope), an ack is idempotent and gives the
|
|
16
|
+
* deterministic reply id, a register is an upsert. The same function runs in the runner and in the undo down-sync.
|
|
17
|
+
*/
|
|
18
|
+
export function applySpooled(hub, { op, body, at = 0 }) {
|
|
19
|
+
const b = body && typeof body === 'object' ? body : {};
|
|
20
|
+
switch (op) {
|
|
21
|
+
case 'publish':
|
|
22
|
+
return hub.messaging.publish({
|
|
23
|
+
id: b.id, from: b.from, fromName: b.fromName, topic: b.topic, type: b.type || 'MESSAGE', body: b.body, needsAck: b.needsAck,
|
|
24
|
+
ttlMs: b.ttlMs, replyTo: b.replyTo,
|
|
25
|
+
}, hub.registry.directory());
|
|
26
|
+
case 'ack':
|
|
27
|
+
return hub.messaging.ack(String(b.as || ''), String(b.id || ''), { reply: b.reply || b.body, fromName: b.fromName, reaction: b.reaction }, hub.registry.directory());
|
|
28
|
+
case 'register': {
|
|
29
|
+
// A registration made online after this line was spooled is newer; the line doesn't put back older topics.
|
|
30
|
+
const online = registeredOnline(hub.db, String(b.id || ''));
|
|
31
|
+
if (online && Number(online.at) > Number(at)) return { superseded: true };
|
|
32
|
+
return hub.registry.register({ ...b, id: String(b.id || '') });
|
|
33
|
+
}
|
|
34
|
+
default:
|
|
35
|
+
return { error: `unknown spool op ${String(op).slice(0, 40)}` };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Complete lines only: a torn last line (no newline yet) waits for the next pass. */
|
|
40
|
+
function completeLines(text) {
|
|
41
|
+
const end = text.lastIndexOf('\n');
|
|
42
|
+
return end < 0 ? [] : text.slice(0, end).split('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Imports every spool line not imported before, oldest file first, each in its own capture transaction with its
|
|
47
|
+
* pulse_spool row, so a crash between lines imports the rest exactly once on the next pass.
|
|
48
|
+
*/
|
|
49
|
+
export function importSpool({ hub, capture, spoolDir, now = Date.now, log = () => {} }) {
|
|
50
|
+
let files;
|
|
51
|
+
try {
|
|
52
|
+
files = readdirSync(spoolDir).filter((f) => SPOOL_FILE.test(f)).sort();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error.code === 'ENOENT') return { imported: 0, refused: 0 };
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
const seen = hub.db.prepare('SELECT 1 AS found FROM pulse_spool WHERE sha256 = ?');
|
|
58
|
+
const record = hub.db.prepare('INSERT INTO pulse_spool (sha256, file, line, outcome, imported_at) VALUES (?, ?, ?, ?, ?)');
|
|
59
|
+
let imported = 0;
|
|
60
|
+
let refused = 0;
|
|
61
|
+
for (const file of files) {
|
|
62
|
+
const lines = completeLines(readFileSync(path.join(spoolDir, file), 'utf8'));
|
|
63
|
+
lines.forEach((line, index) => {
|
|
64
|
+
if (!line.trim()) return;
|
|
65
|
+
const sha = createHash('sha256').update(line).digest('hex');
|
|
66
|
+
if (seen.get(sha)) return;
|
|
67
|
+
capture.tx(() => {
|
|
68
|
+
let outcome;
|
|
69
|
+
try {
|
|
70
|
+
const entry = JSON.parse(line);
|
|
71
|
+
const result = entry && entry.v === 1 ? applySpooled(hub, entry) : { error: 'unknown spool version' };
|
|
72
|
+
outcome = result && result.error ? `refused: ${String(result.error).slice(0, 80)}` : result && result.superseded ? 'superseded' : 'applied';
|
|
73
|
+
} catch {
|
|
74
|
+
outcome = 'refused: unreadable line';
|
|
75
|
+
}
|
|
76
|
+
record.run(sha, file, index + 1, outcome, now());
|
|
77
|
+
if (outcome === 'applied') imported += 1;
|
|
78
|
+
else {
|
|
79
|
+
refused += 1;
|
|
80
|
+
log('spool_line_refused', { file, line: index + 1, outcome });
|
|
81
|
+
}
|
|
82
|
+
}, { label: 'spool import' });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return { imported, refused };
|
|
86
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Rollback's last step before the old hub starts again (ADR-4, A3): every write the thin clients spooled is applied to
|
|
3
|
+
// the restored hub.db by its edge id, with the ported 6b2fb0b core, so nothing an agent was told was sent goes
|
|
4
|
+
// missing. Run with PULSE_HUB_HOME set: `node spoolInto.js <hub.db>`. Prints `{ imported, refused }`.
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { withTx } from '../hub/core/db.mjs';
|
|
7
|
+
import { openHub } from '../hub/core/hub.mjs';
|
|
8
|
+
import { hubHome } from '../hub/home.mjs';
|
|
9
|
+
import { importSpool } from './spool.js';
|
|
10
|
+
|
|
11
|
+
const file = process.argv[2];
|
|
12
|
+
if (!file) {
|
|
13
|
+
process.stderr.write('usage: spoolInto.js <hub.db>\n');
|
|
14
|
+
process.exit(2);
|
|
15
|
+
}
|
|
16
|
+
const stateDir = path.join(hubHome(), 'state');
|
|
17
|
+
const hub = openHub(stateDir, path.resolve(file));
|
|
18
|
+
hub.db.prepare('CREATE TABLE IF NOT EXISTS pulse_spool (sha256 TEXT PRIMARY KEY, file TEXT NOT NULL, line INTEGER NOT NULL, outcome TEXT NOT NULL, imported_at INTEGER NOT NULL)').run();
|
|
19
|
+
const capture = { tx: (fn, { label = 'spool into hub.db' } = {}) => withTx(hub.db, fn, label) };
|
|
20
|
+
const result = importSpool({ hub, capture, spoolDir: path.join(stateDir, 'spool') });
|
|
21
|
+
hub.db.close();
|
|
22
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const escape = (value) => String(value ?? '')
|
|
2
|
+
.replaceAll('&', '&')
|
|
3
|
+
.replaceAll('<', '<')
|
|
4
|
+
.replaceAll('>', '>')
|
|
5
|
+
.replaceAll('"', '"');
|
|
6
|
+
|
|
7
|
+
const row = (label, value) => `<tr><th>${escape(label)}</th><td>${escape(value)}</td></tr>`;
|
|
8
|
+
|
|
9
|
+
/** What 127.0.0.1:8890 shows while Pulse is out of reach (D11): the measured health and what is held here. */
|
|
10
|
+
export function statusPage({ health, pulseUrl }) {
|
|
11
|
+
const h = health || {};
|
|
12
|
+
const queue = h.outbox || {};
|
|
13
|
+
return `<!doctype html>
|
|
14
|
+
<html lang="en"><head><meta charset="utf-8"><title>Pulse runner</title>
|
|
15
|
+
<style>body{font:15px/1.5 system-ui,sans-serif;margin:3rem auto;max-width:36rem;color:#1d2433}th{text-align:left;padding-right:1.5rem;font-weight:600}td,th{padding:.2rem 0}</style>
|
|
16
|
+
</head><body>
|
|
17
|
+
<h1>Pulse runner</h1>
|
|
18
|
+
<p>${escape(h.summary || 'The local Agent Hub moved into Pulse. Agents on this machine keep working; changes wait here until Pulse is reachable.')}</p>
|
|
19
|
+
<table>
|
|
20
|
+
${row('State', h.state || 'unknown')}
|
|
21
|
+
${row('Reason', h.reason || '')}
|
|
22
|
+
${h.fix ? row('Fix', h.fix) : ''}
|
|
23
|
+
${row('Last sync', h.lastSyncAt ? new Date(h.lastSyncAt).toISOString() : 'never')}
|
|
24
|
+
${row('Waiting to send', queue.pending ?? 0)}
|
|
25
|
+
${row('Set aside', (queue.dead ?? 0) + (h.quarantined ?? 0))}
|
|
26
|
+
</table>
|
|
27
|
+
${pulseUrl ? `<p><a href="${escape(pulseUrl)}">Open Pulse</a></p>` : ''}
|
|
28
|
+
</body></html>
|
|
29
|
+
`;
|
|
30
|
+
}
|