@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
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { setImmediate as yieldToLoop } from 'node:timers/promises';
|
|
5
|
+
import { channelFiles, splitPosts } from './channelScanner.js';
|
|
6
|
+
import {
|
|
7
|
+
actorPayload, contentSha, eventPayload, factDescriptors, keyPart, messagePayload, personaPayload, postKey, postPayload, receiptEvents, without,
|
|
8
|
+
} from './hubMapping.js';
|
|
9
|
+
import { contentWithheld, leaksPath, pathRejected, SECRET_RULES } from './mirrorContract.js';
|
|
10
|
+
import { canonicalJson } from './planContract.js';
|
|
11
|
+
|
|
12
|
+
export const SCAN_SOURCES = Object.freeze(['facts', 'actors', 'messages', 'receipts', 'events', 'personas', 'chat', 'channels', 'presence']);
|
|
13
|
+
/** Fact kinds the cloud log carries, and the ones it does not, with the reason. */
|
|
14
|
+
export const FACT_KINDS = Object.freeze(['delivery', 'agent-row', 'request', 'accepted', 'settings', 'status_override']);
|
|
15
|
+
export const SKIPPED_FACT_KINDS = Object.freeze({
|
|
16
|
+
stream: 'carried_in_delivery',
|
|
17
|
+
run: 'machine_runtime_state',
|
|
18
|
+
wake: 'machine_runtime_state',
|
|
19
|
+
manager: 'machine_runtime_state',
|
|
20
|
+
totals: 'phase1_deviation_totals',
|
|
21
|
+
});
|
|
22
|
+
export const REMOVABLE_KINDS = Object.freeze(['delivery', 'stream', 'agent-row', 'request', 'status_override']);
|
|
23
|
+
export const ALL_FACT_KINDS = Object.freeze([...FACT_KINDS, ...Object.keys(SKIPPED_FACT_KINDS)]);
|
|
24
|
+
|
|
25
|
+
// Same as the cloud's idempotency key rule (bll/pulse/PulseCommandService.js KEY).
|
|
26
|
+
const KEY = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,319}$/;
|
|
27
|
+
const ABSOLUTE_PATH = /(?:^|[\s"'(=:])(?:\/(?:Users|home|Volumes|private|var|tmp|opt)\/|[A-Za-z]:\\)/;
|
|
28
|
+
const MAX_PAYLOAD_BYTES = 32 * 1024;
|
|
29
|
+
const POST_BODY_MAX = 16_000;
|
|
30
|
+
// The hub stores a bus body of at most 4,000 characters; a redaction that lengthened one is cut back to the cloud's bound.
|
|
31
|
+
const MESSAGE_BODY_MAX = 4_000;
|
|
32
|
+
// Fields of an actor row that move on their own while it works; a change in only these is sent at most once a minute.
|
|
33
|
+
export const ACTOR_TIME_FIELDS = Object.freeze(['last_at', 'msgs_in', 'msgs_out', 'tokens_est']);
|
|
34
|
+
export const MESSAGE_LOOKBACK_MS = 120_000;
|
|
35
|
+
export const RECEIPT_WINDOW_MS = 8 * 86_400_000;
|
|
36
|
+
export const RETRACT_WINDOW_MS = 48 * 3_600_000;
|
|
37
|
+
export const ACTOR_COALESCE_MS = 60_000;
|
|
38
|
+
export const PRESENCE_COALESCE_MS = 30_000;
|
|
39
|
+
export const CHANNEL_QUIET_MS = 2_000;
|
|
40
|
+
const YIELD_EVERY = 200;
|
|
41
|
+
|
|
42
|
+
const sha = (text) => createHash('sha256').update(text).digest('hex');
|
|
43
|
+
const short = (text) => sha(text).slice(0, 12);
|
|
44
|
+
const clean = (value) => JSON.parse(JSON.stringify(value ?? null));
|
|
45
|
+
|
|
46
|
+
function newSource() {
|
|
47
|
+
return { read: 0, planned: 0, unchanged: 0, skipped: {}, refused: {}, events: {}, redactions: 0, cut: 0, vanished: 0 };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One pass over the local hub's sources (design §11 table, PLAN v2): every row becomes Pulse commands queued in the
|
|
52
|
+
* runner store, or is counted as unchanged, skipped (with a reason) or refused (with a code), so for each source
|
|
53
|
+
* read = planned + unchanged + skipped + refused. `import` reads every source from the start; `shadow` reads from the
|
|
54
|
+
* saved cursors with lookbacks and coalesces fields that move on their own. hub.db is read once, in one short read
|
|
55
|
+
* transaction, before anything is redacted or queued; each source's commands and its cursor commit together.
|
|
56
|
+
*/
|
|
57
|
+
export class HubScan {
|
|
58
|
+
constructor({ store, reader, hubDir, roots, redaction, secrets = () => [], now = Date.now }) {
|
|
59
|
+
this.store = store;
|
|
60
|
+
this.reader = reader;
|
|
61
|
+
this.hubDir = hubDir;
|
|
62
|
+
this.roots = roots;
|
|
63
|
+
this.redaction = redaction;
|
|
64
|
+
this.secrets = secrets;
|
|
65
|
+
this.now = now;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What one pass (or one capture transaction) needs: its counters, its time, whether it coalesces as the shadow
|
|
70
|
+
* does, and a stable redactor that knows the repos named by these delivery facts.
|
|
71
|
+
*/
|
|
72
|
+
context({ now = this.now(), shadow = true, deliveries = [] } = {}) {
|
|
73
|
+
const report = Object.fromEntries(SCAN_SOURCES.map((source) => [source, newSource()]));
|
|
74
|
+
const secrets = this.secrets().filter(Boolean);
|
|
75
|
+
this.redaction.learnRepos(deliveries);
|
|
76
|
+
const redactor = this.redaction.redactor(secrets, { stable: true });
|
|
77
|
+
return { report, now, shadow, redactor, secrets };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `only` ({ channels, topics }: sets of channel keys and bus topics) limits the channels and messages scanned, for a
|
|
82
|
+
* reconcile's rescan of a few items; nothing else is scanned with it.
|
|
83
|
+
*/
|
|
84
|
+
async run({ mode, sources = SCAN_SOURCES, only = null }) {
|
|
85
|
+
if (mode !== 'import' && mode !== 'shadow') throw new TypeError(`HubScan mode must be import or shadow, not ${mode}`);
|
|
86
|
+
if (only) sources = sources.filter((source) => source === 'channels' || source === 'messages');
|
|
87
|
+
const now = this.now();
|
|
88
|
+
const shadow = mode === 'shadow';
|
|
89
|
+
const want = new Set(sources);
|
|
90
|
+
const cursor = (source, fallback) => (shadow ? this.store.cursor(source) ?? fallback : fallback);
|
|
91
|
+
const messagesCursor = cursor('messages', { createdAt: 0 });
|
|
92
|
+
const eventsCursor = cursor('events', { id: 0 });
|
|
93
|
+
const hub = this.reader.read((snapshot) => {
|
|
94
|
+
const messages = snapshot.messagesSince(shadow ? Math.max(0, messagesCursor.createdAt - MESSAGE_LOOKBACK_MS) : 0);
|
|
95
|
+
const open = shadow ? this.store.openReceipts() : [];
|
|
96
|
+
const recent = shadow ? this.store.recentMessages(now - RETRACT_WINDOW_MS) : [];
|
|
97
|
+
return {
|
|
98
|
+
facts: snapshot.facts(ALL_FACT_KINDS),
|
|
99
|
+
actors: snapshot.actors(),
|
|
100
|
+
presence: snapshot.presence(),
|
|
101
|
+
messages,
|
|
102
|
+
recipients: snapshot.recipients(messages.map((message) => message.id)),
|
|
103
|
+
open: open.map((entry) => ({ ...entry, row: snapshot.receipt(entry.messageId, entry.recipient) })),
|
|
104
|
+
gone: recent.filter((entry) => !snapshot.messageExists(entry.id)),
|
|
105
|
+
events: snapshot.eventsAfter(eventsCursor.id),
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
const deliveries = hub.facts.filter((fact) => fact.kind === 'delivery').map((fact) => parseFact(fact)).filter(Boolean);
|
|
109
|
+
const context = this.context({ now, shadow, deliveries });
|
|
110
|
+
const { report } = context;
|
|
111
|
+
|
|
112
|
+
if (want.has('facts')) await this.#facts(context, hub.facts);
|
|
113
|
+
if (want.has('actors')) await this.#actors(context, hub.actors);
|
|
114
|
+
if (want.has('messages')) await this.#messages(context, only ? hub.messages.filter((message) => only.topics.has(message.topic)) : hub.messages, hub.recipients, messagesCursor);
|
|
115
|
+
if (shadow && want.has('receipts')) await this.#receiptTransitions(context, hub.open, hub.gone);
|
|
116
|
+
if (want.has('events')) await this.#events(context, hub.events, eventsCursor);
|
|
117
|
+
if (want.has('personas')) await this.#personas(context);
|
|
118
|
+
if (want.has('chat')) await this.#chat(context, hub.messages);
|
|
119
|
+
if (want.has('channels')) await this.#channels(context, only);
|
|
120
|
+
const presence = want.has('presence') ? this.presenceBeats(context, hub.presence) : [];
|
|
121
|
+
return { mode, at: now, sources: report, presence, totals: totals(report) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Redact, check and key one command. `fixedKey` for append-only items (their key comes from the source id or raw
|
|
126
|
+
* content); otherwise the key is content-addressed per entity from the redacted payload without `at`.
|
|
127
|
+
*/
|
|
128
|
+
prepare(context, source, { type, group, payload, fixedKey = null, entity = null, prefix = null, separator = ':' }) {
|
|
129
|
+
const stats = context.report[source];
|
|
130
|
+
const redacted = clean(context.redactor.redactValue(payload));
|
|
131
|
+
stats.redactions += context.redactor.takeRedactions();
|
|
132
|
+
if (type === 'message.published' && redacted.body.length > MESSAGE_BODY_MAX) redacted.body = `${redacted.body.slice(0, MESSAGE_BODY_MAX - 1)}…`;
|
|
133
|
+
let key = fixedKey;
|
|
134
|
+
let version = null;
|
|
135
|
+
if (!key) {
|
|
136
|
+
const content = contentSha(redacted);
|
|
137
|
+
if (this.store.version(entity)?.sha === content) return { unchanged: true };
|
|
138
|
+
key = this.store.versionKey(entity, prefix, content, separator);
|
|
139
|
+
version = { entity, sha: content, key };
|
|
140
|
+
}
|
|
141
|
+
const entry = { key, group, type, payload: redacted, source };
|
|
142
|
+
const unsafe = (text) => !text || ABSOLUTE_PATH.test(text) || pathRejected(text) || SECRET_RULES.some((rule) => rule.probe.test(text));
|
|
143
|
+
if (!KEY.test(key) || unsafe(key) || unsafe(group)) return { refused: 'unsafe_key', entry: { ...entry, key: `unsafe:${sha(key).slice(0, 40)}`, group: 'unsafe' } };
|
|
144
|
+
if (contentWithheld(redacted, context.secrets)) return { refused: 'content_withheld', entry };
|
|
145
|
+
if (leaksPath(redacted)) return { refused: 'path_not_redacted', entry };
|
|
146
|
+
if (Buffer.byteLength(JSON.stringify(redacted), 'utf8') > MAX_PAYLOAD_BYTES) return { refused: 'payload_too_large', entry };
|
|
147
|
+
return { entry, version };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Queue the ready commands of one row; returns whether anything new was queued. */
|
|
151
|
+
queue(context, source, prepared) {
|
|
152
|
+
const stats = context.report[source];
|
|
153
|
+
let added = 0;
|
|
154
|
+
for (const item of prepared) {
|
|
155
|
+
if (!item.entry || item.refused) continue;
|
|
156
|
+
if (this.store.known(item.entry.key)) continue;
|
|
157
|
+
added += this.store.enqueue([item.entry]);
|
|
158
|
+
if (item.version) this.store.setVersion(item.version.entity, { ...item.version, rawSha: item.rawSha ?? null, staticSha: item.staticSha ?? null });
|
|
159
|
+
stats.events[item.entry.type] = (stats.events[item.entry.type] || 0) + 1;
|
|
160
|
+
}
|
|
161
|
+
return added > 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Count one source row by the outcome of its commands: planned when anything new was queued, refused (recorded with
|
|
166
|
+
* its code in the store's dead letters, so it is visible and never sent) when any command was, else unchanged.
|
|
167
|
+
*/
|
|
168
|
+
count(context, source, prepared, queued) {
|
|
169
|
+
const stats = context.report[source];
|
|
170
|
+
stats.read += 1;
|
|
171
|
+
const refused = prepared.find((item) => item.refused);
|
|
172
|
+
if (refused) {
|
|
173
|
+
stats.refused[refused.refused] = (stats.refused[refused.refused] || 0) + 1;
|
|
174
|
+
if (refused.entry) this.store.refuse(refused.entry, { code: refused.refused });
|
|
175
|
+
} else if (queued) stats.planned += 1;
|
|
176
|
+
else stats.unchanged += 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
skip(context, source, reason) {
|
|
180
|
+
const stats = context.report[source];
|
|
181
|
+
stats.read += 1;
|
|
182
|
+
stats.skipped[reason] = (stats.skipped[reason] || 0) + 1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async #facts(context, rows) {
|
|
186
|
+
const present = new Map(REMOVABLE_KINDS.map((kind) => [kind, new Set()]));
|
|
187
|
+
await this.#chunked(rows, (row) => {
|
|
188
|
+
const fact = this.factRow(context, row);
|
|
189
|
+
if (!fact) return;
|
|
190
|
+
present.get(row.kind)?.add(row.key);
|
|
191
|
+
if (row.kind === 'delivery') for (const stream of fact.streams || []) present.get('stream').add(stream.key);
|
|
192
|
+
});
|
|
193
|
+
// A fact the hub no longer has (keepFacts, dropFact) is removed in Pulse too, never inferred there from absence.
|
|
194
|
+
const removed = REMOVABLE_KINDS.flatMap((kind) => this.store.entities(`fact:${kind}:`)
|
|
195
|
+
.map((entity) => ({ kind, entity, key: entity.slice(`fact:${kind}:`.length) }))
|
|
196
|
+
.filter((entry) => !present.get(entry.kind).has(entry.key)));
|
|
197
|
+
await this.#chunked(removed, ({ kind, key }) => this.factRemoved(context, kind, key));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** One fact row: its commands queued (or counted unchanged, skipped or refused). Returns the parsed fact, or null. */
|
|
201
|
+
factRow(context, row) {
|
|
202
|
+
if (SKIPPED_FACT_KINDS[row.kind]) {
|
|
203
|
+
this.skip(context, 'facts', SKIPPED_FACT_KINDS[row.kind]);
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
const fact = parseFact(row);
|
|
207
|
+
if (!fact) {
|
|
208
|
+
this.skip(context, 'facts', 'unreadable_payload');
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const rawSha = sha(row.payload);
|
|
212
|
+
const entity = `fact:${row.kind}:${row.key}`;
|
|
213
|
+
const known = this.store.version(entity);
|
|
214
|
+
if (known && known.rawSha === rawSha) {
|
|
215
|
+
context.report.facts.read += 1;
|
|
216
|
+
context.report.facts.unchanged += 1;
|
|
217
|
+
return fact;
|
|
218
|
+
}
|
|
219
|
+
const prepared = this.factCommands(context, row, fact);
|
|
220
|
+
prepared[0] = { ...prepared[0], rawSha };
|
|
221
|
+
const queued = this.queue(context, 'facts', prepared);
|
|
222
|
+
if (!queued && known && !prepared.some((item) => item.refused)) this.store.touchVersion(entity, rawSha);
|
|
223
|
+
this.count(context, 'facts', prepared, queued);
|
|
224
|
+
return fact;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A fact Pulse has that the hub dropped: removed in Pulse, keyed by the version it had. No-op if never sent. */
|
|
228
|
+
factRemoved(context, kind, key) {
|
|
229
|
+
const entity = `fact:${kind}:${key}`;
|
|
230
|
+
const version = this.store.version(entity);
|
|
231
|
+
if (!version) return;
|
|
232
|
+
const prepared = [this.prepare(context, 'facts', {
|
|
233
|
+
type: 'fact.removed', group: entity, payload: { kind, key, at: context.now },
|
|
234
|
+
fixedKey: `${keyPart(`rm:${kind}`, key)}:${version.sha}`,
|
|
235
|
+
})];
|
|
236
|
+
if (this.queue(context, 'facts', prepared)) this.store.dropVersion(entity);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Run `fn` over rows in store transactions of YIELD_EVERY rows, giving the event loop a turn between them. */
|
|
240
|
+
async #chunked(rows, fn) {
|
|
241
|
+
for (let start = 0; start < rows.length; start += YIELD_EVERY) {
|
|
242
|
+
this.store.transaction(() => {
|
|
243
|
+
for (const row of rows.slice(start, start + YIELD_EVERY)) fn(row);
|
|
244
|
+
});
|
|
245
|
+
await yieldToLoop();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
factCommands(context, row, fact) {
|
|
250
|
+
return factDescriptors(row, fact).map(({ type, entity, prefix, group, payload }) => this.prepare(context, 'facts', { type, group, payload, entity, prefix }));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async #actors(context, rows) {
|
|
254
|
+
await this.#chunked(rows, (actor) => this.actorRow(context, actor));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** One actor row in the registry's shape: registered first, updated after, coalesced as the shadow does. */
|
|
258
|
+
actorRow(context, actor) {
|
|
259
|
+
const entity = `actor:${actor.id}`;
|
|
260
|
+
const rawSha = sha(JSON.stringify(actor));
|
|
261
|
+
const known = this.store.version(entity);
|
|
262
|
+
if (known && known.rawSha === rawSha) {
|
|
263
|
+
context.report.actors.read += 1;
|
|
264
|
+
context.report.actors.unchanged += 1;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// The first version a store sees is the registration (`actor:<id>:<sha>`); later ones are updates
|
|
268
|
+
// (`actor:<id>@<sha>`), so the same content never travels under one key as two types.
|
|
269
|
+
const registered = !known;
|
|
270
|
+
const prepared = this.prepare(context, 'actors', {
|
|
271
|
+
type: registered ? 'actor.registered' : 'actor.updated', group: entity, entity, prefix: keyPart('actor', actor.id),
|
|
272
|
+
separator: registered ? ':' : '@', payload: actorPayload(actor),
|
|
273
|
+
});
|
|
274
|
+
const staticSha = prepared.entry ? short(canonicalJson(without(prepared.entry.payload.actor, ...ACTOR_TIME_FIELDS))) : null;
|
|
275
|
+
if (context.shadow && known && prepared.entry && known.staticSha === staticSha && context.now - known.at < ACTOR_COALESCE_MS) {
|
|
276
|
+
this.skip(context, 'actors', 'coalesced');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const item = { ...prepared, rawSha, staticSha };
|
|
280
|
+
const queued = this.queue(context, 'actors', [item]);
|
|
281
|
+
if (!queued && prepared.unchanged) this.store.touchVersion(entity, rawSha);
|
|
282
|
+
this.count(context, 'actors', [item], queued);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async #messages(context, rows, recipients, cursor) {
|
|
286
|
+
let newest = cursor.createdAt;
|
|
287
|
+
await this.#chunked(rows, (message) => {
|
|
288
|
+
newest = Math.max(newest, Number(message.created_at));
|
|
289
|
+
this.messageRow(context, message, recipients.get(message.id) || []);
|
|
290
|
+
});
|
|
291
|
+
// The cursor moves only after every chunk it covers is durably queued; a crash before this rescans and dedupes.
|
|
292
|
+
this.store.transaction(() => {
|
|
293
|
+
this.store.setCursor('messages', { createdAt: newest });
|
|
294
|
+
this.store.pruneRecentMessages(context.now - RETRACT_WINDOW_MS);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** One bus message with its recipient rows: published once, then each receipt its rows' states imply. */
|
|
299
|
+
messageRow(context, message, to) {
|
|
300
|
+
const group = `bus:${message.topic}`;
|
|
301
|
+
const refusedBefore = this.store.deadCode(message.id);
|
|
302
|
+
if (refusedBefore) {
|
|
303
|
+
this.count(context, 'messages', [{ refused: refusedBefore }], false);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const published = this.store.known(message.id) ? null : this.prepare(context, 'messages', {
|
|
307
|
+
type: 'message.published', group, fixedKey: message.id, payload: messagePayload(message, to),
|
|
308
|
+
});
|
|
309
|
+
// A message that cannot go up takes its receipts with it: they would wait for it forever.
|
|
310
|
+
if (published?.refused) {
|
|
311
|
+
this.count(context, 'messages', [published], false);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const prepared = published ? [published] : [];
|
|
315
|
+
const states = [];
|
|
316
|
+
for (const row of to) {
|
|
317
|
+
const { state, events } = receiptEvents(row, message.topic, message.expires_at);
|
|
318
|
+
states.push([row.recipient, state]);
|
|
319
|
+
for (const event of events) {
|
|
320
|
+
if (!this.store.known(event.key)) prepared.push(this.prepare(context, 'messages', { type: event.type, group: event.group, fixedKey: event.key, payload: event.payload }));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const queued = this.queue(context, 'messages', prepared);
|
|
324
|
+
for (const [recipient, state] of states) this.store.setReceiptState(message.id, recipient, state);
|
|
325
|
+
if (Number(message.created_at) >= context.now - RETRACT_WINDOW_MS) this.store.rememberMessage(message.id, Number(message.created_at), group);
|
|
326
|
+
this.count(context, 'messages', prepared, queued);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async #receiptTransitions(context, open, gone) {
|
|
330
|
+
await this.#chunked(open, (entry) => this.receiptRow(context, entry));
|
|
331
|
+
await this.#chunked(gone, (message) => this.messageGone(context, message));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** A recipient row whose state may have moved since it was last sent: `{ messageId, recipient, state, row }`. */
|
|
335
|
+
receiptRow(context, entry) {
|
|
336
|
+
if (!entry.row) {
|
|
337
|
+
this.skip(context, 'receipts', 'message_deleted');
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const { state, events } = receiptEvents(entry.row, entry.row.topic, entry.row.expires_at);
|
|
341
|
+
if (state === entry.state) {
|
|
342
|
+
context.report.receipts.read += 1;
|
|
343
|
+
context.report.receipts.unchanged += 1;
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const prepared = events.filter((event) => !this.store.known(event.key))
|
|
347
|
+
.map((event) => this.prepare(context, 'receipts', { type: event.type, group: event.group, fixedKey: event.key, payload: event.payload }));
|
|
348
|
+
const queued = this.queue(context, 'receipts', prepared);
|
|
349
|
+
this.store.setReceiptState(entry.messageId, entry.recipient, state);
|
|
350
|
+
this.count(context, 'receipts', prepared, queued);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A sent message the hub deleted (`{ id, group }`): retracted in Pulse. */
|
|
354
|
+
messageGone(context, message) {
|
|
355
|
+
const prepared = [this.prepare(context, 'receipts', {
|
|
356
|
+
type: 'message.retracted', group: message.group, fixedKey: `retract:${message.id}`, payload: { id: message.id, at: context.now },
|
|
357
|
+
})];
|
|
358
|
+
const queued = this.queue(context, 'receipts', prepared);
|
|
359
|
+
if (queued || prepared[0].refused || this.store.known(`retract:${message.id}`)) {
|
|
360
|
+
this.store.forgetMessage(message.id);
|
|
361
|
+
this.store.dropReceipts(message.id);
|
|
362
|
+
}
|
|
363
|
+
this.count(context, 'receipts', prepared, queued);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async #events(context, rows, cursor) {
|
|
367
|
+
let last = cursor.id;
|
|
368
|
+
await this.#chunked(rows, (event) => {
|
|
369
|
+
last = Math.max(last, Number(event.id));
|
|
370
|
+
this.eventRow(context, event);
|
|
371
|
+
});
|
|
372
|
+
this.store.setCursor('events', { id: last });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
eventRow(context, event) {
|
|
376
|
+
const prepared = [this.prepare(context, 'events', {
|
|
377
|
+
type: 'audit.recorded', group: 'audit', fixedKey: `evt:${event.id}`,
|
|
378
|
+
payload: eventPayload(event),
|
|
379
|
+
})];
|
|
380
|
+
this.count(context, 'events', prepared, this.queue(context, 'events', prepared));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async #personas(context) {
|
|
384
|
+
const file = path.join(this.hubDir, 'state', 'personas.json');
|
|
385
|
+
let info;
|
|
386
|
+
try {
|
|
387
|
+
info = await stat(file);
|
|
388
|
+
} catch {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const position = { mtimeMs: info.mtimeMs, size: info.size };
|
|
392
|
+
const last = context.shadow ? this.store.cursor('personas') : null;
|
|
393
|
+
if (last && last.mtimeMs === position.mtimeMs && last.size === position.size) return;
|
|
394
|
+
let personas;
|
|
395
|
+
try {
|
|
396
|
+
personas = JSON.parse(await readFile(file, 'utf8'));
|
|
397
|
+
} catch {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
let index = 0;
|
|
401
|
+
for (const [id, persona] of Object.entries(personas && typeof personas === 'object' ? personas : {})) {
|
|
402
|
+
if ((index += 1) % YIELD_EVERY === 0) await yieldToLoop();
|
|
403
|
+
this.store.transaction(() => {
|
|
404
|
+
const entity = `persona:${id}`;
|
|
405
|
+
const prepared = [this.prepare(context, 'personas', { type: 'persona.updated', group: entity, entity, prefix: keyPart('persona', id), payload: personaPayload(id, persona) })];
|
|
406
|
+
this.count(context, 'personas', prepared, this.queue(context, 'personas', prepared));
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
this.store.setCursor('personas', position);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async #chat(context, messages) {
|
|
413
|
+
const dir = path.join(this.hubDir, 'state', 'chat');
|
|
414
|
+
let names = [];
|
|
415
|
+
try {
|
|
416
|
+
names = (await readdir(dir)).filter((name) => name.endsWith('.jsonl')).sort();
|
|
417
|
+
} catch {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const busIds = new Set(messages.map((message) => message.id));
|
|
421
|
+
for (const name of names) {
|
|
422
|
+
const base = name.slice(0, -'.jsonl'.length);
|
|
423
|
+
const lines = (await readFile(path.join(dir, name), 'utf8')).split('\n').filter(Boolean);
|
|
424
|
+
const done = context.shadow ? this.store.cursor(`chat:${base}`)?.lines ?? 0 : 0;
|
|
425
|
+
this.store.transaction(() => {
|
|
426
|
+
for (const [index, line] of lines.entries()) {
|
|
427
|
+
if (index < done) continue;
|
|
428
|
+
let entry;
|
|
429
|
+
try {
|
|
430
|
+
entry = JSON.parse(line);
|
|
431
|
+
} catch {
|
|
432
|
+
this.skip(context, 'chat', 'unreadable_line');
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (!Number.isFinite(entry?.at) || typeof entry.text !== 'string' || typeof entry.from !== 'string') {
|
|
436
|
+
this.skip(context, 'chat', 'unreadable_line');
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (base.startsWith('dm-')) {
|
|
440
|
+
const id = `m-${base}-${entry.at}`;
|
|
441
|
+
if (busIds.has(id) || this.store.known(id)) this.skip(context, 'chat', 'already_bus_message');
|
|
442
|
+
else this.count(context, 'chat', [{ refused: 'dm_not_on_bus' }], false);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (!base.startsWith('project-')) {
|
|
446
|
+
this.skip(context, 'chat', 'unknown_chat_file');
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const project = base.slice('project-'.length);
|
|
450
|
+
const prepared = [this.prepare(context, 'chat', {
|
|
451
|
+
type: 'post.created', group: `conv:project:${project}`, fixedKey: `${keyPart('chat', base)}:${index}:${short(line)}`,
|
|
452
|
+
payload: { channel: `project:${project}`, author: entry.from, role: entry.from, type: 'MESSAGE', body: entry.text, at: entry.at },
|
|
453
|
+
})];
|
|
454
|
+
this.count(context, 'chat', prepared, this.queue(context, 'chat', prepared));
|
|
455
|
+
}
|
|
456
|
+
this.store.setCursor(`chat:${base}`, { lines: lines.length });
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async #channels(context, only = null) {
|
|
462
|
+
const { files, collisions, unnamed } = await channelFiles(this.roots);
|
|
463
|
+
for (const _ of collisions) this.skip(context, 'channels', 'shadowed_by_first_root');
|
|
464
|
+
for (const _ of unnamed) this.skip(context, 'channels', 'name_outside_key_charset');
|
|
465
|
+
for (const file of files) {
|
|
466
|
+
if (only && !only.channels.has(`task:${file.key}`)) continue;
|
|
467
|
+
const position = { mtimeMs: file.mtimeMs, size: file.size };
|
|
468
|
+
const last = this.store.cursor(`channel:${file.key}`);
|
|
469
|
+
if (context.shadow && last && last.mtimeMs === position.mtimeMs && last.size === position.size) continue;
|
|
470
|
+
if (context.shadow && context.now - file.mtimeMs < CHANNEL_QUIET_MS) continue;
|
|
471
|
+
let text;
|
|
472
|
+
try {
|
|
473
|
+
text = await readFile(file.file, 'utf8');
|
|
474
|
+
} catch {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
await yieldToLoop();
|
|
478
|
+
this.store.transaction(() => {
|
|
479
|
+
this.#ingestChannel(context, file, text);
|
|
480
|
+
this.store.setCursor(`channel:${file.key}`, position);
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Diff one channel file against what was ingested from it: a post is identified by the digest of its own block and
|
|
487
|
+
* its occurrence among identical blocks, so an edit or insert elsewhere leaves it alone. A post whose block changed
|
|
488
|
+
* in place (its old digest gone, a new one at the same position) is an edit of its first key; a post that vanished
|
|
489
|
+
* stays in Pulse and is recorded once. `at` is fixed at first ingestion.
|
|
490
|
+
*/
|
|
491
|
+
#ingestChannel(context, file, text) {
|
|
492
|
+
const channel = `task:${file.key}`;
|
|
493
|
+
const group = `conv:${channel}`;
|
|
494
|
+
const posts = splitPosts(text, file.mtimeMs);
|
|
495
|
+
const before = this.store.ingested(channel);
|
|
496
|
+
const seen = new Map();
|
|
497
|
+
const current = posts.map((post, ordinal) => {
|
|
498
|
+
const digest = sha(post.text).slice(0, 16);
|
|
499
|
+
const n = seen.get(digest) || 0;
|
|
500
|
+
seen.set(digest, n + 1);
|
|
501
|
+
return { post, ordinal, id: `${digest}:${n}` };
|
|
502
|
+
});
|
|
503
|
+
const ids = new Set(current.map((entry) => entry.id));
|
|
504
|
+
const byId = new Map(before.map((row) => [row.sha, row]));
|
|
505
|
+
const byOrdinal = new Map(before.map((row) => [row.ordinal, row]));
|
|
506
|
+
const prepared = [];
|
|
507
|
+
if (!this.store.known(`channel:${keyPart('task', file.key)}`)) {
|
|
508
|
+
prepared.push(this.prepare(context, 'channels', { type: 'channel.created', group, fixedKey: `channel:${keyPart('task', file.key)}`, payload: { key: channel } }));
|
|
509
|
+
}
|
|
510
|
+
this.queue(context, 'channels', prepared);
|
|
511
|
+
const kept = new Set();
|
|
512
|
+
const rows = [];
|
|
513
|
+
for (const { post, ordinal, id } of current) {
|
|
514
|
+
const known = byId.get(id);
|
|
515
|
+
if (known) {
|
|
516
|
+
kept.add(known.sha);
|
|
517
|
+
rows.push({ ordinal, sha: id, postKey: known.postKey, at: known.at });
|
|
518
|
+
context.report.channels.read += 1;
|
|
519
|
+
context.report.channels.unchanged += 1;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const prior = byOrdinal.get(ordinal);
|
|
523
|
+
const edit = prior && !ids.has(prior.sha) && !kept.has(prior.sha);
|
|
524
|
+
const { body, truncated } = this.#postBody(context, post.text);
|
|
525
|
+
const item = edit
|
|
526
|
+
? this.prepare(context, 'channels', { type: 'post.edited', group, fixedKey: `edit:${prior.postKey.slice(5)}:${id.split(':')[0]}`, payload: { channel, postKey: prior.postKey, body, at: context.now } })
|
|
527
|
+
: this.prepare(context, 'channels', {
|
|
528
|
+
type: 'post.created', group, fixedKey: postKey(file.key, id), payload: postPayload(channel, post, body, truncated),
|
|
529
|
+
});
|
|
530
|
+
const queued = this.queue(context, 'channels', [item]);
|
|
531
|
+
this.count(context, 'channels', [item], queued);
|
|
532
|
+
if (queued || this.store.known(item.entry?.key ?? '')) {
|
|
533
|
+
if (edit) kept.add(prior.sha);
|
|
534
|
+
rows.push({ ordinal, sha: id, postKey: edit ? prior.postKey : item.entry.key, at: edit ? prior.at : post.at });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
for (const row of before) {
|
|
538
|
+
if (kept.has(row.sha) || ids.has(row.sha)) continue;
|
|
539
|
+
const item = this.prepare(context, 'channels', {
|
|
540
|
+
type: 'audit.recorded', group, fixedKey: `vanished:${row.postKey.slice(5)}`,
|
|
541
|
+
payload: { kind: 'channel.post_vanished', actor: null, data: { channel, postKey: row.postKey }, at: context.now },
|
|
542
|
+
});
|
|
543
|
+
this.queue(context, 'channels', [item]);
|
|
544
|
+
context.report.channels.vanished = (context.report.channels.vanished || 0) + 1;
|
|
545
|
+
}
|
|
546
|
+
this.store.replaceIngested(channel, rows);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The block as it will be stored: a long one is redacted first and then cut visibly at the cloud's 16,000
|
|
551
|
+
* characters, with its full length and digest; a short one is redacted with the rest of its payload.
|
|
552
|
+
*/
|
|
553
|
+
#postBody(context, text) {
|
|
554
|
+
if (text.length <= POST_BODY_MAX - 200) return { body: text, truncated: null };
|
|
555
|
+
const redacted = context.redactor.redactString(text);
|
|
556
|
+
context.report.channels.redactions += context.redactor.takeRedactions();
|
|
557
|
+
if (redacted.length <= POST_BODY_MAX) return { body: redacted, truncated: null };
|
|
558
|
+
context.report.channels.cut += 1;
|
|
559
|
+
const marker = `\n\n[… ${redacted.length - POST_BODY_MAX + 80} more characters in channel.md]`;
|
|
560
|
+
const body = `${redacted.slice(0, POST_BODY_MAX - 80)}${marker}`.slice(0, POST_BODY_MAX);
|
|
561
|
+
return { body, truncated: { chars: redacted.length, sha256: sha(redacted) } };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Presence rows due a heartbeat (not log events, D10): the caller sends them and marks them sent. */
|
|
565
|
+
presenceBeats(context, rows) {
|
|
566
|
+
const beats = [];
|
|
567
|
+
for (const row of rows) {
|
|
568
|
+
const last = this.store.presenceSent(row.actor_id);
|
|
569
|
+
const lastSeen = Number(row.last_seen);
|
|
570
|
+
const stopped = row.last_event === 'stop';
|
|
571
|
+
const due = !last || (lastSeen > last.lastSeen && (!context.shadow || context.now - last.sentAt >= PRESENCE_COALESCE_MS || stopped));
|
|
572
|
+
context.report.presence.read += 1;
|
|
573
|
+
if (!due) {
|
|
574
|
+
context.report.presence.unchanged += 1;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
context.report.presence.planned += 1;
|
|
578
|
+
beats.push({ type: 'presence.heartbeat', idempotencyKey: `presence:${short(row.actor_id)}:${lastSeen}`, payload: { actorId: row.actor_id, event: row.last_event ?? null, at: lastSeen } });
|
|
579
|
+
}
|
|
580
|
+
return beats;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function parseFact(row) {
|
|
585
|
+
try {
|
|
586
|
+
const value = JSON.parse(row.payload);
|
|
587
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
588
|
+
} catch {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Sums over every source; presence beats are not events, so they are counted as `beats`, not as planned. */
|
|
594
|
+
function totals(report) {
|
|
595
|
+
const out = { read: 0, planned: 0, unchanged: 0, skipped: 0, refused: 0, events: 0, beats: 0, redactions: 0, balanced: true };
|
|
596
|
+
for (const [source, stats] of Object.entries(report)) {
|
|
597
|
+
const skipped = Object.values(stats.skipped).reduce((sum, n) => sum + n, 0);
|
|
598
|
+
const refused = Object.values(stats.refused).reduce((sum, n) => sum + n, 0);
|
|
599
|
+
out.read += stats.read;
|
|
600
|
+
if (source === 'presence') out.beats += stats.planned;
|
|
601
|
+
else out.planned += stats.planned;
|
|
602
|
+
out.unchanged += stats.unchanged;
|
|
603
|
+
out.skipped += skipped;
|
|
604
|
+
out.refused += refused;
|
|
605
|
+
out.events += Object.values(stats.events).reduce((sum, n) => sum + n, 0);
|
|
606
|
+
out.redactions += stats.redactions;
|
|
607
|
+
if (stats.read !== stats.planned + stats.unchanged + skipped + refused) out.balanced = false;
|
|
608
|
+
}
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { copyFile, mkdir, readdir, rm, stat, utimes, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { channelFiles } from './channelScanner.js';
|
|
4
|
+
import { openDatabase } from './sqlite.js';
|
|
5
|
+
|
|
6
|
+
export const SNAPSHOT_DIR = 'import-snapshot';
|
|
7
|
+
const MANIFEST = 'manifest.json';
|
|
8
|
+
|
|
9
|
+
async function copyKeepingTime(from, to) {
|
|
10
|
+
await mkdir(path.dirname(to), { recursive: true });
|
|
11
|
+
await copyFile(from, to);
|
|
12
|
+
const info = await stat(from);
|
|
13
|
+
await utimes(to, info.atime, info.mtime);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function listing(dir) {
|
|
17
|
+
try {
|
|
18
|
+
return await readdir(dir);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error.code === 'ENOENT') return [];
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A frozen copy of everything an import reads (cloud-first ADR-4): hub.db (VACUUM INTO from a read-only connection,
|
|
27
|
+
* one consistent read), state/personas.json, state/chat/*.jsonl and every channel file under each channel root, with
|
|
28
|
+
* their mtimes. The import and its `--reconcile` scan this copy, so both read the same sources while the hub moves on.
|
|
29
|
+
* Laid out as a hub directory plus one folder per root, so the scanner sees the same names, keys and collisions.
|
|
30
|
+
*/
|
|
31
|
+
export async function takeSnapshot({ hubDir, roots, into, now = Date.now }) {
|
|
32
|
+
await rm(into, { recursive: true, force: true });
|
|
33
|
+
const snapshotHubDir = path.join(into, 'agents-hub');
|
|
34
|
+
const state = path.join(hubDir, 'state');
|
|
35
|
+
await mkdir(path.join(snapshotHubDir, 'state'), { recursive: true, mode: 0o700 });
|
|
36
|
+
const db = openDatabase(path.join(state, 'hub.db'), { readOnly: true });
|
|
37
|
+
try {
|
|
38
|
+
db.prepare('PRAGMA busy_timeout = 5000').run();
|
|
39
|
+
db.prepare('VACUUM INTO ?').run(path.join(snapshotHubDir, 'state', 'hub.db'));
|
|
40
|
+
} finally {
|
|
41
|
+
db.close();
|
|
42
|
+
}
|
|
43
|
+
let files = 0;
|
|
44
|
+
if ((await listing(state)).includes('personas.json')) {
|
|
45
|
+
await copyKeepingTime(path.join(state, 'personas.json'), path.join(snapshotHubDir, 'state', 'personas.json'));
|
|
46
|
+
files += 1;
|
|
47
|
+
}
|
|
48
|
+
for (const name of (await listing(path.join(state, 'chat'))).filter((entry) => entry.endsWith('.jsonl'))) {
|
|
49
|
+
await copyKeepingTime(path.join(state, 'chat', name), path.join(snapshotHubDir, 'state', 'chat', name));
|
|
50
|
+
files += 1;
|
|
51
|
+
}
|
|
52
|
+
const snapshotRoots = roots.map((_root, index) => path.join(into, 'roots', String(index)));
|
|
53
|
+
for (const [index, root] of roots.entries()) {
|
|
54
|
+
await mkdir(snapshotRoots[index], { recursive: true });
|
|
55
|
+
const { files: kept, collisions, unnamed } = await channelFiles([root]);
|
|
56
|
+
for (const entry of [...kept, ...collisions, ...unnamed]) {
|
|
57
|
+
await copyKeepingTime(entry.file, path.join(snapshotRoots[index], path.relative(root, entry.file)));
|
|
58
|
+
files += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const manifest = { takenAt: now(), hubDir, roots, files, snapshotHubDir, snapshotRoots };
|
|
62
|
+
await writeFile(path.join(into, MANIFEST), `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
|
|
63
|
+
return { ...manifest, dir: into };
|
|
64
|
+
}
|