@agentpulselabs/cli-plugin-pulse 0.2.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 +115 -32
- package/package.json +14 -19
- 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 +15 -0
- package/src/commands/pulse/hub-write.js +26 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/install.js +14 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/login.js +20 -0
- package/src/commands/pulse/mirror.js +27 -0
- package/src/commands/pulse/pair.js +38 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/restart.js +14 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/commands/pulse/start.js +6 -18
- package/src/commands/pulse/status.js +6 -6
- package/src/commands/pulse/stop.js +24 -0
- package/src/daemon/main.js +43 -0
- 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/BaseCommand.js +23 -23
- package/src/lib/agentSessions.js +279 -0
- package/src/lib/application.js +704 -0
- package/src/lib/backoff.js +8 -0
- package/src/lib/budgets.js +85 -0
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/cliDefaults.js +20 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudAuth.js +189 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +2 -0
- package/src/lib/cutover.js +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonComposition.js +338 -0
- package/src/lib/daemonLock.js +57 -0
- package/src/lib/daemonLoop.js +72 -0
- package/src/lib/daemonProcess.js +380 -0
- package/src/lib/daemonRuntime.js +828 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/drivers.js +280 -0
- package/src/lib/errors.js +33 -0
- package/src/lib/graphWindows.js +149 -0
- package/src/lib/http.js +84 -0
- package/src/lib/hubActions.js +74 -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/identity.js +102 -0
- package/src/lib/intents.js +590 -0
- package/src/lib/ledger.js +535 -0
- package/src/lib/lifecycle.js +123 -0
- package/src/lib/localHub.js +217 -0
- package/src/lib/localHubWriter.js +69 -0
- package/src/lib/mirror-contract.json +4299 -0
- package/src/lib/mirrorContract.js +513 -0
- package/src/lib/mirrorDaemon.js +127 -0
- package/src/lib/mirrorService.js +365 -0
- package/src/lib/mirrorSource.js +430 -0
- package/src/lib/mirrorSync.js +353 -0
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/oauthLogin.js +132 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/ownerLock.js +99 -0
- package/src/lib/pairedConfig.js +40 -0
- package/src/lib/pairingLifecycle.js +179 -0
- package/src/lib/pathRedaction.js +89 -0
- package/src/lib/planContract.js +141 -0
- package/src/lib/priorityLane.js +80 -0
- package/src/lib/processOwnership.js +252 -0
- package/src/lib/processSupervisor.js +376 -0
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/redaction.js +128 -0
- package/src/lib/rootPolicy.js +372 -0
- package/src/lib/rulePacks.js +162 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/secretStore.js +169 -0
- package/src/lib/selfRestart.js +261 -0
- package/src/lib/serviceManager.js +419 -0
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/storage.js +131 -0
- package/src/lib/subprocess.js +45 -0
- package/src/lib/tokenStore.js +136 -0
- package/src/lib/transport.js +334 -0
- package/src/lib/transportContract.js +40 -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
- package/LICENSE +0 -21
- package/src/commands/pulse/gate.js +0 -25
- package/src/commands/pulse/next.js +0 -14
- package/src/commands/pulse/submit.js +0 -21
- package/src/commands/pulse/verify.js +0 -21
- package/src/runPulse.js +0 -58
|
@@ -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
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 AgentPulse Labs
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { Flags } from '@oclif/core';
|
|
2
|
-
import BaseCommand from '../../lib/BaseCommand.js';
|
|
3
|
-
import { runPulse } from '../../runPulse.js';
|
|
4
|
-
|
|
5
|
-
/** `agentpulse pulse gate` — the human-in-the-loop barrier. Advance only with --approved. */
|
|
6
|
-
export default class PulseGate extends BaseCommand {
|
|
7
|
-
static description = 'The human gate. Advance the phase ONLY with --approved. Lock product decisions here.';
|
|
8
|
-
static examples = ['<%= config.bin %> pulse gate --approved --decisions-file decisions.json'];
|
|
9
|
-
static flags = {
|
|
10
|
-
approved: Flags.boolean({ description: 'Approve this phase (silence/omission = not approved).', default: false }),
|
|
11
|
-
'decisions-file': Flags.string({ description: 'Path to a JSON file of human product decisions to lock: [{ title, decision, rationale }].' }),
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
async run() {
|
|
15
|
-
const { flags } = await this.parse(PulseGate);
|
|
16
|
-
let decisions = [];
|
|
17
|
-
if (flags['decisions-file']) {
|
|
18
|
-
const fs = await import('node:fs');
|
|
19
|
-
decisions = JSON.parse(fs.readFileSync(flags['decisions-file'], 'utf8'));
|
|
20
|
-
}
|
|
21
|
-
const result = await runPulse('gate', { approved: flags.approved, decisions });
|
|
22
|
-
this.log(JSON.stringify(result, null, 2)); // default: raw JSON (auto-silenced under --json)
|
|
23
|
-
return result; // --json: standard envelope
|
|
24
|
-
}
|
|
25
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import BaseCommand from '../../lib/BaseCommand.js';
|
|
2
|
-
import { runPulse } from '../../runPulse.js';
|
|
3
|
-
|
|
4
|
-
/** `agentpulse pulse next` — emit the current phase work-list (prompts + schema, contract injected). */
|
|
5
|
-
export default class PulseNext extends BaseCommand {
|
|
6
|
-
static description = 'Emit the current phase work-list: tasks (prompt + JSON schema) your model must run.';
|
|
7
|
-
static examples = ['<%= config.bin %> pulse next'];
|
|
8
|
-
|
|
9
|
-
async run() {
|
|
10
|
-
const result = await runPulse('next', {});
|
|
11
|
-
this.log(JSON.stringify(result, null, 2)); // default: raw JSON (auto-silenced under --json)
|
|
12
|
-
return result; // --json: standard envelope
|
|
13
|
-
}
|
|
14
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { Flags } from '@oclif/core';
|
|
2
|
-
import BaseCommand from '../../lib/BaseCommand.js';
|
|
3
|
-
import { runPulse } from '../../runPulse.js';
|
|
4
|
-
|
|
5
|
-
/** `agentpulse pulse submit` — validate the model's structured results against schema + contract. */
|
|
6
|
-
export default class PulseSubmit extends BaseCommand {
|
|
7
|
-
static description = "Submit your model's structured results (JSON). Pulse validates them vs schema + contract.";
|
|
8
|
-
static examples = ['<%= config.bin %> pulse submit --results-file discovery-results.json'];
|
|
9
|
-
static flags = {
|
|
10
|
-
'results-file': Flags.string({ description: 'Path to a JSON file: [{ id, data }, …] (one per work-list task).', required: true }),
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
async run() {
|
|
14
|
-
const { flags } = await this.parse(PulseSubmit);
|
|
15
|
-
const fs = await import('node:fs');
|
|
16
|
-
const results = JSON.parse(fs.readFileSync(flags['results-file'], 'utf8'));
|
|
17
|
-
const result = await runPulse('submit', { results });
|
|
18
|
-
this.log(JSON.stringify(result, null, 2)); // default: raw JSON (auto-silenced under --json)
|
|
19
|
-
return result; // --json: standard envelope
|
|
20
|
-
}
|
|
21
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { Flags } from '@oclif/core';
|
|
2
|
-
import BaseCommand from '../../lib/BaseCommand.js';
|
|
3
|
-
import { runPulse } from '../../runPulse.js';
|
|
4
|
-
|
|
5
|
-
/** `agentpulse pulse verify` — run the contract's deterministic gate command. "Done" is this exit code. */
|
|
6
|
-
export default class PulseVerify extends BaseCommand {
|
|
7
|
-
static description = "Run the contract's gate command (e.g. `npm run check`) and report pass/fail. Never an LLM opinion.";
|
|
8
|
-
static examples = ['<%= config.bin %> pulse verify'];
|
|
9
|
-
static flags = {
|
|
10
|
-
cwd: Flags.string({ description: 'Directory to run the gate in (default: current repo).' }),
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
async run() {
|
|
14
|
-
const { flags } = await this.parse(PulseVerify);
|
|
15
|
-
const result = await runPulse('verify', { cwd: flags.cwd });
|
|
16
|
-
this.log(JSON.stringify(result, null, 2)); // default: raw JSON (auto-silenced under --json)
|
|
17
|
-
// A red gate is a SUCCESSFUL run reporting ok:false + exit 1 (so CI/scripts gate on it) — not a thrown error.
|
|
18
|
-
if (!result.green) return this.problem(result, 1);
|
|
19
|
-
return result; // --json: standard envelope
|
|
20
|
-
}
|
|
21
|
-
}
|
package/src/runPulse.js
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* runPulse.js — the PURE command core for the pulse CLI topic. Maps a verb + args → an engine call → a
|
|
3
|
-
* JSON-serializable result. No oclif, no I/O of its own (the engine's state.js owns fs). This is what makes
|
|
4
|
-
* the plugin testable without installing @oclif/core, and keeps the seam thin: the oclif command classes
|
|
5
|
-
* (src/commands/pulse/*.js) are ~5-line wrappers that parse flags and call this.
|
|
6
|
-
*
|
|
7
|
-
* Mirror of the MCP seam's tools.js: one verb == one engine method. All discipline lives in @agentpulselabs/pulse.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { createEngine } from '@agentpulselabs/pulse';
|
|
11
|
-
|
|
12
|
-
/** The verbs this topic exposes, each → an engine method. Kept in sync with the MCP tool surface. */
|
|
13
|
-
export const ACTIONS = ['start', 'next', 'submit', 'gate', 'verify', 'status', 'board', 'claim', 'sync'];
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* @param {string} action - one of ACTIONS
|
|
17
|
-
* @param {Object} args - parsed flags/inputs for the action
|
|
18
|
-
* @param {Object} [opts] - { cwd, createEngine } (createEngine injectable for tests)
|
|
19
|
-
* @returns {Promise<Object>} JSON-serializable result
|
|
20
|
-
*/
|
|
21
|
-
export async function runPulse(action, args = {}, opts = {}) {
|
|
22
|
-
const make = opts.createEngine || createEngine;
|
|
23
|
-
const engine = make({ cwd: opts.cwd || process.cwd() });
|
|
24
|
-
|
|
25
|
-
switch (action) {
|
|
26
|
-
case 'start':
|
|
27
|
-
return engine.start({ requirements: args.requirements, grounding: args.grounding, name: args.name });
|
|
28
|
-
case 'next':
|
|
29
|
-
return engine.next();
|
|
30
|
-
case 'submit':
|
|
31
|
-
return engine.submit({ results: parseResults(args.results) });
|
|
32
|
-
case 'gate':
|
|
33
|
-
return engine.gate({ approved: !!args.approved, decisions: parseJson(args.decisions, []) });
|
|
34
|
-
case 'verify':
|
|
35
|
-
return await engine.verify({ cwd: args.cwd });
|
|
36
|
-
case 'status':
|
|
37
|
-
return engine.status();
|
|
38
|
-
case 'board':
|
|
39
|
-
return engine.boardUpdate({ id: args.id, status: args.status });
|
|
40
|
-
case 'claim':
|
|
41
|
-
return engine.claim({ id: args.id, agentId: args.agentId });
|
|
42
|
-
case 'sync':
|
|
43
|
-
return engine.sync({ ids: parseJson(args.ids, undefined) });
|
|
44
|
-
default:
|
|
45
|
-
throw new Error(`unknown pulse action "${action}" — expected one of ${ACTIONS.join(', ')}`);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// results may arrive as a JSON string (from --results-file content) or already-parsed array.
|
|
50
|
-
function parseResults(r) {
|
|
51
|
-
if (Array.isArray(r)) return r;
|
|
52
|
-
return parseJson(r, []);
|
|
53
|
-
}
|
|
54
|
-
function parseJson(v, fallback) {
|
|
55
|
-
if (v == null) return fallback;
|
|
56
|
-
if (typeof v !== 'string') return v;
|
|
57
|
-
try { return JSON.parse(v); } catch { return fallback; }
|
|
58
|
-
}
|