@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.
Files changed (101) hide show
  1. package/README.md +46 -0
  2. package/package.json +7 -4
  3. package/src/clients/hub-bridge.mjs +99 -0
  4. package/src/clients/hub.mjs +324 -0
  5. package/src/commands/pulse/cutover.js +39 -0
  6. package/src/commands/pulse/doctor.js +1 -0
  7. package/src/commands/pulse/import.js +62 -0
  8. package/src/commands/pulse/log-write.js +26 -0
  9. package/src/commands/pulse/parity.js +20 -0
  10. package/src/commands/pulse/shadow.js +19 -0
  11. package/src/daemon/main.js +19 -12
  12. package/src/hub/core/auth.mjs +80 -0
  13. package/src/hub/core/changes.mjs +38 -0
  14. package/src/hub/core/conversations.mjs +121 -0
  15. package/src/hub/core/db.mjs +263 -0
  16. package/src/hub/core/edgeIds.mjs +9 -0
  17. package/src/hub/core/hub.mjs +20 -0
  18. package/src/hub/core/ids.mjs +39 -0
  19. package/src/hub/core/leases.mjs +55 -0
  20. package/src/hub/core/messaging.mjs +322 -0
  21. package/src/hub/core/registry.mjs +281 -0
  22. package/src/hub/core/ticks.mjs +58 -0
  23. package/src/hub/core/wake.mjs +125 -0
  24. package/src/hub/home.mjs +16 -0
  25. package/src/hub/hooks/hub-bridge.mjs +167 -0
  26. package/src/hub/keepawake/agent.mjs +96 -0
  27. package/src/hub/lib/brief.mjs +133 -0
  28. package/src/hub/lib/channels.mjs +268 -0
  29. package/src/hub/lib/chat.mjs +163 -0
  30. package/src/hub/lib/closure.mjs +92 -0
  31. package/src/hub/lib/collect-worker.mjs +20 -0
  32. package/src/hub/lib/collect.mjs +342 -0
  33. package/src/hub/lib/deliveries.mjs +222 -0
  34. package/src/hub/lib/git.mjs +93 -0
  35. package/src/hub/lib/me.mjs +123 -0
  36. package/src/hub/lib/personas.mjs +143 -0
  37. package/src/hub/lib/status.mjs +147 -0
  38. package/src/hub/lib/store.mjs +106 -0
  39. package/src/hub/lib/titles.mjs +29 -0
  40. package/src/hub/lib/transcript-feed.mjs +72 -0
  41. package/src/hub/lib/transcripts.mjs +364 -0
  42. package/src/hub/lib/writing.mjs +18 -0
  43. package/src/hub/projections/deliveries.mjs +31 -0
  44. package/src/hub/projections/graph.mjs +149 -0
  45. package/src/hub/runners/drivers.mjs +162 -0
  46. package/src/hub/workers/collector.mjs +128 -0
  47. package/src/hub/workers/manager.mjs +366 -0
  48. package/src/hub/workers/runner.mjs +145 -0
  49. package/src/lib/application.js +82 -34
  50. package/src/lib/channelScanner.js +99 -0
  51. package/src/lib/clientInstaller.js +111 -0
  52. package/src/lib/cloudFirstCommands.js +293 -0
  53. package/src/lib/contract.sha256 +1 -1
  54. package/src/lib/cutover.js +376 -0
  55. package/src/lib/cutoverWiring.js +137 -0
  56. package/src/lib/daemonLock.js +2 -0
  57. package/src/lib/daemonLoop.js +5 -2
  58. package/src/lib/daemonProcess.js +16 -2
  59. package/src/lib/daemonRuntime.js +8 -0
  60. package/src/lib/doctorWarnings.js +53 -0
  61. package/src/lib/errors.js +14 -0
  62. package/src/lib/hubDbReader.js +132 -0
  63. package/src/lib/hubMapping.js +108 -0
  64. package/src/lib/hubScan.js +611 -0
  65. package/src/lib/hubSnapshot.js +64 -0
  66. package/src/lib/mirrorContract.js +1 -1
  67. package/src/lib/mirrorService.js +8 -1
  68. package/src/lib/mirrorSource.js +13 -38
  69. package/src/lib/nodeFloor.js +19 -0
  70. package/src/lib/outboxFlusher.js +75 -0
  71. package/src/lib/pairingLifecycle.js +1 -1
  72. package/src/lib/reconcile.js +316 -0
  73. package/src/lib/reconcileView.js +129 -0
  74. package/src/lib/runnerStore.js +418 -0
  75. package/src/lib/serviceManager.js +61 -0
  76. package/src/lib/shadowService.js +435 -0
  77. package/src/lib/sqlite.js +28 -0
  78. package/src/lib/tokenStore.js +43 -4
  79. package/src/lib/transport.js +53 -5
  80. package/src/lib/transportContract.js +3 -0
  81. package/src/lib/uploadRedactor.js +102 -0
  82. package/src/runner/applier.js +297 -0
  83. package/src/runner/bridge.js +289 -0
  84. package/src/runner/busIdentity.js +499 -0
  85. package/src/runner/capture.js +103 -0
  86. package/src/runner/captureMapping.js +21 -0
  87. package/src/runner/channelDiscipline.js +425 -0
  88. package/src/runner/deliveryStates.js +220 -0
  89. package/src/runner/facade.js +876 -0
  90. package/src/runner/hubCapture.js +160 -0
  91. package/src/runner/lease.js +46 -0
  92. package/src/runner/main.js +35 -0
  93. package/src/runner/render.js +91 -0
  94. package/src/runner/runnerChild.js +57 -0
  95. package/src/runner/runnerHub.js +104 -0
  96. package/src/runner/runnerProcess.js +124 -0
  97. package/src/runner/runnerSchema.js +51 -0
  98. package/src/runner/runnerService.js +140 -0
  99. package/src/runner/spool.js +86 -0
  100. package/src/runner/spoolInto.js +22 -0
  101. package/src/runner/statusPage.js +30 -0
@@ -0,0 +1,289 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { fullJitterDelay } from '../lib/backoff.js';
3
+ import { scopeBlock } from '../lib/errors.js';
4
+ import { OUTBOX_MAX_PENDING } from '../lib/runnerStore.js';
5
+ import { OutboxFlusher } from '../lib/outboxFlusher.js';
6
+
7
+ const ONLINE_MS = 35_000;
8
+ const OFFLINE_MS = 60_000;
9
+ const LAG_EVENTS = 50;
10
+ const WAKE_GAP_MS = 30_000;
11
+ const FLUSH_EVERY_MS = 1_000;
12
+ const HEALTH_EVERY_MS = 5_000;
13
+ const BATCHES_PER_FLUSH = 30;
14
+ /** The Phase 2 scanner's file sources (channel.md, personas.json, state/chat) and presence, as the shadow ran them. */
15
+ export const FILE_SOURCES = Object.freeze(['personas', 'chat', 'channels', 'presence']);
16
+ const FILE_SCAN_MS = 10_000;
17
+ /** The manager lease (Q9): renewed well inside its ttl; this machine stops counting on it `LEASE_MARGIN_MS` early. */
18
+ export const LEASE_TTL_MS = 30_000;
19
+ const LEASE_RENEW_MS = 10_000;
20
+ const LEASE_MARGIN_MS = 5_000;
21
+ /** runner.db meta key of the cloud's presence keyset cursor (S3), kept beside the event cursor and sent on every sync. */
22
+ const PRESENCE_CURSOR = 'presence_cursor';
23
+ /** This many sync failures in a row (no page applied in between) and health says so, ahead of anything milder. */
24
+ export const SYNC_FAILING_AFTER = 3;
25
+ /** SCOPE-02: while blocked for a scope or grant, the sync is retried this often with the sign-in read again, so a re-login clears it. */
26
+ export const BLOCK_RETRY_MS = 60_000;
27
+ const ERROR_CODES = new Set(['HUB_LOGIN_REQUIRED', 'HUB_SCOPE_DENIED', 'capability_denied', 'cloud_first_off', 'HUB_MACHINE_REVOKED']);
28
+
29
+ /**
30
+ * The daemon's half of the runner (design §6.3): it holds the cloud credentials, so it long-polls `/connector/sync`
31
+ * and hands each page to the runner process to apply, and it flushes runner.db's outbox to `/connector/commands`.
32
+ * Outbound only. The cursor it asks from is the one the runner committed with the page it applied (I2), read back
33
+ * from runner.db, so a crash anywhere re-asks for the same page. Health (§9.4) is measured here and sent to the
34
+ * runner for the facade, `pulse status` and the cloud.
35
+ */
36
+ export class RunnerBridge {
37
+ constructor({
38
+ runner, transport, store, machineId = null, now = Date.now, monotonic = () => performance.now(), random = Math.random, waitSeconds = 20, log = () => {},
39
+ lease = { ttlMs: LEASE_TTL_MS, renewMs: LEASE_RENEW_MS, marginMs: LEASE_MARGIN_MS }, fileScan = null, fileScanMs = FILE_SCAN_MS, blockRetryMs = BLOCK_RETRY_MS,
40
+ flusher = new OutboxFlusher({ store, transport, random }),
41
+ }) {
42
+ this.runner = runner;
43
+ this.transport = transport;
44
+ this.store = store;
45
+ this.now = now;
46
+ this.monotonic = monotonic;
47
+ this.random = random;
48
+ this.waitSeconds = waitSeconds;
49
+ this.log = log;
50
+ this.flusher = flusher;
51
+ this.machineId = machineId;
52
+ this.leaseConfig = lease;
53
+ this.leaseDeadline = 0;
54
+ this.fileScan = fileScan;
55
+ this.fileScanMs = fileScanMs;
56
+ this.blockRetryMs = blockRetryMs;
57
+ this.running = false;
58
+ this.kicked = null;
59
+ this.state = { lastSyncAt: null, lastContactAt: null, failures: 0, syncFailure: null, error: null, resync: null, head: 0, sleptAt: null, flushError: null };
60
+ }
61
+
62
+ start() {
63
+ if (this.running) return;
64
+ this.running = true;
65
+ this.runner.on('wrote', () => this.kick());
66
+ this.syncing = this.#syncLoop();
67
+ this.flushing = this.#flushLoop();
68
+ if (this.leaseConfig) this.leasing = this.#leaseLoop();
69
+ if (this.fileScan) this.scanning = this.#fileLoop();
70
+ this.healthTimer = setInterval(() => this.#pushHealth(), HEALTH_EVERY_MS);
71
+ this.healthTimer.unref?.();
72
+ this.#pushHealth();
73
+ }
74
+
75
+ async stop() {
76
+ this.running = false;
77
+ clearInterval(this.healthTimer);
78
+ this.kick();
79
+ this.leaseWake?.();
80
+ this.fileWake?.();
81
+ await Promise.allSettled([this.syncing, this.flushing, this.leasing, this.scanning]);
82
+ if (this.leaseConfig && this.leaseDeadline > this.monotonic()) {
83
+ await this.#claimLease({ release: true }).catch(() => {});
84
+ this.runner.send({ type: 'lease', held: false });
85
+ }
86
+ }
87
+
88
+ /** A local write landed: flush now instead of at the next tick. */
89
+ kick() {
90
+ const wake = this.kicked;
91
+ this.kicked = null;
92
+ wake?.();
93
+ }
94
+
95
+ applied() {
96
+ return Number(this.store.db.prepare('SELECT applied_seq FROM pulse_sync WHERE one = 1').get()?.applied_seq || 0);
97
+ }
98
+
99
+ async #syncLoop() {
100
+ let lastWall = this.now();
101
+ let lastMono = this.monotonic();
102
+ let lagging = true;
103
+ while (this.running) {
104
+ const wallGap = this.now() - lastWall;
105
+ const monoGap = this.monotonic() - lastMono;
106
+ if (wallGap - monoGap > WAKE_GAP_MS) {
107
+ this.state.sleptAt = this.now();
108
+ this.log('runner_woke', { asleepMs: Math.round(wallGap - monoGap) });
109
+ lagging = true;
110
+ }
111
+ lastWall = this.now();
112
+ lastMono = this.monotonic();
113
+ const blocked = this.state.error?.block ? this.state.error : null;
114
+ if (this.state.resync || (this.state.error && !blocked)) { await sleep(5_000); continue; }
115
+ if (blocked) {
116
+ const left = blocked.at + this.blockRetryMs - this.now();
117
+ if (left > 0) { await sleep(Math.min(5_000, left)); continue; }
118
+ this.transport.auth?.invalidate?.();
119
+ }
120
+ try {
121
+ const since = this.applied();
122
+ const presence = this.store.meta?.(PRESENCE_CURSOR) ?? null;
123
+ const page = await this.transport.sync({ since, wait: lagging ? 0 : this.waitSeconds, ...(presence ? { presence } : {}) });
124
+ this.state.lastContactAt = this.now();
125
+ const result = await this.runner.request({ type: 'apply', page });
126
+ this.state.lastSyncAt = this.now();
127
+ if (blocked) {
128
+ this.state.error = null;
129
+ this.log('sync_unblocked', { code: blocked.code });
130
+ this.#pushHealth();
131
+ }
132
+ if (typeof page.presenceNext === 'string' && page.presenceNext && page.presenceNext !== presence) this.store.setMeta?.(PRESENCE_CURSOR, page.presenceNext);
133
+ this.state.failures = 0;
134
+ this.state.syncFailure = null;
135
+ this.state.head = Number(page.head) || 0;
136
+ lagging = Number(page.head) > Number(result.next);
137
+ if (result.quarantined) this.log('sync_quarantined', { count: result.quarantined, next: result.next });
138
+ } catch (error) {
139
+ if (!this.running) break;
140
+ if (error.code === 'runner_exited') { await sleep(1_000); continue; }
141
+ if (error.status === 410 || error.code === 'resync_required') {
142
+ this.state.resync = { at: this.now(), floor: error.details?.floor ?? null };
143
+ this.log('sync_resync_required', { since: this.applied() });
144
+ continue;
145
+ }
146
+ // A cursor the cloud refuses (after a downgrade, say) would fail every sync: drop it and start presence over.
147
+ if (error.code === 'invalid_presence' && this.store.meta?.(PRESENCE_CURSOR)) {
148
+ this.store.setMeta?.(PRESENCE_CURSOR, null);
149
+ this.log('presence_cursor_reset', { code: error.code });
150
+ continue;
151
+ }
152
+ if (ERROR_CODES.has(error.code)) {
153
+ this.state.error = { code: error.code, at: this.now(), block: scopeBlock(error) };
154
+ this.log('sync_refused', { code: error.code });
155
+ this.#pushHealth();
156
+ continue;
157
+ }
158
+ this.state.failures += 1;
159
+ this.state.syncFailure = { code: error.code || 'error', at: this.now() };
160
+ this.log('sync_failed', { code: error.code || 'error', failures: this.state.failures });
161
+ await sleep(Math.max(1_000, fullJitterDelay(this.state.failures, { capMs: 30_000, random: this.random })));
162
+ }
163
+ }
164
+ }
165
+
166
+ async #flushLoop() {
167
+ let failures = 0;
168
+ while (this.running) {
169
+ try {
170
+ const tally = await this.flusher.drain({ maxBatches: BATCHES_PER_FLUSH });
171
+ this.state.lastContactAt = tally.batches ? this.now() : this.state.lastContactAt;
172
+ this.state.flushError = null;
173
+ failures = 0;
174
+ if (tally.written + tally.duplicate > 0) await this.runner.request({ type: 'retryDeferred' }).catch(() => {});
175
+ } catch (error) {
176
+ failures += 1;
177
+ this.state.flushError = { code: error.code || 'error', at: this.now() };
178
+ if (ERROR_CODES.has(error.code)) this.state.error = { code: error.code, at: this.now(), block: scopeBlock(error) };
179
+ await sleep(Math.max(1_000, fullJitterDelay(failures, { capMs: 30_000, random: this.random })));
180
+ continue;
181
+ }
182
+ await Promise.race([sleep(FLUSH_EVERY_MS), new Promise((resolve) => { this.kicked = resolve; })]);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * After cutover the hub.db sources are captured in the runner; the file sources stay with the Phase 2 scanner, now
188
+ * reading runner.db: channel.md posts agents append, personas, project chat, and presence beats (not log events).
189
+ */
190
+ async #fileLoop() {
191
+ while (this.running) {
192
+ try {
193
+ const report = await this.fileScan.run({ mode: 'shadow', sources: FILE_SOURCES });
194
+ for (let start = 0; start < report.presence.length; start += 100) {
195
+ const chunk = report.presence.slice(start, start + 100);
196
+ const answer = await this.transport.commands({ commands: chunk });
197
+ chunk.forEach((beat, index) => {
198
+ if (!answer?.results?.[index]?.error) this.store.setPresenceSent(beat.payload.actorId, beat.payload.at);
199
+ });
200
+ }
201
+ if (report.totals.planned) this.kick();
202
+ } catch (error) {
203
+ this.log('file_scan_failed', { code: error.code || 'error', message: String(error.message).slice(0, 200) });
204
+ }
205
+ await Promise.race([sleep(this.fileScanMs), new Promise((resolve) => { this.fileWake = resolve; })]);
206
+ }
207
+ }
208
+
209
+ /** One lease call through /commands (no seq). Resolves with the lease, or throws. */
210
+ async #claimLease({ release = false } = {}) {
211
+ const payload = release ? { role: 'manager', release: true } : { role: 'manager', ttlMs: this.leaseConfig.ttlMs };
212
+ const answer = await this.transport.commands({ commands: [{ type: 'runner.lease', idempotencyKey: `lease:${this.machineId || 'runner'}:${this.now()}`, payload }] });
213
+ const result = answer?.results?.[0];
214
+ if (!result || result.error) throw Object.assign(new Error(result?.error?.message || 'no lease result'), { code: result?.error?.code || 'lease_failed' });
215
+ return result.lease;
216
+ }
217
+
218
+ /**
219
+ * Claims or renews the manager lease every renewMs. The deadline is the monotonic time the winning request was sent,
220
+ * plus the ttl, minus a margin: never the cloud's `until` against this machine's clock (H4). A failed renew keeps the
221
+ * deadline it had; when it passes, the runner is told at once and stops its manager before the cloud lets anyone else in.
222
+ */
223
+ async #leaseLoop() {
224
+ const { ttlMs, renewMs, marginMs } = this.leaseConfig;
225
+ let lapse = null;
226
+ const report = (lease) => {
227
+ const remainingMs = Math.max(0, this.leaseDeadline - this.monotonic());
228
+ this.runner.send({ type: 'lease', held: remainingMs > 0, remainingMs, holder: lease?.holder ?? null, fence: lease?.fence ?? 0 });
229
+ };
230
+ while (this.running) {
231
+ const sentAt = this.monotonic();
232
+ try {
233
+ const lease = await this.#claimLease();
234
+ this.lease = lease;
235
+ this.leaseDeadline = lease.here ? sentAt + ttlMs - marginMs : 0;
236
+ report(lease);
237
+ } catch (error) {
238
+ this.log('lease_failed', { code: error.code || 'error' });
239
+ report(this.lease);
240
+ }
241
+ clearTimeout(lapse);
242
+ const remaining = this.leaseDeadline - this.monotonic();
243
+ if (remaining > 0) {
244
+ lapse = setTimeout(() => report(this.lease), remaining);
245
+ lapse.unref?.();
246
+ }
247
+ await Promise.race([sleep(renewMs), new Promise((resolve) => { this.leaseWake = resolve; })]);
248
+ }
249
+ clearTimeout(lapse);
250
+ }
251
+
252
+ /** The measured state (§9.4): never "running" alone. */
253
+ health() {
254
+ const now = this.now();
255
+ const pending = this.store.pendingCount();
256
+ const dead = Number(this.store.db.prepare('SELECT COUNT(*) AS n FROM dead').get().n);
257
+ const quarantined = Number(this.store.db.prepare('SELECT COUNT(*) AS n FROM pulse_quarantine').get()?.n || 0);
258
+ const applied = this.applied();
259
+ const lag = Math.max(0, this.state.head - applied);
260
+ const outbox = { pending, dead, capacity: OUTBOX_MAX_PENDING };
261
+ const base = { lastSyncAt: this.state.lastSyncAt, applied, head: this.state.head, lag, outbox, quarantined,
262
+ staleSeconds: this.state.lastSyncAt ? (now - this.state.lastSyncAt) / 1000 : null };
263
+ const { block } = this.state.error || {};
264
+ if (block) {
265
+ const missing = block.missingScopes.length ? `missing ${block.missingScopes.join(', ')}` : 'missing a scope or capability';
266
+ return { ...base, state: 'blocked', reason: `${block.code}: ${missing}`, code: block.code, missingScopes: block.missingScopes, fix: block.fix };
267
+ }
268
+ if (this.state.error) return { ...base, state: 'error', reason: this.state.error.code, fix: this.state.error.code === 'HUB_LOGIN_REQUIRED' ? 'agentpulse login' : null };
269
+ if (this.state.resync) return { ...base, state: 'error', reason: 'resync_required' };
270
+ if (this.state.failures >= SYNC_FAILING_AFTER && this.state.syncFailure) {
271
+ const { code } = this.state.syncFailure;
272
+ return { ...base, state: 'degraded', reason: `sync failing (${code})`, code, failures: this.state.failures };
273
+ }
274
+ if (this.state.sleptAt && (!this.state.lastSyncAt || this.state.lastSyncAt < this.state.sleptAt)) return { ...base, state: 'sleeping', reason: 'woke from sleep, catching up' };
275
+ if (!this.state.lastContactAt || now - this.state.lastContactAt > OFFLINE_MS) {
276
+ return { ...base, state: 'offline', reason: this.state.lastContactAt ? 'no contact with Pulse' : 'not connected yet' };
277
+ }
278
+ if (lag > LAG_EVENTS || (pending > 0 && this.state.flushError)) return { ...base, state: 'catching_up', reason: `${lag} events and ${pending} changes to go` };
279
+ if (dead || quarantined || pending > 0.8 * OUTBOX_MAX_PENDING) {
280
+ return { ...base, state: 'degraded', reason: `${dead} change${dead === 1 ? '' : 's'} and ${quarantined} event${quarantined === 1 ? '' : 's'} set aside` };
281
+ }
282
+ if (!this.state.lastSyncAt || now - this.state.lastSyncAt > ONLINE_MS) return { ...base, state: 'catching_up', reason: 'waiting for a sync' };
283
+ return { ...base, state: 'online', reason: 'in sync' };
284
+ }
285
+
286
+ #pushHealth() {
287
+ try { this.runner.send({ type: 'health', health: this.health() }); } catch (error) { this.log('health_failed', { code: error.code || 'error' }); }
288
+ }
289
+ }