@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.
Files changed (151) hide show
  1. package/README.md +115 -32
  2. package/package.json +14 -19
  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 +15 -0
  7. package/src/commands/pulse/hub-write.js +26 -0
  8. package/src/commands/pulse/import.js +62 -0
  9. package/src/commands/pulse/install.js +14 -0
  10. package/src/commands/pulse/log-write.js +26 -0
  11. package/src/commands/pulse/login.js +20 -0
  12. package/src/commands/pulse/mirror.js +27 -0
  13. package/src/commands/pulse/pair.js +38 -0
  14. package/src/commands/pulse/parity.js +20 -0
  15. package/src/commands/pulse/restart.js +14 -0
  16. package/src/commands/pulse/shadow.js +19 -0
  17. package/src/commands/pulse/start.js +6 -18
  18. package/src/commands/pulse/status.js +6 -6
  19. package/src/commands/pulse/stop.js +24 -0
  20. package/src/daemon/main.js +43 -0
  21. package/src/hub/core/auth.mjs +80 -0
  22. package/src/hub/core/changes.mjs +38 -0
  23. package/src/hub/core/conversations.mjs +121 -0
  24. package/src/hub/core/db.mjs +263 -0
  25. package/src/hub/core/edgeIds.mjs +9 -0
  26. package/src/hub/core/hub.mjs +20 -0
  27. package/src/hub/core/ids.mjs +39 -0
  28. package/src/hub/core/leases.mjs +55 -0
  29. package/src/hub/core/messaging.mjs +322 -0
  30. package/src/hub/core/registry.mjs +281 -0
  31. package/src/hub/core/ticks.mjs +58 -0
  32. package/src/hub/core/wake.mjs +125 -0
  33. package/src/hub/home.mjs +16 -0
  34. package/src/hub/hooks/hub-bridge.mjs +167 -0
  35. package/src/hub/keepawake/agent.mjs +96 -0
  36. package/src/hub/lib/brief.mjs +133 -0
  37. package/src/hub/lib/channels.mjs +268 -0
  38. package/src/hub/lib/chat.mjs +163 -0
  39. package/src/hub/lib/closure.mjs +92 -0
  40. package/src/hub/lib/collect-worker.mjs +20 -0
  41. package/src/hub/lib/collect.mjs +342 -0
  42. package/src/hub/lib/deliveries.mjs +222 -0
  43. package/src/hub/lib/git.mjs +93 -0
  44. package/src/hub/lib/me.mjs +123 -0
  45. package/src/hub/lib/personas.mjs +143 -0
  46. package/src/hub/lib/status.mjs +147 -0
  47. package/src/hub/lib/store.mjs +106 -0
  48. package/src/hub/lib/titles.mjs +29 -0
  49. package/src/hub/lib/transcript-feed.mjs +72 -0
  50. package/src/hub/lib/transcripts.mjs +364 -0
  51. package/src/hub/lib/writing.mjs +18 -0
  52. package/src/hub/projections/deliveries.mjs +31 -0
  53. package/src/hub/projections/graph.mjs +149 -0
  54. package/src/hub/runners/drivers.mjs +162 -0
  55. package/src/hub/workers/collector.mjs +128 -0
  56. package/src/hub/workers/manager.mjs +366 -0
  57. package/src/hub/workers/runner.mjs +145 -0
  58. package/src/lib/BaseCommand.js +23 -23
  59. package/src/lib/agentSessions.js +279 -0
  60. package/src/lib/application.js +704 -0
  61. package/src/lib/backoff.js +8 -0
  62. package/src/lib/budgets.js +85 -0
  63. package/src/lib/channelScanner.js +99 -0
  64. package/src/lib/cliDefaults.js +20 -0
  65. package/src/lib/clientInstaller.js +111 -0
  66. package/src/lib/cloudAuth.js +189 -0
  67. package/src/lib/cloudFirstCommands.js +293 -0
  68. package/src/lib/contract.sha256 +2 -0
  69. package/src/lib/cutover.js +376 -0
  70. package/src/lib/cutoverWiring.js +137 -0
  71. package/src/lib/daemonComposition.js +338 -0
  72. package/src/lib/daemonLock.js +57 -0
  73. package/src/lib/daemonLoop.js +72 -0
  74. package/src/lib/daemonProcess.js +380 -0
  75. package/src/lib/daemonRuntime.js +828 -0
  76. package/src/lib/doctorWarnings.js +53 -0
  77. package/src/lib/drivers.js +280 -0
  78. package/src/lib/errors.js +33 -0
  79. package/src/lib/graphWindows.js +149 -0
  80. package/src/lib/http.js +84 -0
  81. package/src/lib/hubActions.js +74 -0
  82. package/src/lib/hubDbReader.js +132 -0
  83. package/src/lib/hubMapping.js +108 -0
  84. package/src/lib/hubScan.js +611 -0
  85. package/src/lib/hubSnapshot.js +64 -0
  86. package/src/lib/identity.js +102 -0
  87. package/src/lib/intents.js +590 -0
  88. package/src/lib/ledger.js +535 -0
  89. package/src/lib/lifecycle.js +123 -0
  90. package/src/lib/localHub.js +217 -0
  91. package/src/lib/localHubWriter.js +69 -0
  92. package/src/lib/mirror-contract.json +4299 -0
  93. package/src/lib/mirrorContract.js +513 -0
  94. package/src/lib/mirrorDaemon.js +127 -0
  95. package/src/lib/mirrorService.js +365 -0
  96. package/src/lib/mirrorSource.js +430 -0
  97. package/src/lib/mirrorSync.js +353 -0
  98. package/src/lib/nodeFloor.js +19 -0
  99. package/src/lib/oauthLogin.js +132 -0
  100. package/src/lib/outboxFlusher.js +75 -0
  101. package/src/lib/ownerLock.js +99 -0
  102. package/src/lib/pairedConfig.js +40 -0
  103. package/src/lib/pairingLifecycle.js +179 -0
  104. package/src/lib/pathRedaction.js +89 -0
  105. package/src/lib/planContract.js +141 -0
  106. package/src/lib/priorityLane.js +80 -0
  107. package/src/lib/processOwnership.js +252 -0
  108. package/src/lib/processSupervisor.js +376 -0
  109. package/src/lib/reconcile.js +316 -0
  110. package/src/lib/reconcileView.js +129 -0
  111. package/src/lib/redaction.js +128 -0
  112. package/src/lib/rootPolicy.js +372 -0
  113. package/src/lib/rulePacks.js +162 -0
  114. package/src/lib/runnerStore.js +418 -0
  115. package/src/lib/secretStore.js +169 -0
  116. package/src/lib/selfRestart.js +261 -0
  117. package/src/lib/serviceManager.js +419 -0
  118. package/src/lib/shadowService.js +435 -0
  119. package/src/lib/sqlite.js +28 -0
  120. package/src/lib/storage.js +131 -0
  121. package/src/lib/subprocess.js +45 -0
  122. package/src/lib/tokenStore.js +136 -0
  123. package/src/lib/transport.js +334 -0
  124. package/src/lib/transportContract.js +40 -0
  125. package/src/lib/uploadRedactor.js +102 -0
  126. package/src/runner/applier.js +297 -0
  127. package/src/runner/bridge.js +289 -0
  128. package/src/runner/busIdentity.js +499 -0
  129. package/src/runner/capture.js +103 -0
  130. package/src/runner/captureMapping.js +21 -0
  131. package/src/runner/channelDiscipline.js +425 -0
  132. package/src/runner/deliveryStates.js +220 -0
  133. package/src/runner/facade.js +876 -0
  134. package/src/runner/hubCapture.js +160 -0
  135. package/src/runner/lease.js +46 -0
  136. package/src/runner/main.js +35 -0
  137. package/src/runner/render.js +91 -0
  138. package/src/runner/runnerChild.js +57 -0
  139. package/src/runner/runnerHub.js +104 -0
  140. package/src/runner/runnerProcess.js +124 -0
  141. package/src/runner/runnerSchema.js +51 -0
  142. package/src/runner/runnerService.js +140 -0
  143. package/src/runner/spool.js +86 -0
  144. package/src/runner/spoolInto.js +22 -0
  145. package/src/runner/statusPage.js +30 -0
  146. package/LICENSE +0 -21
  147. package/src/commands/pulse/gate.js +0 -25
  148. package/src/commands/pulse/next.js +0 -14
  149. package/src/commands/pulse/submit.js +0 -21
  150. package/src/commands/pulse/verify.js +0 -21
  151. package/src/runPulse.js +0 -58
@@ -0,0 +1,418 @@
1
+ import { chmod } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { HubError } from './errors.js';
4
+ import { openDatabase } from './sqlite.js';
5
+ import { ensureOwnerOnlyDirectory } from './storage.js';
6
+
7
+ export const RUNNER_DB_FILE = 'runner.db';
8
+ /** The meta key of the cutover backup the hub tables were seeded from: `{ file, sha256, at, counts, rolledBackAt? }`. */
9
+ export const SEED_META = 'seeded_from';
10
+ /** The meta key of the repo and worktree roots the upload redactor learned, `{ repo, worktree }`: a union that never shrinks. */
11
+ export const REDACTION_ROOTS_META = 'redaction_roots';
12
+ // §6.2: the pending backlog is capped; acknowledged keys live in `sent` and do not count against it.
13
+ export const OUTBOX_MAX_PENDING = 50_000;
14
+
15
+ const SCHEMA = [
16
+ 'CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)',
17
+ 'CREATE TABLE IF NOT EXISTS cursor (source TEXT PRIMARY KEY, position TEXT NOT NULL)',
18
+ `CREATE TABLE IF NOT EXISTS outbox (
19
+ ord INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ key TEXT NOT NULL UNIQUE,
21
+ grp TEXT NOT NULL,
22
+ type TEXT NOT NULL,
23
+ payload TEXT NOT NULL,
24
+ source TEXT NOT NULL,
25
+ attempts INTEGER NOT NULL DEFAULT 0,
26
+ next_at INTEGER NOT NULL DEFAULT 0,
27
+ last_error TEXT,
28
+ enqueued_at INTEGER NOT NULL)`,
29
+ 'CREATE INDEX IF NOT EXISTS outbox_grp ON outbox (grp, ord)',
30
+ `CREATE TABLE IF NOT EXISTS sent (
31
+ key TEXT PRIMARY KEY, source TEXT NOT NULL, type TEXT NOT NULL, seq TEXT, duplicate INTEGER NOT NULL, at INTEGER NOT NULL)`,
32
+ 'CREATE INDEX IF NOT EXISTS sent_at ON sent (at)',
33
+ `CREATE TABLE IF NOT EXISTS dead (
34
+ key TEXT PRIMARY KEY, grp TEXT NOT NULL, type TEXT NOT NULL, payload TEXT NOT NULL, source TEXT NOT NULL,
35
+ code TEXT NOT NULL, message TEXT, at INTEGER NOT NULL)`,
36
+ `CREATE TABLE IF NOT EXISTS version (
37
+ entity TEXT PRIMARY KEY, sha TEXT NOT NULL, raw_sha TEXT, static_sha TEXT, key TEXT NOT NULL, at INTEGER NOT NULL)`,
38
+ 'CREATE TABLE IF NOT EXISTS version_seen (entity TEXT NOT NULL, sha TEXT NOT NULL, count INTEGER NOT NULL, PRIMARY KEY (entity, sha))',
39
+ `CREATE TABLE IF NOT EXISTS receipt_state (
40
+ message_id TEXT NOT NULL, recipient TEXT NOT NULL, state TEXT NOT NULL, PRIMARY KEY (message_id, recipient))`,
41
+ 'CREATE TABLE IF NOT EXISTS recent_message (id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, grp TEXT NOT NULL)',
42
+ `CREATE TABLE IF NOT EXISTS ingested (
43
+ channel TEXT NOT NULL, ordinal INTEGER NOT NULL, sha TEXT NOT NULL, post_key TEXT NOT NULL, at INTEGER NOT NULL,
44
+ PRIMARY KEY (channel, ordinal))`,
45
+ 'CREATE TABLE IF NOT EXISTS presence_sent (actor_id TEXT PRIMARY KEY, last_seen INTEGER NOT NULL, sent_at INTEGER NOT NULL)',
46
+ ];
47
+
48
+ const TERMINAL_RECEIPT = new Set(['acked', 'expired']);
49
+
50
+ /**
51
+ * The runner's local store (design §6.2), `runner.db` in the connector's config directory: per-source scan cursors,
52
+ * the durable outbox of commands waiting for the cloud, the keys the cloud acknowledged, dead letters, and what the
53
+ * scanners last saw of each mutable entity. node:sqlite with WAL and synchronous=FULL; owner-only.
54
+ */
55
+ export class RunnerStore {
56
+ static async open(directory, { now = Date.now, maxPending = OUTBOX_MAX_PENDING } = {}) {
57
+ await ensureOwnerOnlyDirectory(directory);
58
+ const file = path.join(directory, RUNNER_DB_FILE);
59
+ const db = openDatabase(file);
60
+ const run = (sql) => db.prepare(sql).run();
61
+ run('PRAGMA journal_mode = WAL');
62
+ run('PRAGMA synchronous = FULL');
63
+ run('PRAGMA busy_timeout = 5000');
64
+ for (const statement of SCHEMA) run(statement);
65
+ await chmod(file, 0o600);
66
+ return new RunnerStore({ db, file, now, maxPending });
67
+ }
68
+
69
+ /** The store as the daemon left it, for status reads; null when there is none. Writes through it fail. */
70
+ static openReadOnly(directory, { now = Date.now } = {}) {
71
+ const file = path.join(directory, RUNNER_DB_FILE);
72
+ let db;
73
+ try {
74
+ db = openDatabase(file, { readOnly: true });
75
+ } catch {
76
+ return null;
77
+ }
78
+ db.prepare('PRAGMA busy_timeout = 5000').run();
79
+ return new RunnerStore({ db, file, now, maxPending: OUTBOX_MAX_PENDING });
80
+ }
81
+
82
+ /** A consistent copy of the store in `target` (a file that must not exist), taken without blocking its writer. */
83
+ static copy(directory, target) {
84
+ const source = RunnerStore.openReadOnly(directory);
85
+ if (!source) return false;
86
+ try {
87
+ source.db.prepare('VACUUM INTO ?').run(target);
88
+ return true;
89
+ } finally {
90
+ source.close();
91
+ }
92
+ }
93
+
94
+ constructor({ db, file, now, maxPending }) {
95
+ this.db = db;
96
+ this.file = file;
97
+ this.now = now;
98
+ this.maxPending = maxPending;
99
+ this.depth = 0;
100
+ }
101
+
102
+ close() {
103
+ this.db.close();
104
+ }
105
+
106
+ /**
107
+ * Run `fn` in one immediate transaction: everything it enqueues and every cursor it moves commit together. Inside a
108
+ * transaction already open on this connection (the runner's capture) it joins that one.
109
+ */
110
+ transaction(fn) {
111
+ if (this.depth > 0 || this.db.isTransaction) return fn();
112
+ this.db.prepare('BEGIN IMMEDIATE').run();
113
+ this.depth += 1;
114
+ try {
115
+ const out = fn();
116
+ this.db.prepare('COMMIT').run();
117
+ return out;
118
+ } catch (error) {
119
+ this.db.prepare('ROLLBACK').run();
120
+ throw error;
121
+ } finally {
122
+ this.depth -= 1;
123
+ }
124
+ }
125
+
126
+ meta(key) {
127
+ const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
128
+ return row ? JSON.parse(row.value) : null;
129
+ }
130
+
131
+ setMeta(key, value) {
132
+ this.db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value')
133
+ .run(key, JSON.stringify(value));
134
+ }
135
+
136
+ cursor(source) {
137
+ const row = this.db.prepare('SELECT position FROM cursor WHERE source = ?').get(source);
138
+ return row ? JSON.parse(row.position) : null;
139
+ }
140
+
141
+ setCursor(source, position) {
142
+ this.db.prepare('INSERT INTO cursor (source, position) VALUES (?, ?) ON CONFLICT (source) DO UPDATE SET position = excluded.position')
143
+ .run(source, JSON.stringify(position));
144
+ }
145
+
146
+ /** Whether a key was ever enqueued: pending, acknowledged or dead. */
147
+ known(key) {
148
+ return Boolean(this.db.prepare(
149
+ 'SELECT 1 AS found FROM outbox WHERE key = ? UNION ALL SELECT 1 FROM sent WHERE key = ? UNION ALL SELECT 1 FROM dead WHERE key = ? LIMIT 1',
150
+ ).get(key, key, key));
151
+ }
152
+
153
+ pendingCount() {
154
+ return Number(this.db.prepare('SELECT COUNT(*) AS n FROM outbox').get().n);
155
+ }
156
+
157
+ /**
158
+ * Queue commands (`{ key, group, type, payload, source }`), skipping any key already known. Returns how many were new.
159
+ * Past the pending cap it refuses the whole call with pulse_backlog, so the caller's cursor does not move either.
160
+ */
161
+ enqueue(entries) {
162
+ return this.transaction(() => {
163
+ const insert = this.db.prepare(
164
+ `INSERT INTO outbox (key, grp, type, payload, source, enqueued_at)
165
+ SELECT ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM sent WHERE key = ?) AND NOT EXISTS (SELECT 1 FROM dead WHERE key = ?)
166
+ ON CONFLICT (key) DO NOTHING`,
167
+ );
168
+ let added = 0;
169
+ for (const entry of entries) {
170
+ added += Number(insert.run(entry.key, entry.group, entry.type, JSON.stringify(entry.payload), entry.source, this.now(), entry.key, entry.key).changes);
171
+ }
172
+ if (added && this.pendingCount() > this.maxPending) {
173
+ throw new HubError('pulse_backlog', `More than ${this.maxPending} commands are waiting for Pulse`);
174
+ }
175
+ return added;
176
+ });
177
+ }
178
+
179
+ /**
180
+ * The next batch to send: the oldest due rows in enqueue order, at most `max` rows and `maxBytes` of payload. A row
181
+ * behind an earlier row of its own group that is not due (backing off) waits for it, so each group keeps its order;
182
+ * other groups go on.
183
+ */
184
+ nextBatch({ max = 100, maxBytes = 480 * 1024 } = {}) {
185
+ const now = this.now();
186
+ const blocked = new Set();
187
+ const batch = [];
188
+ const corrupt = [];
189
+ let bytes = 0;
190
+ for (const row of this.db.prepare('SELECT ord, key, grp, type, payload, source, attempts, next_at FROM outbox ORDER BY ord').iterate()) {
191
+ if (blocked.has(row.grp)) continue;
192
+ if (Number(row.next_at) > now) {
193
+ blocked.add(row.grp);
194
+ continue;
195
+ }
196
+ const size = Buffer.byteLength(row.payload, 'utf8') + row.key.length + row.type.length + 64;
197
+ if (batch.length && bytes + size > maxBytes) break;
198
+ let payload;
199
+ try {
200
+ payload = JSON.parse(row.payload);
201
+ } catch {
202
+ corrupt.push(row.key);
203
+ blocked.add(row.grp);
204
+ continue;
205
+ }
206
+ batch.push({ key: row.key, group: row.grp, type: row.type, payload, source: row.source, attempts: Number(row.attempts) });
207
+ bytes += size;
208
+ if (batch.length >= max) break;
209
+ }
210
+ for (const key of corrupt) this.dead(key, { code: 'corrupt_row', message: 'The stored payload could not be read' });
211
+ return batch;
212
+ }
213
+
214
+ /** The cloud stored (or already had) this key. */
215
+ acked(key, { seq = null, duplicate = false } = {}) {
216
+ this.transaction(() => {
217
+ const row = this.db.prepare('SELECT source, type FROM outbox WHERE key = ?').get(key);
218
+ if (!row) return;
219
+ this.db.prepare('INSERT INTO sent (key, source, type, seq, duplicate, at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (key) DO NOTHING')
220
+ .run(key, row.source, row.type, seq === null ? null : String(seq), duplicate ? 1 : 0, this.now());
221
+ this.db.prepare('DELETE FROM outbox WHERE key = ?').run(key);
222
+ });
223
+ }
224
+
225
+ /** A retryable refusal: the row stays first in its group and is due again after `delayMs`. */
226
+ retry(key, { code, delayMs }) {
227
+ this.db.prepare('UPDATE outbox SET attempts = attempts + 1, next_at = ?, last_error = ? WHERE key = ?').run(this.now() + delayMs, code, key);
228
+ }
229
+
230
+ /** Held behind an earlier command of its group: due again after `delayMs`, without counting an attempt. */
231
+ hold(key, { code, delayMs }) {
232
+ this.db.prepare('UPDATE outbox SET next_at = ?, last_error = ? WHERE key = ?').run(this.now() + delayMs, code, key);
233
+ }
234
+
235
+ /** A refusal that repeating cannot fix: the row leaves the outbox with its reason. */
236
+ dead(key, { code, message = null }) {
237
+ this.transaction(() => {
238
+ const row = this.db.prepare('SELECT grp, type, payload, source FROM outbox WHERE key = ?').get(key);
239
+ if (!row) return;
240
+ this.db.prepare(
241
+ `INSERT INTO dead (key, grp, type, payload, source, code, message, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
242
+ ON CONFLICT (key) DO UPDATE SET code = excluded.code, message = excluded.message, at = excluded.at`,
243
+ ).run(key, row.grp, row.type, row.payload, row.source, code, message, this.now());
244
+ this.db.prepare('DELETE FROM outbox WHERE key = ?').run(key);
245
+ });
246
+ }
247
+
248
+ /** A command refused before it was queued (it never reaches the cloud), recorded with its reason. */
249
+ refuse(entry, { code, message = null }) {
250
+ this.db.prepare(
251
+ `INSERT INTO dead (key, grp, type, payload, source, code, message, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
252
+ ON CONFLICT (key) DO NOTHING`,
253
+ ).run(entry.key, entry.group, entry.type, JSON.stringify(entry.payload ?? null), entry.source, code, message, this.now());
254
+ }
255
+
256
+ /** The code a key was refused or dead-lettered with, or null. */
257
+ deadCode(key) {
258
+ return this.db.prepare('SELECT code FROM dead WHERE key = ?').get(key)?.code ?? null;
259
+ }
260
+
261
+ /** Every queued command in queue order, payload parsed (a reconcile reads a scratch store's whole scan this way). */
262
+ planned() {
263
+ return this.db.prepare('SELECT key, grp, type, payload, source FROM outbox ORDER BY ord').all()
264
+ .map((row) => ({ key: row.key, group: row.grp, type: row.type, payload: JSON.parse(row.payload), source: row.source }));
265
+ }
266
+
267
+ /** Every set-aside key with its code (all of them; deadRows is the newest few). */
268
+ deadKeys() {
269
+ return this.db.prepare('SELECT key, type, source, code FROM dead ORDER BY key').all().map((row) => ({ ...row }));
270
+ }
271
+
272
+ deadRows({ limit = 20 } = {}) {
273
+ return this.db.prepare('SELECT key, type, source, code, message, at FROM dead ORDER BY at DESC LIMIT ?').all(limit).map((row) => ({ ...row }));
274
+ }
275
+
276
+ /** Counts per source: pending, sent (written or duplicate) and dead (by code). */
277
+ counts() {
278
+ const out = {};
279
+ const at = (source) => (out[source] ||= { pending: 0, written: 0, duplicate: 0, dead: {} });
280
+ for (const row of this.db.prepare('SELECT source, COUNT(*) AS n FROM outbox GROUP BY source').all()) at(row.source).pending = Number(row.n);
281
+ for (const row of this.db.prepare('SELECT source, duplicate, COUNT(*) AS n FROM sent GROUP BY source, duplicate').all()) {
282
+ at(row.source)[row.duplicate ? 'duplicate' : 'written'] = Number(row.n);
283
+ }
284
+ for (const row of this.db.prepare('SELECT source, code, COUNT(*) AS n FROM dead GROUP BY source, code').all()) at(row.source).dead[row.code] = Number(row.n);
285
+ return out;
286
+ }
287
+
288
+ version(entity) {
289
+ const row = this.db.prepare('SELECT sha, raw_sha, static_sha, key, at FROM version WHERE entity = ?').get(entity);
290
+ return row ? { sha: row.sha, rawSha: row.raw_sha, staticSha: row.static_sha, key: row.key, at: Number(row.at) } : null;
291
+ }
292
+
293
+ /**
294
+ * The key for this content of an entity: `<prefix><sep><sha>` the first time this content is sent, and
295
+ * `<prefix><sep><sha>.<k>` when it returns after another version (A→B→A), so the return is a new event that applies.
296
+ */
297
+ versionKey(entity, prefix, sha, separator = ':') {
298
+ const seen = this.db.prepare('SELECT count FROM version_seen WHERE entity = ? AND sha = ?').get(entity, sha);
299
+ const count = seen ? Number(seen.count) : 0;
300
+ return count === 0 ? `${prefix}${separator}${sha}` : `${prefix}${separator}${sha}.${count}`;
301
+ }
302
+
303
+ /** Record that this content of an entity was queued under `key`. */
304
+ setVersion(entity, { sha, rawSha = null, staticSha = null, key }) {
305
+ const last = this.version(entity);
306
+ if (last?.sha !== sha) {
307
+ this.db.prepare(
308
+ `INSERT INTO version_seen (entity, sha, count) VALUES (?, ?, 1)
309
+ ON CONFLICT (entity, sha) DO UPDATE SET count = count + 1`,
310
+ ).run(entity, sha);
311
+ }
312
+ this.db.prepare(
313
+ `INSERT INTO version (entity, sha, raw_sha, static_sha, key, at) VALUES (?, ?, ?, ?, ?, ?)
314
+ ON CONFLICT (entity) DO UPDATE SET sha = excluded.sha, raw_sha = excluded.raw_sha, static_sha = excluded.static_sha,
315
+ key = excluded.key, at = excluded.at`,
316
+ ).run(entity, sha, rawSha, staticSha, key, this.now());
317
+ }
318
+
319
+ /** The raw form last read, without changing what was sent (an unchanged row skips redaction next time). */
320
+ touchVersion(entity, rawSha) {
321
+ this.db.prepare('UPDATE version SET raw_sha = ? WHERE entity = ?').run(rawSha, entity);
322
+ }
323
+
324
+ dropVersion(entity) {
325
+ this.db.prepare('DELETE FROM version WHERE entity = ?').run(entity);
326
+ }
327
+
328
+ entities(prefix) {
329
+ return this.db.prepare("SELECT entity FROM version WHERE entity >= ? AND entity < ? || char(1114111)").all(prefix, prefix).map((row) => row.entity);
330
+ }
331
+
332
+ /** The receipt state last queued for each recipient row still able to change (not acked or expired). */
333
+ openReceipts() {
334
+ return this.db.prepare("SELECT message_id, recipient, state FROM receipt_state WHERE state NOT IN ('acked', 'expired')").all()
335
+ .map((row) => ({ messageId: row.message_id, recipient: row.recipient, state: row.state }));
336
+ }
337
+
338
+ receiptState(messageId, recipient) {
339
+ return this.db.prepare('SELECT state FROM receipt_state WHERE message_id = ? AND recipient = ?').get(messageId, recipient)?.state ?? null;
340
+ }
341
+
342
+ setReceiptState(messageId, recipient, state) {
343
+ this.db.prepare(
344
+ `INSERT INTO receipt_state (message_id, recipient, state) VALUES (?, ?, ?)
345
+ ON CONFLICT (message_id, recipient) DO UPDATE SET state = excluded.state`,
346
+ ).run(messageId, recipient, state);
347
+ }
348
+
349
+ dropReceipts(messageId) {
350
+ this.db.prepare('DELETE FROM receipt_state WHERE message_id = ?').run(messageId);
351
+ }
352
+
353
+ rememberMessage(id, createdAt, group) {
354
+ this.db.prepare('INSERT INTO recent_message (id, created_at, grp) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING').run(id, createdAt, group);
355
+ }
356
+
357
+ recentMessages(sinceMs) {
358
+ return this.db.prepare('SELECT id, created_at, grp FROM recent_message WHERE created_at >= ? ORDER BY created_at').all(sinceMs)
359
+ .map((row) => ({ id: row.id, createdAt: Number(row.created_at), group: row.grp }));
360
+ }
361
+
362
+ forgetMessage(id) {
363
+ this.db.prepare('DELETE FROM recent_message WHERE id = ?').run(id);
364
+ }
365
+
366
+ pruneRecentMessages(beforeMs) {
367
+ this.db.prepare('DELETE FROM recent_message WHERE created_at < ?').run(beforeMs);
368
+ }
369
+
370
+ ingested(channel) {
371
+ return this.db.prepare('SELECT ordinal, sha, post_key, at FROM ingested WHERE channel = ? ORDER BY ordinal').all(channel)
372
+ .map((row) => ({ ordinal: Number(row.ordinal), sha: row.sha, postKey: row.post_key, at: Number(row.at) }));
373
+ }
374
+
375
+ /** The key a channel's block (its digest and occurrence) was first sent under, or null. */
376
+ ingestedKey(channel, sha) {
377
+ return this.db.prepare('SELECT post_key FROM ingested WHERE channel = ? AND sha = ?').get(channel, sha)?.post_key ?? null;
378
+ }
379
+
380
+ setIngested(channel, { ordinal, sha, postKey, at }) {
381
+ this.db.prepare(
382
+ `INSERT INTO ingested (channel, ordinal, sha, post_key, at) VALUES (?, ?, ?, ?, ?)
383
+ ON CONFLICT (channel, ordinal) DO UPDATE SET sha = excluded.sha, post_key = excluded.post_key, at = excluded.at`,
384
+ ).run(channel, ordinal, sha, postKey, at);
385
+ }
386
+
387
+ /** What one channel file holds now, by position, replacing what was recorded before. */
388
+ replaceIngested(channel, rows) {
389
+ this.transaction(() => {
390
+ this.db.prepare('DELETE FROM ingested WHERE channel = ?').run(channel);
391
+ for (const row of rows) this.setIngested(channel, row);
392
+ });
393
+ }
394
+
395
+ presenceSent(actorId) {
396
+ const row = this.db.prepare('SELECT last_seen, sent_at FROM presence_sent WHERE actor_id = ?').get(actorId);
397
+ return row ? { lastSeen: Number(row.last_seen), sentAt: Number(row.sent_at) } : null;
398
+ }
399
+
400
+ setPresenceSent(actorId, lastSeen) {
401
+ this.db.prepare(
402
+ `INSERT INTO presence_sent (actor_id, last_seen, sent_at) VALUES (?, ?, ?)
403
+ ON CONFLICT (actor_id) DO UPDATE SET last_seen = excluded.last_seen, sent_at = excluded.sent_at`,
404
+ ).run(actorId, lastSeen, this.now());
405
+ }
406
+
407
+ /** Whether the cloud acknowledged `key` as a command of `type`. */
408
+ sentAs(key, type) {
409
+ return Boolean(this.db.prepare('SELECT 1 AS found FROM sent WHERE key = ? AND type = ?').get(key, type));
410
+ }
411
+
412
+ /** Acknowledged keys older than `beforeMs` go; the cloud's dedupe is the backstop for anything resent after that. */
413
+ pruneSent(beforeMs) {
414
+ return Number(this.db.prepare('DELETE FROM sent WHERE at < ?').run(beforeMs).changes);
415
+ }
416
+ }
417
+
418
+ export { TERMINAL_RECEIPT };
@@ -0,0 +1,169 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import os from 'node:os';
3
+ import { HubError } from './errors.js';
4
+ import { spawnBounded } from './subprocess.js';
5
+
6
+ const SERVICE = 'com.agentpulse.hub';
7
+ /** A Keychain or libsecret call normally takes well under a second; one that hangs (a locked keychain) is killed. */
8
+ export const SECRET_COMMAND_TIMEOUT_MS = 10_000;
9
+
10
+ /** What a secret-command error may carry: the command's output can be a secret, so only how it ended. */
11
+ const exitOnly = ({ code, signal }) => ({ code, signal });
12
+
13
+ export async function runSecretCommand(bin, args, { input = '', env = process.env, timeoutMs = SECRET_COMMAND_TIMEOUT_MS } = {}) {
14
+ let result;
15
+ try {
16
+ result = await spawnBounded(bin, args, {
17
+ input,
18
+ env,
19
+ timeoutMs,
20
+ onTimeout: () => new HubError('HUB_SECRET_STORE_TIMEOUT', `${bin} did not answer within ${timeoutMs} ms and was stopped`),
21
+ });
22
+ } catch (error) {
23
+ if (error.code === 'ENOENT') {
24
+ throw new HubError(
25
+ 'HUB_SECRET_STORE_UNAVAILABLE',
26
+ `${bin} is not installed; Pulse stores sign-in and keys only in the OS keychain (install libsecret's secret-tool on Linux)`,
27
+ { cause: error },
28
+ );
29
+ }
30
+ if (error.result) error.result = exitOnly(error.result);
31
+ throw error;
32
+ }
33
+ if (result.code === 0) return result;
34
+ const error = new HubError('HUB_SECRET_STORE_FAILED', `${bin} failed with exit ${result.code}`);
35
+ error.result = exitOnly(result);
36
+ throw error;
37
+ }
38
+
39
+ const SECURITY = '/usr/bin/security';
40
+ const CHUNKED_PREFIX = 'b64chunks:';
41
+ const CHUNK_CHARACTERS = 1024;
42
+ const KEYCHAIN_NAME = /^[A-Za-z0-9._@-]+$/;
43
+
44
+ export class KeychainSecretStore {
45
+ constructor({ execute = runSecretCommand, account = os.userInfo().username } = {}) {
46
+ if (!KEYCHAIN_NAME.test(account)) {
47
+ throw new HubError('HUB_SECRET_STORE_UNAVAILABLE', 'The macOS account name cannot be used as a Keychain account');
48
+ }
49
+ this.execute = execute;
50
+ this.account = account;
51
+ }
52
+
53
+ #service(reference) {
54
+ const service = `${SERVICE}.${reference}`;
55
+ if (!KEYCHAIN_NAME.test(service)) throw new HubError('HUB_SECRET_STORE_FAILED', 'Invalid Keychain item name');
56
+ return service;
57
+ }
58
+
59
+ async #find(service) {
60
+ try {
61
+ const result = await this.execute(SECURITY, ['find-generic-password', '-a', this.account, '-s', service, '-w']);
62
+ return result.stdout.replace(/\n$/, '');
63
+ } catch (error) {
64
+ if (error.result?.code === 44) return null;
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ async #remove(service) {
70
+ try {
71
+ await this.execute(SECURITY, ['delete-generic-password', '-a', this.account, '-s', service]);
72
+ } catch (error) {
73
+ if (error.result?.code !== 44) throw error;
74
+ }
75
+ }
76
+
77
+ async #write(entries) {
78
+ const lines = entries.map(([service, data]) => `add-generic-password -U -a "${this.account}" -s "${service}" -w ${data}`);
79
+ await this.execute(SECURITY, ['-i'], { input: `${lines.join('\n')}\n` });
80
+ }
81
+
82
+ static #parts(service, header) {
83
+ if (!header?.startsWith(CHUNKED_PREFIX)) return null;
84
+ const [generation, count] = header.slice(CHUNKED_PREFIX.length).split(':');
85
+ return Array.from({ length: Number(count) || 0 }, (_unused, index) => `${service}.${generation}.${index}`);
86
+ }
87
+
88
+ async get(reference) {
89
+ const service = this.#service(reference);
90
+ const header = await this.#find(service);
91
+ const parts = KeychainSecretStore.#parts(service, header);
92
+ if (!parts) return header;
93
+ const chunks = [];
94
+ for (const part of parts) {
95
+ const chunk = await this.#find(part);
96
+ if (chunk === null) throw new HubError('HUB_SECRET_STORE_CORRUPT', 'A Keychain secret is incomplete');
97
+ chunks.push(chunk);
98
+ }
99
+ return Buffer.from(chunks.join(''), 'base64').toString('utf8');
100
+ }
101
+
102
+ async set(reference, value) {
103
+ const service = this.#service(reference);
104
+ const previous = KeychainSecretStore.#parts(service, await this.#find(service)) || [];
105
+ const encoded = Buffer.from(String(value), 'utf8').toString('base64');
106
+ const chunks = encoded.match(new RegExp(`.{1,${CHUNK_CHARACTERS}}`, 'g')) || [];
107
+ const generation = randomBytes(6).toString('hex');
108
+ if (chunks.length) {
109
+ await this.#write(chunks.map((chunk, index) => [`${service}.${generation}.${index}`, chunk]));
110
+ }
111
+ await this.#write([[service, `${CHUNKED_PREFIX}${generation}:${chunks.length}`]]);
112
+ if ((await this.get(reference)) !== String(value)) {
113
+ throw new HubError('HUB_SECRET_STORE_FAILED', 'The Keychain did not store the secret intact');
114
+ }
115
+ for (const part of previous) await this.#remove(part);
116
+ }
117
+
118
+ async delete(reference) {
119
+ const service = this.#service(reference);
120
+ const parts = KeychainSecretStore.#parts(service, await this.#find(service)) || [];
121
+ await this.#remove(service);
122
+ for (const part of parts) await this.#remove(part);
123
+ }
124
+ }
125
+
126
+ export class LibsecretStore {
127
+ constructor({ execute = runSecretCommand } = {}) {
128
+ this.execute = execute;
129
+ }
130
+
131
+ async get(reference) {
132
+ try {
133
+ const result = await this.execute('secret-tool', [
134
+ 'lookup',
135
+ 'service',
136
+ SERVICE,
137
+ 'reference',
138
+ reference,
139
+ ]);
140
+ return result.stdout.replace(/\n$/, '');
141
+ } catch (error) {
142
+ if (error.result?.code === 1) return null;
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ async set(reference, value) {
148
+ await this.execute(
149
+ 'secret-tool',
150
+ ['store', '--label', 'AgentPulse Pulse connector', 'service', SERVICE, 'reference', reference],
151
+ { input: String(value) }
152
+ );
153
+ }
154
+
155
+ async delete(reference) {
156
+ await this.execute('secret-tool', ['clear', 'service', SERVICE, 'reference', reference]);
157
+ }
158
+ }
159
+
160
+ export function defaultSecretStore(platform = process.platform) {
161
+ switch (platform) {
162
+ case 'darwin':
163
+ return new KeychainSecretStore();
164
+ case 'linux':
165
+ return new LibsecretStore();
166
+ default:
167
+ throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Pulse supports macOS and Linux, not ${platform}`);
168
+ }
169
+ }