@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.
Files changed (102) hide show
  1. package/README.md +55 -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 +63 -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 +20 -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 +411 -0
  55. package/src/lib/cutoverRequest.js +485 -0
  56. package/src/lib/cutoverWiring.js +140 -0
  57. package/src/lib/daemonLock.js +2 -0
  58. package/src/lib/daemonLoop.js +5 -2
  59. package/src/lib/daemonProcess.js +57 -6
  60. package/src/lib/daemonRuntime.js +8 -0
  61. package/src/lib/doctorWarnings.js +53 -0
  62. package/src/lib/errors.js +14 -0
  63. package/src/lib/hubDbReader.js +132 -0
  64. package/src/lib/hubMapping.js +108 -0
  65. package/src/lib/hubScan.js +611 -0
  66. package/src/lib/hubSnapshot.js +64 -0
  67. package/src/lib/mirrorContract.js +1 -1
  68. package/src/lib/mirrorService.js +8 -1
  69. package/src/lib/mirrorSource.js +13 -38
  70. package/src/lib/nodeFloor.js +19 -0
  71. package/src/lib/outboxFlusher.js +75 -0
  72. package/src/lib/pairingLifecycle.js +1 -1
  73. package/src/lib/reconcile.js +316 -0
  74. package/src/lib/reconcileView.js +129 -0
  75. package/src/lib/runnerStore.js +418 -0
  76. package/src/lib/serviceManager.js +64 -1
  77. package/src/lib/shadowService.js +435 -0
  78. package/src/lib/sqlite.js +28 -0
  79. package/src/lib/tokenStore.js +43 -4
  80. package/src/lib/transport.js +53 -5
  81. package/src/lib/transportContract.js +3 -0
  82. package/src/lib/uploadRedactor.js +102 -0
  83. package/src/runner/applier.js +297 -0
  84. package/src/runner/bridge.js +289 -0
  85. package/src/runner/busIdentity.js +499 -0
  86. package/src/runner/capture.js +103 -0
  87. package/src/runner/captureMapping.js +21 -0
  88. package/src/runner/channelDiscipline.js +425 -0
  89. package/src/runner/deliveryStates.js +220 -0
  90. package/src/runner/facade.js +876 -0
  91. package/src/runner/hubCapture.js +160 -0
  92. package/src/runner/lease.js +46 -0
  93. package/src/runner/main.js +35 -0
  94. package/src/runner/render.js +91 -0
  95. package/src/runner/runnerChild.js +57 -0
  96. package/src/runner/runnerHub.js +104 -0
  97. package/src/runner/runnerProcess.js +124 -0
  98. package/src/runner/runnerSchema.js +51 -0
  99. package/src/runner/runnerService.js +149 -0
  100. package/src/runner/spool.js +86 -0
  101. package/src/runner/spoolInto.js +22 -0
  102. package/src/runner/statusPage.js +30 -0
@@ -0,0 +1,293 @@
1
+ import { mkdtemp, readFile, rm } 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 { HubError, scopeBlock } from './errors.js';
6
+ import { HubDbReader } from './hubDbReader.js';
7
+ import { HubScan } from './hubScan.js';
8
+ import { discoverLocalHub, LocalHubReader } from './localHub.js';
9
+ import { assertNodeFloor } from './nodeFloor.js';
10
+ import { pairedStatus } from './pairedConfig.js';
11
+ import { reconcilePasses } from './reconcile.js';
12
+ import { REDACTION_ROOTS_META, RunnerStore } from './runnerStore.js';
13
+ import {
14
+ channelRoots, IMPORT_REQUEST_FILE, importEnabled, PARITY_LOG_FILE, PARITY_REQUEST_FILE, RECONCILE_REQUEST_FILE, shadowEnabled,
15
+ } from './shadowService.js';
16
+ import { readOwnerOnlyJson, writeOwnerOnlyJson } from './storage.js';
17
+ import { UploadRedactor } from './uploadRedactor.js';
18
+
19
+ const POLL_MS = 1_000;
20
+
21
+ /** Per-source rows of a scan report, for the dry-run table. */
22
+ export function sourceRows(report) {
23
+ return Object.entries(report.sources).map(([source, stats]) => ({
24
+ source,
25
+ read: stats.read,
26
+ planned: stats.planned,
27
+ unchanged: stats.unchanged,
28
+ skipped: stats.skipped,
29
+ refused: stats.refused,
30
+ events: stats.events,
31
+ redactions: stats.redactions,
32
+ cut: stats.cut,
33
+ vanished: stats.vanished,
34
+ }));
35
+ }
36
+
37
+ function delta(before = {}, after = {}) {
38
+ const out = { written: 0, duplicate: 0, dead: 0 };
39
+ for (const source of new Set([...Object.keys(before), ...Object.keys(after)])) {
40
+ const was = before[source] || { written: 0, duplicate: 0, dead: {} };
41
+ const now = after[source] || { written: 0, duplicate: 0, dead: {} };
42
+ out.written += now.written - was.written;
43
+ out.duplicate += now.duplicate - was.duplicate;
44
+ out.dead += Object.values(now.dead).reduce((n, v) => n + v, 0) - Object.values(was.dead).reduce((n, v) => n + v, 0);
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * SCOPE-02: the daemon is blocked by a missing scope or grant since this request: say so now, with the fix, instead of
51
+ * waiting out the timeout while it retries.
52
+ */
53
+ function blockedForScopes(state, requestedAt) {
54
+ if (state?.state !== 'blocked' || !(state.at >= requestedAt) || !state.fix) return;
55
+ const block = scopeBlock({ code: state.code, details: { missingScopes: state.missingScopes } });
56
+ if (!block) return;
57
+ const missing = block.missingScopes.length ? `lacks ${block.missingScopes.join(', ')}` : `was refused (${block.code})`;
58
+ throw new HubError(block.code, `Pulse is blocked: this machine's sign-in or grant ${missing}. Run \`${block.fix}\`, then try again.`, {
59
+ details: { missingScopes: block.missingScopes, fix: block.fix },
60
+ });
61
+ }
62
+
63
+ /**
64
+ * The `pulse import`, `pulse shadow` and `pulse parity` commands. Only the dry run reads the hub here; a real import,
65
+ * its reconcile, the shadow and parity checks run in the daemon on its own connector session, asked for through
66
+ * request files in the config directory and followed through runner.db.
67
+ */
68
+ export class CloudFirstCommands {
69
+ constructor({ configDir, now = Date.now, wait = sleep, home = os.homedir(), discover = discoverLocalHub, tmpDir = os.tmpdir() }) {
70
+ this.configDir = configDir;
71
+ this.configFile = path.join(configDir, 'config.json');
72
+ this.now = now;
73
+ this.wait = wait;
74
+ this.home = home;
75
+ this.discover = discover;
76
+ this.tmpDir = tmpDir;
77
+ }
78
+
79
+ async #config() {
80
+ const config = await readOwnerOnlyJson(this.configFile);
81
+ if (!config) throw new HubError('HUB_NOT_INSTALLED', 'Pulse is not installed here; run `agentpulse pulse install`');
82
+ return config;
83
+ }
84
+
85
+ /**
86
+ * Scan every source as an import would, against a copy of runner.db (or an empty store), and report what it would
87
+ * send. Reads the hub only; writes nothing to it, to runner.db or to Pulse.
88
+ */
89
+ async dryRun() {
90
+ assertNodeFloor();
91
+ const config = await this.#config();
92
+ const hub = await this.discover({ config });
93
+ const scratch = await mkdtemp(path.join(this.tmpDir, 'pulse-import-dry-'));
94
+ try {
95
+ const copied = RunnerStore.copy(this.configDir, path.join(scratch, 'runner.db'));
96
+ const store = await RunnerStore.open(scratch);
97
+ try {
98
+ const token = await new LocalHubReader({ dir: hub.dir, port: hub.port }).tokenValue().catch(() => null);
99
+ const redaction = new UploadRedactor({
100
+ workspace: path.dirname(hub.dir), home: this.home,
101
+ approvedRoots: (config.localRoots || []).map((root) => root.canonicalRoot).filter(Boolean).sort(),
102
+ });
103
+ redaction.seed(store.meta(REDACTION_ROOTS_META));
104
+ const scan = new HubScan({
105
+ store,
106
+ reader: new HubDbReader({ hubDir: hub.dir }),
107
+ hubDir: hub.dir,
108
+ roots: channelRoots(config, hub.dir),
109
+ redaction,
110
+ secrets: () => [token],
111
+ now: this.now,
112
+ });
113
+ const report = await scan.run({ mode: 'import' });
114
+ return {
115
+ command: 'import',
116
+ dryRun: true,
117
+ againstExistingStore: copied,
118
+ totals: report.totals,
119
+ sources: sourceRows(report),
120
+ presenceBeats: report.presence.length,
121
+ message: `Dry run: ${report.totals.read} rows read, ${report.totals.planned} would send ${report.totals.events} events `
122
+ + `(${report.totals.unchanged} unchanged, ${report.totals.skipped} skipped, ${report.totals.refused} refused, `
123
+ + `${report.totals.redactions} redactions). ${report.totals.balanced ? 'Every row is accounted for.' : 'ROWS DO NOT ADD UP.'}`,
124
+ };
125
+ } finally {
126
+ store.close();
127
+ }
128
+ } finally {
129
+ await rm(scratch, { recursive: true, force: true });
130
+ }
131
+ }
132
+
133
+ async #importConfig() {
134
+ const config = await this.#config();
135
+ if (!pairedStatus(config).ok) throw new HubError('HUB_NOT_PAIRED', 'Pair this machine first: `agentpulse pulse pair`');
136
+ if (!importEnabled(config)) {
137
+ await writeOwnerOnlyJson(this.configFile, { ...config, cloudFirst: { ...(config.cloudFirst || {}), import: true } });
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Ask the daemon for an import and follow it until it has fully drained (or only queue it, with `wait: false`). The
143
+ * daemon snapshots the sources first; `reuseSnapshot` imports the last snapshot again (a re-run that must write 0).
144
+ */
145
+ async import({ wait = true, reuseSnapshot = false, timeoutMs = 60 * 60_000, onProgress = () => {} } = {}) {
146
+ assertNodeFloor();
147
+ await this.#importConfig();
148
+ const requestedAt = this.now();
149
+ await writeOwnerOnlyJson(path.join(this.configDir, IMPORT_REQUEST_FILE), { requestedAt, reuseSnapshot });
150
+ if (!wait) return { command: 'import', requestedAt, state: 'requested', message: 'Import requested; the daemon runs it on its next pass.' };
151
+ const deadline = this.now() + timeoutMs;
152
+ for (;;) {
153
+ const store = RunnerStore.openReadOnly(this.configDir);
154
+ const job = store?.meta('import');
155
+ const state = store?.meta('shadow_state');
156
+ store?.close();
157
+ if (job?.requestedAt === requestedAt && job.phase === 'done') {
158
+ const sent = delta(job.before, job.after);
159
+ return {
160
+ command: 'import',
161
+ requestedAt,
162
+ state: 'done',
163
+ snapshot: job.snapshot ? { takenAt: new Date(job.snapshot.takenAt).toISOString(), files: job.snapshot.files, reused: job.reusedSnapshot } : null,
164
+ scan: job.scan?.totals,
165
+ cloud: sent,
166
+ message: `Import done${job.reusedSnapshot ? ' (same snapshot)' : ''}: ${job.scan?.totals?.planned ?? 0} rows planned, `
167
+ + `${sent.written} events written, ${sent.duplicate} already in Pulse, ${sent.dead} set aside.`,
168
+ };
169
+ }
170
+ blockedForScopes(state, requestedAt);
171
+ if (state?.state === 'blocked' || state?.state === 'error') onProgress({ state: state.state, code: state.code });
172
+ else onProgress({ state: job?.requestedAt === requestedAt ? job.phase : 'waiting for the daemon', pending: state?.pending ?? null });
173
+ if (this.now() >= deadline) throw new HubError('HUB_IMPORT_TIMEOUT', 'The import did not finish in time; it keeps running in the daemon. See `agentpulse pulse shadow status`.');
174
+ await this.wait(POLL_MS);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * ADR-4: ask the daemon to rescan the last import's snapshot and compare it with the cloud's view of this machine's
180
+ * log; returns the report, with `exitCode` 1 unless every family, channel and conversation is equal or differs only
181
+ * by retired channels and redacted items (CUT-02).
182
+ */
183
+ async reconcile({ timeoutMs = 10 * 60_000, onProgress = () => {} } = {}) {
184
+ assertNodeFloor();
185
+ await this.#importConfig();
186
+ const requestedAt = this.now();
187
+ await writeOwnerOnlyJson(path.join(this.configDir, RECONCILE_REQUEST_FILE), { requestedAt });
188
+ const deadline = this.now() + timeoutMs;
189
+ for (;;) {
190
+ const store = RunnerStore.openReadOnly(this.configDir);
191
+ const last = store?.meta('reconcile_last');
192
+ const state = store?.meta('shadow_state');
193
+ store?.close();
194
+ if (last?.requestedAt === requestedAt) {
195
+ return { command: 'import', reconcile: true, ...last, exitCode: reconcilePasses(last.status) ? 0 : 1, message: reconcileLine(last) };
196
+ }
197
+ blockedForScopes(state, requestedAt);
198
+ onProgress({ state: state?.state ?? 'waiting for the daemon', code: state?.code ?? null });
199
+ if (this.now() >= deadline) throw new HubError('HUB_RECONCILE_TIMEOUT', 'The daemon did not finish the reconcile in time; see `agentpulse pulse shadow status`.');
200
+ await this.wait(POLL_MS);
201
+ }
202
+ }
203
+
204
+ async shadow({ action }) {
205
+ const config = await this.#config();
206
+ switch (action) {
207
+ case 'on':
208
+ case 'off': {
209
+ if (action === 'on') assertNodeFloor();
210
+ const enable = action === 'on';
211
+ await writeOwnerOnlyJson(this.configFile, { ...config, cloudFirst: { ...(config.cloudFirst || {}), shadow: enable } });
212
+ return {
213
+ command: 'shadow',
214
+ enabled: enable,
215
+ message: enable
216
+ ? 'Shadow feed on: once your team has Pulse cloud-first on, the daemon imports on `agentpulse pulse import` and then tails the local hub into Pulse.'
217
+ : 'Shadow feed off: nothing more goes to the Pulse log from this machine. The mirror is unchanged.',
218
+ };
219
+ }
220
+ case 'status': {
221
+ const store = RunnerStore.openReadOnly(this.configDir);
222
+ const state = store?.meta('shadow_state') ?? { state: shadowEnabled(config) ? 'starting' : 'off' };
223
+ const out = {
224
+ command: 'shadow',
225
+ enabled: shadowEnabled(config),
226
+ state,
227
+ importedAt: store?.meta('imported_at') ?? null,
228
+ import: store?.meta('import') ?? null,
229
+ pending: store ? store.pendingCount() : 0,
230
+ counts: store ? store.counts() : {},
231
+ setAside: store ? store.deadRows({ limit: 10 }) : [],
232
+ parity: store?.meta('parity_last') ?? null,
233
+ reconcile: store?.meta('reconcile_last') ?? null,
234
+ };
235
+ store?.close();
236
+ out.message = `Shadow feed is ${out.enabled ? state.state : 'off'}${state.code ? ` (${state.code})` : ''}; ${out.pending} commands waiting.`;
237
+ return out;
238
+ }
239
+ default:
240
+ throw new HubError('HUB_SHADOW_ACTION_INVALID', `Unknown shadow action: ${action}`);
241
+ }
242
+ }
243
+
244
+ /** The last parity check, its history, or a fresh one (`now`), which the daemon runs and records. */
245
+ async parity({ hours = 6, now = false, timeoutMs = 120_000 } = {}) {
246
+ if (now) {
247
+ const requestedAt = this.now();
248
+ await writeOwnerOnlyJson(path.join(this.configDir, PARITY_REQUEST_FILE), { requestedAt, hours });
249
+ const deadline = this.now() + timeoutMs;
250
+ for (;;) {
251
+ const store = RunnerStore.openReadOnly(this.configDir);
252
+ const last = store?.meta('parity_last');
253
+ store?.close();
254
+ if (last?.requestedAt === requestedAt) return { command: 'parity', ...last, message: parityLine(last) };
255
+ if (this.now() >= deadline) throw new HubError('HUB_PARITY_TIMEOUT', 'The daemon did not run the parity check in time; is the shadow feed running?');
256
+ await this.wait(POLL_MS);
257
+ }
258
+ }
259
+ const store = RunnerStore.openReadOnly(this.configDir);
260
+ const last = store?.meta('parity_last') ?? null;
261
+ store?.close();
262
+ let history = [];
263
+ try {
264
+ history = (await readFile(path.join(this.configDir, PARITY_LOG_FILE), 'utf8')).split('\n').filter(Boolean).map((line) => JSON.parse(line))
265
+ .filter((line) => line.at >= this.now() - hours * 3_600_000);
266
+ } catch {
267
+ history = [];
268
+ }
269
+ return {
270
+ command: 'parity',
271
+ last,
272
+ history,
273
+ message: last ? `${parityLine(last)} ${history.length} checks in the last ${hours} h, ${history.filter((line) => line.status === 'pass').length} at 100 %.` : 'No parity check has run yet.',
274
+ };
275
+ }
276
+ }
277
+
278
+ const VERDICT = Object.freeze({ equal: 'EQUAL', explained: 'EQUAL (retired and redacted listed)', mismatch: 'MISMATCH' });
279
+
280
+ export function reconcileLine(report) {
281
+ if (report.status === 'no_import') return report.message;
282
+ const families = Object.values(report.families || {});
283
+ const equal = families.filter((row) => row.equal).length;
284
+ const classes = report.classes ? Object.entries(report.classes).map(([name, n]) => `${name} ${n}`).join(', ') : null;
285
+ return `Reconcile ${VERDICT[report.status] ?? 'MISMATCH'}: ${equal} of ${families.length} families equal, `
286
+ + `${report.mismatches.length} mismatches${classes ? ` (by class: ${classes})` : ''}; ${report.withheld.length} withheld and ${report.cut.length} cut, by key in the report; `
287
+ + `Pulse head ${report.head}, ${report.otherWriters?.entries ?? 0} entries by other writers not compared.`;
288
+ }
289
+
290
+ export function parityLine(report) {
291
+ if (!report) return 'No parity report.';
292
+ return `Parity ${report.status} at ${report.percent ?? '—'} % over ${report.hours} h (${new Date(report.at).toISOString()}).`;
293
+ }
@@ -1,2 +1,2 @@
1
- 88e39539d7d2b1775b4970a8aeed416ae6d7203c53df143093ea54d2014cdd31 connector-contract.json
1
+ ce1142e1e133c8aff45a999d0d4b67faa6177f420ddcd1b449af81f8e806d8d0 connector-contract.json
2
2
  7b7a59c08797c100b46436123dfcb09b517730357fa90cd0890da9b80308faf3 mirror-contract.json