@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
@@ -457,7 +457,7 @@ function project(schema, value, depth) {
457
457
  return INVALID;
458
458
  }
459
459
 
460
- function leaksPath(value) {
460
+ export function leaksPath(value) {
461
461
  if (typeof value === 'string') return pathRejected(value);
462
462
  if (Array.isArray(value)) return value.some(leaksPath);
463
463
  if (value && typeof value === 'object') return Object.entries(value).some(([key, entry]) => pathRejected(key) || leaksPath(entry));
@@ -56,6 +56,7 @@ export class MirrorService {
56
56
  this.windowsRunning = false;
57
57
  this.windowsDeferredGapMs = null;
58
58
  this.windowsChanged = false;
59
+ this.echoDuringWindows = false;
59
60
  this.reconcileMs = reconcileMs;
60
61
  this.maxFullDelayMs = maxFullDelayMs;
61
62
  this.lastFullCompletedAt = -Infinity;
@@ -113,6 +114,7 @@ export class MirrorService {
113
114
  // in a loop. While windows passes keep finding changes the hub is busy, and it refreshes at the normal gap.
114
115
  const ours = this.reader.graphReadRecently?.(this.echoMs) ?? false;
115
116
  const echo = !this.windowsChanged && ours;
117
+ if (echo && this.windowsPass && kinds.includes('hub')) this.echoDuringWindows = true;
116
118
  const news = kinds.filter((kind) => kind !== 'presence');
117
119
  if (news.some((kind) => CHANGE_SOURCES[kind]) && !(ours && news.every((kind) => kind === 'hub'))) this.#preemptFull();
118
120
  for (const kind of kinds) {
@@ -214,6 +216,7 @@ export class MirrorService {
214
216
  const slow = this.now() >= this.lastSlowWindowsAt + this.slowWindowsGapMs;
215
217
  const sources = new Set(slow ? ['graph', 'windows'] : ['recent']);
216
218
  this.windowsRunning = true;
219
+ this.echoDuringWindows = false;
217
220
  this.windowsPass = this.#pass({ full: false, sources, lane: 'windows' }).finally(() => {
218
221
  this.windowsPass = null;
219
222
  this.lastWindowsAt = this.now();
@@ -283,7 +286,11 @@ export class MirrorService {
283
286
  this.refusals = 0;
284
287
  if (full) this.lastFullCompletedAt = this.now();
285
288
  if (result.briefsPending) this.pendingSources.add('briefs');
286
- if (lane === 'windows') this.windowsChanged = result.pushed + result.tombstoned > 0;
289
+ if (lane === 'windows') {
290
+ this.windowsChanged = result.pushed + result.tombstoned > 0;
291
+ // The hub can echo this pass's reads before its result is in; that echo was judged as if the hub were quiet.
292
+ if (this.windowsChanged && this.echoDuringWindows) this.#requestWindows(this.windowsGapMs);
293
+ }
287
294
  this.log('mirror', { full, pushed: result.pushed, tombstoned: result.tombstoned, rejected: result.rejected.length });
288
295
  })
289
296
  .catch((error) => {
@@ -1,12 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import os from 'node:os';
3
- import path from 'node:path';
4
3
  import { GRAPH_HOURS as WINDOW_HOURS, mergeWindows, RowMemo, windowRows } from './graphWindows.js';
5
4
  import { HubError } from './errors.js';
6
- import { MIRROR_CONTRACT, redactSecretSubtree, sanitizeItem, secretKeyReplacement } from './mirrorContract.js';
7
- import { createPathRedactor } from './pathRedaction.js';
8
- import { redactSecrets } from './redaction.js';
5
+ import { MIRROR_CONTRACT, sanitizeItem } from './mirrorContract.js';
9
6
  import { readRulePacks } from './rulePacks.js';
7
+ import { UploadRedactor } from './uploadRedactor.js';
10
8
 
11
9
  export const MIRROR_SOURCES = Object.freeze(['deliveries', 'graph', 'channels', 'inbox', 'rules', 'windows', 'briefs']);
12
10
  /**
@@ -27,7 +25,6 @@ const SOURCE_KINDS = Object.freeze({
27
25
  });
28
26
  const BRIEF_READS_IN_FLIGHT = 4;
29
27
  const GRAPH_HOURS = 168;
30
- const SECRET_SEEN_MAX = 100_000;
31
28
  const TASK_KEY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
32
29
  const BRIEF = MIRROR_CONTRACT.kinds.brief.properties;
33
30
  const longer = (text, max) => typeof text === 'string' && Array.from(text).length > max;
@@ -150,10 +147,9 @@ export class MirrorSource {
150
147
  this.now = now;
151
148
  this.maxMessagesPerChannel = maxMessagesPerChannel;
152
149
  this.briefBudgetMs = briefBudgetMs;
153
- this.repoRoots = new Map(approvedRoots.map((root) => [root, path.basename(root)]));
150
+ this.redaction = new UploadRedactor({ workspace, home, approvedRoots });
154
151
  this.windowCache = new Map();
155
152
  this.rowMemo = new RowMemo();
156
- this.worktreeRoots = new Map();
157
153
  this.briefs = new Map();
158
154
  this.taskKeys = null;
159
155
  }
@@ -197,37 +193,16 @@ export class MirrorSource {
197
193
  return !failed && index === stale.length;
198
194
  }
199
195
 
200
- #learnRepos(deliveries) {
201
- for (const stream of deliveries.flatMap((delivery) => delivery.streams || [])) {
202
- const git = stream.git || {};
203
- if (typeof git.mainRepo === 'string' && path.isAbsolute(git.mainRepo)) this.repoRoots.set(git.mainRepo, path.basename(git.mainRepo));
204
- for (const extra of [git.worktreePath, git.channelCheckout]) {
205
- if (typeof extra === 'string' && path.isAbsolute(extra) && !this.repoRoots.has(extra)) this.worktreeRoots.set(extra, path.basename(extra));
206
- }
207
- }
196
+ get repoRoots() {
197
+ return this.redaction.repoRoots;
208
198
  }
209
199
 
210
- /**
211
- * Secrets first, then paths, on every string and key: a secret inside a path is never cut down to a last segment
212
- * that still holds it, and nothing is projected, truncated or hashed before both have run.
213
- */
214
- #redactor(secrets) {
215
- const repos = [...this.repoRoots, ...this.worktreeRoots].map(([root, name]) => ({ root, name }));
216
- const paths = createPathRedactor({ repos, workspace: this.workspace, home: this.home });
217
- const seen = new Map();
218
- const redactString = (input) => {
219
- const original = String(input ?? '');
220
- let text = seen.get(original);
221
- if (text === undefined) {
222
- text = paths.redactString(redactSecrets(original, { secrets }).text);
223
- if (seen.size >= SECRET_SEEN_MAX) seen.clear();
224
- seen.set(original, text);
225
- }
226
- return text;
227
- };
228
- const entry = (key, item) => secretKeyReplacement(key, item) ?? redactSecretSubtree(key, item, redactString);
229
- const redactValue = (value, { keep = [] } = {}) => paths.redactValue(value, { keep, redactString, entry });
230
- return { redactString, redactValue };
200
+ get worktreeRoots() {
201
+ return this.redaction.worktreeRoots;
202
+ }
203
+
204
+ #learnRepos(deliveries) {
205
+ this.redaction.learnRepos(deliveries);
231
206
  }
232
207
 
233
208
  async collect({ full = false, sources = new Set(MIRROR_SOURCES), channelLastAt = new Map(), signal } = {}) {
@@ -412,7 +387,7 @@ export class MirrorSource {
412
387
 
413
388
  checkpoint();
414
389
  const secrets = [await this.reader.tokenValue().catch(() => null), ...this.heldSecrets()].filter(Boolean);
415
- const redactor = this.#redactor(secrets);
390
+ const redactor = this.redaction.redactor(secrets);
416
391
  const items = new Map();
417
392
  for (const entry of raw) {
418
393
  if (typeof entry.externalId !== 'string' || !entry.externalId) continue;
@@ -430,7 +405,7 @@ export class MirrorSource {
430
405
  const graphDropped = {};
431
406
  if (windows.length === WINDOW_HOURS.length) {
432
407
  // A row's redacted, checked form depends on the roots the redactor knows and the secrets, and on nothing else.
433
- this.rowMemo.begin(JSON.stringify([[...this.repoRoots], [...this.worktreeRoots], this.workspace, this.home, secrets]));
408
+ this.rowMemo.begin(this.redaction.basis(secrets));
434
409
  const options = { redact: (value) => redactor.redactValue(value), secrets, memo: this.rowMemo };
435
410
  const built = mergeWindows(windows.map(({ hours, body, fresh }) => {
436
411
  if (!fresh) return this.windowCache.get(hours).rows;
@@ -0,0 +1,19 @@
1
+ import { HubError } from './errors.js';
2
+
3
+ export const NODE_FLOOR_MAJOR = 24;
4
+
5
+ /** Whether this Node can run Pulse; on Node 22 and older, fetch can lose an abort on a long-lived stream (NODE22-01). */
6
+ export function nodeFloor(version = process.versions.node) {
7
+ if (Number(version.split('.')[0]) >= NODE_FLOOR_MAJOR) return { ok: true, version };
8
+ return {
9
+ ok: false,
10
+ code: 'HUB_NODE_TOO_OLD',
11
+ version,
12
+ message: `Pulse needs Node ${NODE_FLOOR_MAJOR} or newer; this is Node ${version}. Install Node ${NODE_FLOOR_MAJOR} (Active LTS), then run \`agentpulse pulse install\` and \`agentpulse pulse start\`.`,
13
+ };
14
+ }
15
+
16
+ export function assertNodeFloor(version) {
17
+ const floor = nodeFloor(version);
18
+ if (!floor.ok) throw new HubError(floor.code, floor.message);
19
+ }
@@ -0,0 +1,75 @@
1
+ import { fullJitterDelay } from './backoff.js';
2
+ import { HubError } from './errors.js';
3
+
4
+ // I3: a command gets at most this many retryable refusals before it is set aside, so it can block its group only so long.
5
+ export const MAX_ATTEMPTS = 6;
6
+ // The cloud holds a command behind an earlier refused command of its group; it goes again as soon as that one does.
7
+ const HELD_CODE = 'held_by_group';
8
+ const HELD_RETRY_MS = 1_000;
9
+
10
+ /**
11
+ * Sends the runner store's outbox to Pulse, one batch at a time and in enqueue order (design §6.3 flush loop). Each
12
+ * command carries its ordering group, so the cloud holds a group's later commands behind one it refused for now, and
13
+ * the store holds them locally while it backs off. Per item: stored or duplicate → acked; retryable → backoff (then
14
+ * dead after MAX_ATTEMPTS); any other refusal → dead with its code. A refusal of the whole batch is thrown.
15
+ */
16
+ export class OutboxFlusher {
17
+ constructor({ store, transport, random = Math.random, maxAttempts = MAX_ATTEMPTS }) {
18
+ this.store = store;
19
+ this.transport = transport;
20
+ this.random = random;
21
+ this.maxAttempts = maxAttempts;
22
+ }
23
+
24
+ /** One batch; resolves to its tally, or null when nothing is due. */
25
+ async flushOnce() {
26
+ const batch = this.store.nextBatch();
27
+ if (!batch.length) return null;
28
+ const response = await this.transport.commands({
29
+ commands: batch.map((row) => ({ type: row.type, idempotencyKey: row.key, payload: row.payload, group: row.group })),
30
+ });
31
+ const results = Array.isArray(response?.results) ? response.results : [];
32
+ const tally = { sent: batch.length, written: 0, duplicate: 0, retried: 0, held: 0, dead: 0 };
33
+ batch.forEach((row, index) => {
34
+ const result = results[index];
35
+ if (!result || result.key !== row.key) {
36
+ this.store.retry(row.key, { code: 'no_result', delayMs: fullJitterDelay(row.attempts, { random: this.random }) });
37
+ tally.retried += 1;
38
+ return;
39
+ }
40
+ if (!result.error) {
41
+ this.store.acked(row.key, { seq: result.seq ?? null, duplicate: Boolean(result.duplicate) });
42
+ tally[result.duplicate ? 'duplicate' : 'written'] += 1;
43
+ return;
44
+ }
45
+ const { code = 'refused', retryable = false, message = null } = result.error;
46
+ if (code === HELD_CODE) {
47
+ this.store.hold(row.key, { code, delayMs: HELD_RETRY_MS });
48
+ tally.held += 1;
49
+ return;
50
+ }
51
+ if (retryable && row.attempts + 1 < this.maxAttempts) {
52
+ this.store.retry(row.key, { code, delayMs: fullJitterDelay(row.attempts, { random: this.random }) });
53
+ tally.retried += 1;
54
+ return;
55
+ }
56
+ this.store.dead(row.key, { code: retryable ? `max_attempts:${code}` : code, message });
57
+ tally.dead += 1;
58
+ });
59
+ return tally;
60
+ }
61
+
62
+ /** Batches until the outbox has nothing due, `maxBatches` were sent or the signal fires; the summed tally. */
63
+ async drain({ maxBatches = Infinity, signal } = {}) {
64
+ const total = { batches: 0, sent: 0, written: 0, duplicate: 0, retried: 0, held: 0, dead: 0 };
65
+ while (total.batches < maxBatches) {
66
+ if (signal?.aborted) throw new HubError('HUB_SHADOW_STOPPED', 'The shadow feed stopped');
67
+ const tally = await this.flushOnce();
68
+ if (!tally) break;
69
+ total.batches += 1;
70
+ for (const key of Object.keys(tally)) total[key] += tally[key];
71
+ if (tally.written + tally.duplicate + tally.dead === 0) break;
72
+ }
73
+ return total;
74
+ }
75
+ }
@@ -94,7 +94,7 @@ export async function pairConnector({
94
94
  cliDefaults,
95
95
  now = Date.now,
96
96
  pairingPolicy = {
97
- capabilities: ['workspace.read', 'git.read'],
97
+ capabilities: ['workspace.read', 'git.read', 'hub.sync', 'log.write'],
98
98
  providerPolicy: { providers: {}, maxConcurrency: 1, maxTokensPerRun: 1, maxCostUsd: 0 },
99
99
  },
100
100
  platformUrl,
@@ -0,0 +1,316 @@
1
+ import { lstat, readdir, readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { channelFiles, splitPosts } from './channelScanner.js';
4
+ import { HubDbReader } from './hubDbReader.js';
5
+ import { FACT_KINDS, SKIPPED_FACT_KINDS } from './hubScan.js';
6
+ import { bodyDigest, RECONCILE_TYPES, reconcileEntry, reconcileFamilies } from './reconcileView.js';
7
+ import { openDatabase } from './sqlite.js';
8
+
9
+ const NAMED = 50;
10
+ const SOURCE_TABLES = Object.freeze({ actors: 'actors', messages: 'messages', events: 'events', presence: 'presence' });
11
+
12
+ /**
13
+ * How many rows each source holds, counted straight from the snapshot without the scanner, so a scanner that drops
14
+ * a row is caught even though the import and the reconcile run the same scanner.
15
+ */
16
+ export async function sourceCounts({ hubDir, roots }) {
17
+ const counts = {};
18
+ const db = openDatabase(path.join(hubDir, 'state', 'hub.db'), { readOnly: true });
19
+ try {
20
+ for (const [source, table] of Object.entries(SOURCE_TABLES)) counts[source] = Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n);
21
+ const kinds = [...FACT_KINDS, ...Object.keys(SKIPPED_FACT_KINDS)];
22
+ counts.facts = Number(db.prepare(`SELECT COUNT(*) AS n FROM facts WHERE kind IN (${kinds.map(() => '?').join(', ')})`).get(...kinds).n);
23
+ } finally {
24
+ db.close();
25
+ }
26
+ try {
27
+ const personas = JSON.parse(await readFile(path.join(hubDir, 'state', 'personas.json'), 'utf8'));
28
+ counts.personas = Object.keys(personas && typeof personas === 'object' ? personas : {}).length;
29
+ } catch {
30
+ counts.personas = 0;
31
+ }
32
+ counts.chat = 0;
33
+ const chatDir = path.join(hubDir, 'state', 'chat');
34
+ for (const name of (await readdir(chatDir).catch(() => [])).filter((entry) => entry.endsWith('.jsonl'))) {
35
+ counts.chat += (await readFile(path.join(chatDir, name), 'utf8')).split('\n').filter(Boolean).length;
36
+ }
37
+ const { files, collisions, unnamed } = await channelFiles(roots);
38
+ counts.channels = collisions.length + unnamed.length;
39
+ for (const file of files) counts.channels += splitPosts(await readFile(file.file, 'utf8'), file.mtimeMs).length;
40
+ return counts;
41
+ }
42
+
43
+ /** Receipt states a recipient ends in under the hub's (and the cloud's) rules, applied to the commands in order. */
44
+ function receiptStates(commands) {
45
+ const state = new Map();
46
+ const topicOf = new Map();
47
+ for (const command of commands) {
48
+ const p = command.payload;
49
+ switch (command.type) {
50
+ case 'message.published':
51
+ topicOf.set(p.id, p.topic);
52
+ for (const to of p.to) state.set(`${p.id}\0${to}`, 'queued');
53
+ break;
54
+ case 'receipt.delivered':
55
+ case 'receipt.acked':
56
+ case 'receipt.expired': {
57
+ const id = `${p.messageId}\0${p.recipient}`;
58
+ const was = state.get(id);
59
+ if (was === undefined) break;
60
+ if (command.type === 'receipt.acked') state.set(id, 'acked');
61
+ else if (command.type === 'receipt.delivered' && (was === 'queued' || was === 'delivered')) state.set(id, 'delivered');
62
+ else if (command.type === 'receipt.expired' && was === 'queued') state.set(id, 'expired');
63
+ break;
64
+ }
65
+ default:
66
+ break;
67
+ }
68
+ }
69
+ const byTopic = {};
70
+ for (const [id, value] of state) {
71
+ const topic = topicOf.get(id.slice(0, id.indexOf('\0')));
72
+ byTopic[topic] ??= {};
73
+ byTopic[topic][value] = (byTopic[topic][value] || 0) + 1;
74
+ }
75
+ return byTopic;
76
+ }
77
+
78
+ /** The local side: the reconcile view of a fresh scan's commands, its receipts, and what it withheld or cut. */
79
+ export function localReconcile({ commands, dead }) {
80
+ const withBody = (command) => (['post.created', 'message.published'].includes(command.type) ? bodyDigest(command.payload.body) : null);
81
+ const { view, sets } = reconcileFamilies(commands.filter((command) => RECONCILE_TYPES.includes(command.type))
82
+ .map((command) => reconcileEntry(command.type, command.key, command.payload, withBody(command))));
83
+ const byTopic = new Map();
84
+ for (const [id, { topic }] of sets.messages) byTopic.set(topic, [...(byTopic.get(topic) || []), id]);
85
+ return {
86
+ view,
87
+ // The identities behind each channel and conversation, so a difference can be classified (CUT-02).
88
+ keys: { posts: new Map([...sets.posts].map(([channel, list]) => [channel, list.map((post) => post.key)])), messages: byTopic },
89
+ receipts: receiptStates(commands),
90
+ cut: commands.filter((command) => command.type === 'post.created' && command.payload.truncated).map((command) => command.key).sort(),
91
+ withheld: dead.map((row) => ({ key: row.key, type: row.type, code: row.code })),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * A rescan keys a post by the digest of its block as it is now. A post edited in place after it was sent is known to
97
+ * Pulse by its first key, and the delta carried the edit as `post.edited` against that key. So each post takes the key
98
+ * the live store ingested for the same channel and block (`ingestedKey(channel, '<digest>:<n>')`), and a carried edit
99
+ * compares as the post it edited.
100
+ */
101
+ export function resolvePostKeys(commands, ingestedKey) {
102
+ return commands.map((command) => {
103
+ if (command.type !== 'post.created' || !command.key.startsWith('post:')) return command;
104
+ const known = ingestedKey(command.payload.channel, command.key.split(':').slice(-2).join(':'));
105
+ return known && known !== command.key ? { ...command, key: known } : command;
106
+ });
107
+ }
108
+
109
+ const TASK_CHANNEL = /^task:([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/;
110
+ const isDirectory = (dir) => stat(dir).then((info) => info.isDirectory(), () => false);
111
+
112
+ /**
113
+ * Where a task channel's file is now: 'absent' only when every channel root is a readable directory and none holds
114
+ * <repo>/.agents/<task>/channel.md; 'present' when one does; 'unknown' otherwise (a root missing, an error other than
115
+ * not-found, a channel that is not a task channel). Only 'absent' makes a channel retired.
116
+ */
117
+ export async function channelOnDisk(channel, roots) {
118
+ const parts = String(channel).match(TASK_CHANNEL)?.slice(1);
119
+ if (!parts || !roots.length || parts.some((part) => part === '.' || part === '..')) return 'unknown';
120
+ for (const root of roots) {
121
+ if (!(await isDirectory(root))) return 'unknown';
122
+ try {
123
+ await stat(path.join(root, parts[0], '.agents', parts[1], 'channel.md'));
124
+ return 'present';
125
+ } catch (error) {
126
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') return 'unknown';
127
+ }
128
+ }
129
+ return 'absent';
130
+ }
131
+
132
+ /** Channels the cloud has posts in that the rescan has none in: the ones whose file must be looked for on disk. */
133
+ export function cloudOnlyChannels(local, cloud) {
134
+ return Object.keys(cloud.view?.posts?.byChannel ?? {}).filter((channel) => !local.view.posts.byChannel[channel]).sort();
135
+ }
136
+
137
+ /** The id a classified channel or conversation goes by in the `redacted` map. */
138
+ export const itemId = (family, item) => `${family}\0${item}`;
139
+
140
+ /**
141
+ * One differing channel (`posts.byChannel`) or conversation (`messages.byTopic`), classified (CUT-02, the 12:44:58
142
+ * DECISION):
143
+ * - `retired`: the cloud has a channel whose file is gone from disk under every root. Listed and allowed.
144
+ * - `redacted`: same identities, and a rescan with more redaction roots gives exactly the cloud's sha (`redacted`
145
+ * holds those items and their roots). The cloud keeps its more-redacted text; nothing is sent. Allowed.
146
+ * - `drift`: same identities, other content, not explained by redaction. Every local key is one the cloud acknowledged
147
+ * (`runner.db` sent) and the counts are equal, so only bodies differ. Fails: sending local text could un-redact.
148
+ * - `missing`: a local key the cloud never acknowledged, or a channel or conversation it lacks. Fails.
149
+ * - `unexplained`: anything else (a cloud-only channel whose file is there or cannot be checked, extra cloud items in a
150
+ * live channel, no record of what was sent). Fails.
151
+ */
152
+ function classify({ family, item, local, cloud, keys, sent, onDisk, redacted }) {
153
+ const type = family === 'posts.byChannel' ? 'post.created' : 'message.published';
154
+ if (!cloud) return { class: 'missing', note: 'not in Pulse' };
155
+ if (!local) {
156
+ if (family === 'posts.byChannel' && onDisk[item] === 'absent') return { class: 'retired', note: 'its channel file is gone from disk' };
157
+ return { class: 'unexplained', note: family === 'posts.byChannel' ? `only in Pulse, and its channel file is ${onDisk[item] ?? 'unknown'} on disk` : 'only in Pulse' };
158
+ }
159
+ if (!sent) return { class: 'unexplained', note: 'no record of what this machine sent' };
160
+ const lost = keys.filter((key) => !sent.sentAs(key, type));
161
+ if (lost.length) return { class: 'missing', note: `${lost.length} local item(s) Pulse never acknowledged, e.g. ${lost[0]}` };
162
+ if (local.count !== cloud.count) return { class: 'unexplained', note: `${local.count} here, ${cloud.count} in Pulse, every local one acknowledged` };
163
+ const roots = redacted.get(itemId(family, item));
164
+ if (roots) return { class: 'redacted', roots, note: 'Pulse holds the same text, redacted with more roots' };
165
+ return { class: 'drift', note: 'same items, other content, and no redaction root explains it' };
166
+ }
167
+
168
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
169
+
170
+ /**
171
+ * Redaction roots a drifted item's text may have been sent with (CUT-02): for each path under the workspace that the
172
+ * text names, its shallowest directory that no longer exists, a worktree or repo deleted since. Only a candidate;
173
+ * the proof is a rescan that gives exactly the cloud's sha.
174
+ */
175
+ export async function redactionCandidates({ texts, workspace }) {
176
+ const base = workspace.replace(/\/+$/, '');
177
+ const named = new RegExp(`${escapeRegExp(base)}((?:/[^\\s"'\`<>()\\[\\]{},;/]+)+)`, 'g');
178
+ const found = new Set();
179
+ for (const text of texts) {
180
+ for (const match of String(text).matchAll(named)) {
181
+ let dir = base;
182
+ for (const part of match[1].split('/').filter(Boolean)) {
183
+ if (part === '.' || part === '..') break;
184
+ dir = path.join(dir, part);
185
+ const info = await lstat(dir).catch((error) => (error.code === 'ENOENT' ? null : error));
186
+ if (info === null) found.add(dir);
187
+ if (!info?.isDirectory?.()) break;
188
+ }
189
+ }
190
+ }
191
+ return [...found].sort();
192
+ }
193
+
194
+ /**
195
+ * The raw text behind each drifted item, from the import's snapshot: a task channel's posts as blocks, a
196
+ * conversation's message bodies. Keyed by `itemId`. A project channel (chat) has none here.
197
+ */
198
+ export async function driftTexts({ snapshotHubDir, snapshotRoots }, drift) {
199
+ const texts = new Map();
200
+ const channels = new Set(drift.filter((entry) => entry.family === 'posts.byChannel').map((entry) => entry.item));
201
+ if (channels.size) {
202
+ const { files } = await channelFiles(snapshotRoots);
203
+ for (const file of files.filter((entry) => channels.has(`task:${entry.key}`))) {
204
+ texts.set(itemId('posts.byChannel', `task:${file.key}`), splitPosts(await readFile(file.file, 'utf8'), file.mtimeMs).map((post) => post.text));
205
+ }
206
+ }
207
+ const topics = new Set(drift.filter((entry) => entry.family === 'messages.byTopic').map((entry) => entry.item));
208
+ if (topics.size) {
209
+ for (const message of new HubDbReader({ hubDir: snapshotHubDir }).read((snapshot) => snapshot.messagesSince(0))) {
210
+ if (!topics.has(message.topic)) continue;
211
+ const id = itemId('messages.byTopic', message.topic);
212
+ texts.set(id, [...(texts.get(id) || []), message.body]);
213
+ }
214
+ }
215
+ return texts;
216
+ }
217
+
218
+ // Key order carries no meaning: the cloud answers receipt states and topics in its own order.
219
+ const canonical = (value) => {
220
+ if (Array.isArray(value)) return value.map(canonical);
221
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
222
+ return value ?? null;
223
+ };
224
+ const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
225
+
226
+ const differingKeys = (local = {}, cloud = {}) => [...new Set([...Object.keys(local), ...Object.keys(cloud)])].sort().filter((key) => !same(local[key], cloud[key]));
227
+
228
+ function named(mismatches, family, items) {
229
+ mismatches.push(...items.slice(0, NAMED));
230
+ if (items.length > NAMED) mismatches.push({ family, item: `… ${items.length - NAMED} more`, local: null, cloud: null });
231
+ }
232
+
233
+ function compareMap(mismatches, family, local = {}, cloud = {}) {
234
+ named(mismatches, family, differingKeys(local, cloud).map((key) => ({ family, item: key, local: local[key] ?? null, cloud: cloud[key] ?? null })));
235
+ }
236
+
237
+ /**
238
+ * Local scan against the cloud's answer (GET /connector/parity?view=reconcile), and the scan's own rows against the
239
+ * sources counted without it. Every differing channel and conversation is classified (`classify`): `retired` and
240
+ * `redacted` ones are listed and allowed, `drift`, `missing` and `unexplained` ones are mismatches. The `channels`,
241
+ * `posts` and `messages` totals differ only by what is retired or redacted, so the counts must add up to exactly that.
242
+ * Status: `equal` (nothing differs), `explained` (only retired or redacted items do) or `mismatch`. Every other
243
+ * family, the receipt counts, the cut list, every logged item being in the cloud's state tables and every source row
244
+ * being accounted for are compared exactly, as before. `sent` (the live runner store: `sentAs(key, type)`) proves
245
+ * which items the cloud acknowledged; `onDisk` maps each cloud-only channel to `channelOnDisk`; `redacted` maps the
246
+ * `itemId` of each item a rescan proved to the roots it took.
247
+ */
248
+ export function compareReconcile({ local, cloud, counts, report, sent = null, onDisk = {}, redacted = new Map() }) {
249
+ const mismatches = [];
250
+ const items = [];
251
+ for (const [family, localMap, cloudMap, keysOf] of [
252
+ ['posts.byChannel', local.view.posts.byChannel, cloud.view?.posts?.byChannel, local.keys?.posts],
253
+ ['messages.byTopic', local.view.messages.byTopic, cloud.view?.messages?.byTopic, local.keys?.messages],
254
+ ]) {
255
+ for (const item of differingKeys(localMap, cloudMap)) {
256
+ const [l, c] = [localMap[item] ?? null, cloudMap?.[item] ?? null];
257
+ items.push({ family, item, local: l, cloud: c, ...classify({ family, item, local: l, cloud: c, keys: keysOf?.get(item) ?? [], sent, onDisk, redacted }) });
258
+ }
259
+ }
260
+ const retired = items.filter((entry) => entry.class === 'retired');
261
+ const proven = items.filter((entry) => entry.class === 'redacted');
262
+ const drift = items.filter((entry) => entry.class === 'drift');
263
+ const failing = items.filter((entry) => ['drift', 'missing', 'unexplained'].includes(entry.class));
264
+ const failed = (family) => failing.some((entry) => entry.family === family);
265
+ const retiredPosts = retired.reduce((n, entry) => n + entry.cloud.count, 0);
266
+ const totals = {
267
+ channels: local.view.channels.count + retired.length === cloud.view?.channels?.count && !failed('posts.byChannel'),
268
+ posts: local.view.posts.count + retiredPosts === cloud.view?.posts?.count && !failed('posts.byChannel'),
269
+ messages: local.view.messages.count === cloud.view?.messages?.count && !failed('messages.byTopic'),
270
+ };
271
+ const sources = Object.fromEntries(Object.entries(report.sources).map(([source, stats]) => {
272
+ const accounted = stats.planned + stats.unchanged + Object.values(stats.skipped).reduce((n, v) => n + v, 0)
273
+ + Object.values(stats.refused).reduce((n, v) => n + v, 0);
274
+ return [source, { counted: counts[source] ?? null, read: stats.read, accounted }];
275
+ }));
276
+ for (const [source, row] of Object.entries(sources)) {
277
+ if (row.counted !== null && row.counted !== row.read) mismatches.push({ family: 'source', item: source, local: row.read, cloud: null, note: `${row.counted} rows in the source, ${row.read} read` });
278
+ if (row.read !== row.accounted) mismatches.push({ family: 'source', item: source, local: row.accounted, cloud: null, note: `${row.read} read, ${row.accounted} accounted for` });
279
+ }
280
+ for (const family of ['actors', 'agentRows', 'personas', 'statusOverrides']) {
281
+ if (!same(local.view[family], cloud.view?.[family])) mismatches.push({ family, local: local.view[family], cloud: cloud.view?.[family] ?? null });
282
+ }
283
+ for (const family of ['channels', 'posts', 'messages']) {
284
+ const { count, sha } = local.view[family];
285
+ const other = cloud.view?.[family] ?? {};
286
+ if ((count !== other.count || sha !== other.sha) && !totals[family]) {
287
+ const note = retired.length ? { note: `${retired.length} retired channel(s) do not explain the difference` } : {};
288
+ mismatches.push({ family, class: 'unexplained', local: { count, sha }, cloud: { count: other.count ?? null, sha: other.sha ?? null }, ...note });
289
+ }
290
+ }
291
+ for (const family of ['posts.byChannel', 'messages.byTopic']) named(mismatches, family, failing.filter((entry) => entry.family === family));
292
+ compareMap(mismatches, 'facts', local.view.facts, cloud.view?.facts);
293
+ compareMap(mismatches, 'receipts', local.receipts, cloud.receipts);
294
+ // A retired channel's cut posts stay in Pulse's cut list; the rescan cannot have them.
295
+ const retiredPrefixes = retired.map((entry) => `post:${entry.item.slice('task:'.length)}:`);
296
+ const cloudCut = (cloud.cut ?? []).filter((key) => !retiredPrefixes.some((prefix) => key.startsWith(prefix)));
297
+ if (!same(local.cut, cloudCut)) mismatches.push({ family: 'cut', local: local.cut, cloud: cloud.cut ?? null });
298
+ for (const [family, absent] of Object.entries(cloud.missing || {})) {
299
+ mismatches.push({ family: `missing.${family}`, local: null, cloud: absent, note: 'in the log but not in the state tables' });
300
+ }
301
+ const classes = { retired: retired.length, redacted: proven.length, drift: 0, missing: 0, unexplained: 0 };
302
+ for (const entry of failing) classes[entry.class] += 1;
303
+ const status = mismatches.length ? 'mismatch' : retired.length || proven.length ? 'explained' : 'equal';
304
+ return {
305
+ status,
306
+ mismatches,
307
+ sources,
308
+ classes,
309
+ retired: retired.map((entry) => ({ channel: entry.item, posts: entry.cloud.count })),
310
+ redacted: proven.map(({ family, item, local: l, roots }) => ({ family, item, count: l.count, roots })),
311
+ drift: drift.map(({ family, item, local: l, cloud: c }) => ({ family, item, count: l.count, local: l.sha, cloud: c.sha })),
312
+ };
313
+ }
314
+
315
+ /** A reconcile that the cutover may go on from: nothing differs, or only retired and redacted items do. */
316
+ export const reconcilePasses = (status) => status === 'equal' || status === 'explained';