@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.
- package/README.md +46 -0
- package/package.json +7 -4
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +39 -0
- package/src/commands/pulse/doctor.js +1 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/daemon/main.js +19 -12
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/application.js +82 -34
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/cutover.js +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonLock.js +2 -0
- package/src/lib/daemonLoop.js +5 -2
- package/src/lib/daemonProcess.js +16 -2
- package/src/lib/daemonRuntime.js +8 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/errors.js +14 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/mirrorContract.js +1 -1
- package/src/lib/mirrorService.js +8 -1
- package/src/lib/mirrorSource.js +13 -38
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/pairingLifecycle.js +1 -1
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/serviceManager.js +61 -0
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/tokenStore.js +43 -4
- package/src/lib/transport.js +53 -5
- package/src/lib/transportContract.js +3 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +140 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
|
@@ -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));
|
package/src/lib/mirrorService.js
CHANGED
|
@@ -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')
|
|
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) => {
|
package/src/lib/mirrorSource.js
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
#
|
|
215
|
-
|
|
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
|
|
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(
|
|
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';
|