@agentpulselabs/cli-plugin-pulse 0.2.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -32
- package/package.json +14 -19
- 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 +15 -0
- package/src/commands/pulse/hub-write.js +26 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/install.js +14 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/login.js +20 -0
- package/src/commands/pulse/mirror.js +27 -0
- package/src/commands/pulse/pair.js +38 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/restart.js +14 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/commands/pulse/start.js +6 -18
- package/src/commands/pulse/status.js +6 -6
- package/src/commands/pulse/stop.js +24 -0
- package/src/daemon/main.js +43 -0
- 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/BaseCommand.js +23 -23
- package/src/lib/agentSessions.js +279 -0
- package/src/lib/application.js +704 -0
- package/src/lib/backoff.js +8 -0
- package/src/lib/budgets.js +85 -0
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/cliDefaults.js +20 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudAuth.js +189 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +2 -0
- package/src/lib/cutover.js +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonComposition.js +338 -0
- package/src/lib/daemonLock.js +57 -0
- package/src/lib/daemonLoop.js +72 -0
- package/src/lib/daemonProcess.js +380 -0
- package/src/lib/daemonRuntime.js +828 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/drivers.js +280 -0
- package/src/lib/errors.js +33 -0
- package/src/lib/graphWindows.js +149 -0
- package/src/lib/http.js +84 -0
- package/src/lib/hubActions.js +74 -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/identity.js +102 -0
- package/src/lib/intents.js +590 -0
- package/src/lib/ledger.js +535 -0
- package/src/lib/lifecycle.js +123 -0
- package/src/lib/localHub.js +217 -0
- package/src/lib/localHubWriter.js +69 -0
- package/src/lib/mirror-contract.json +4299 -0
- package/src/lib/mirrorContract.js +513 -0
- package/src/lib/mirrorDaemon.js +127 -0
- package/src/lib/mirrorService.js +365 -0
- package/src/lib/mirrorSource.js +430 -0
- package/src/lib/mirrorSync.js +353 -0
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/oauthLogin.js +132 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/ownerLock.js +99 -0
- package/src/lib/pairedConfig.js +40 -0
- package/src/lib/pairingLifecycle.js +179 -0
- package/src/lib/pathRedaction.js +89 -0
- package/src/lib/planContract.js +141 -0
- package/src/lib/priorityLane.js +80 -0
- package/src/lib/processOwnership.js +252 -0
- package/src/lib/processSupervisor.js +376 -0
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/redaction.js +128 -0
- package/src/lib/rootPolicy.js +372 -0
- package/src/lib/rulePacks.js +162 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/secretStore.js +169 -0
- package/src/lib/selfRestart.js +261 -0
- package/src/lib/serviceManager.js +419 -0
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/storage.js +131 -0
- package/src/lib/subprocess.js +45 -0
- package/src/lib/tokenStore.js +136 -0
- package/src/lib/transport.js +334 -0
- package/src/lib/transportContract.js +40 -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
- package/LICENSE +0 -21
- package/src/commands/pulse/gate.js +0 -25
- package/src/commands/pulse/next.js +0 -14
- package/src/commands/pulse/submit.js +0 -21
- package/src/commands/pulse/verify.js +0 -21
- package/src/runPulse.js +0 -58
|
@@ -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';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The import reconciliation view (cloud-first ADR-4): one machine's imported items as families of identities, each a
|
|
5
|
+
* count and a hash, so the connector's fresh scan and the Pulse log can be compared item for item. The connector keeps
|
|
6
|
+
* a byte-identical copy (agentpulse-cli-plugin-pulse src/lib/reconcileView.js, checked by its tests) and builds its
|
|
7
|
+
* side with it.
|
|
8
|
+
*/
|
|
9
|
+
export const RECONCILE_TYPES = Object.freeze([
|
|
10
|
+
'channel.created', 'post.created', 'message.published', 'message.retracted', 'actor.registered', 'actor.row_updated',
|
|
11
|
+
'persona.updated', 'delivery.updated', 'stream.updated', 'request.created', 'delivery.accepted', 'settings.changed',
|
|
12
|
+
'audit.recorded', 'status.overridden', 'fact.removed',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const FACT_KIND = Object.freeze({
|
|
16
|
+
'delivery.updated': 'delivery', 'stream.updated': 'stream', 'request.created': 'request', 'delivery.accepted': 'accepted',
|
|
17
|
+
'settings.changed': 'settings', 'audit.recorded': 'event',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** A count and a hash over the keys, in the order given (posts) or sorted (every other family). */
|
|
21
|
+
export function digest(keys) {
|
|
22
|
+
return { count: keys.length, sha: createHash('sha256').update(keys.join('\n'), 'utf8').digest('hex').slice(0, 16) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sorted = (set) => [...set].sort();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Entries `{ type, key, channel, id, topic, factKey, kind, actorId, digest }` in log order (the connector's in send
|
|
29
|
+
* order). A post or message hashes as `<key> <sha256 of its body>`, so a changed body is a mismatch too. Returns the
|
|
30
|
+
* serializable view and the identity sets behind it.
|
|
31
|
+
*/
|
|
32
|
+
export function reconcileFamilies(entries) {
|
|
33
|
+
const channels = new Set();
|
|
34
|
+
const posts = new Map();
|
|
35
|
+
const messages = new Map();
|
|
36
|
+
const retracted = new Set();
|
|
37
|
+
const actors = new Set();
|
|
38
|
+
const agentRows = new Set();
|
|
39
|
+
const personas = new Set();
|
|
40
|
+
const statusOverrides = new Set();
|
|
41
|
+
const facts = new Map(Object.values(FACT_KIND).map((kind) => [kind, new Set()]));
|
|
42
|
+
const removed = [];
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
switch (entry.type) {
|
|
45
|
+
case 'channel.created':
|
|
46
|
+
channels.add(entry.factKey);
|
|
47
|
+
break;
|
|
48
|
+
case 'post.created':
|
|
49
|
+
channels.add(entry.channel);
|
|
50
|
+
if (!posts.has(entry.channel)) posts.set(entry.channel, []);
|
|
51
|
+
posts.get(entry.channel).push({ key: entry.key, digest: entry.digest });
|
|
52
|
+
break;
|
|
53
|
+
case 'message.published':
|
|
54
|
+
messages.set(entry.id, { topic: entry.topic, digest: entry.digest });
|
|
55
|
+
break;
|
|
56
|
+
case 'message.retracted':
|
|
57
|
+
retracted.add(entry.id);
|
|
58
|
+
break;
|
|
59
|
+
case 'actor.registered':
|
|
60
|
+
actors.add(entry.id);
|
|
61
|
+
break;
|
|
62
|
+
case 'actor.row_updated':
|
|
63
|
+
agentRows.add(entry.id);
|
|
64
|
+
break;
|
|
65
|
+
case 'persona.updated':
|
|
66
|
+
personas.add(entry.actorId);
|
|
67
|
+
break;
|
|
68
|
+
case 'delivery.updated':
|
|
69
|
+
case 'request.created':
|
|
70
|
+
case 'delivery.accepted':
|
|
71
|
+
facts.get(FACT_KIND[entry.type]).add(entry.id);
|
|
72
|
+
break;
|
|
73
|
+
case 'stream.updated':
|
|
74
|
+
case 'settings.changed':
|
|
75
|
+
facts.get(FACT_KIND[entry.type]).add(entry.factKey);
|
|
76
|
+
break;
|
|
77
|
+
case 'audit.recorded':
|
|
78
|
+
facts.get('event').add(entry.key);
|
|
79
|
+
break;
|
|
80
|
+
case 'status.overridden':
|
|
81
|
+
statusOverrides.add(entry.factKey);
|
|
82
|
+
break;
|
|
83
|
+
case 'fact.removed':
|
|
84
|
+
removed.push(entry);
|
|
85
|
+
break;
|
|
86
|
+
default:
|
|
87
|
+
throw new Error(`reconcile: unhandled type ${entry.type}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const id of retracted) messages.delete(id);
|
|
91
|
+
for (const entry of removed) {
|
|
92
|
+
if (entry.kind === 'agent-row') agentRows.delete(entry.factKey);
|
|
93
|
+
else if (entry.kind === 'status_override') statusOverrides.delete(entry.factKey);
|
|
94
|
+
else facts.get(entry.kind)?.delete(entry.factKey);
|
|
95
|
+
}
|
|
96
|
+
const byTopic = new Map();
|
|
97
|
+
for (const [id, { topic, digest: body }] of messages) {
|
|
98
|
+
if (!byTopic.has(topic)) byTopic.set(topic, []);
|
|
99
|
+
byTopic.get(topic).push(`${id} ${body}`);
|
|
100
|
+
}
|
|
101
|
+
const line = (post) => `${post.key} ${post.digest}`;
|
|
102
|
+
const allPosts = [...posts.values()].flat().map(line);
|
|
103
|
+
return {
|
|
104
|
+
sets: { channels, posts, messages, actors, agentRows, personas, statusOverrides, facts },
|
|
105
|
+
view: {
|
|
106
|
+
channels: digest(sorted(channels)),
|
|
107
|
+
posts: { ...digest(allPosts), byChannel: Object.fromEntries([...posts].sort(([a], [b]) => (a < b ? -1 : 1)).map(([key, list]) => [key, digest(list.map(line))])) },
|
|
108
|
+
messages: { ...digest([...messages].map(([id, { digest: body }]) => `${id} ${body}`).sort()), byTopic: Object.fromEntries([...byTopic].sort(([a], [b]) => (a < b ? -1 : 1)).map(([topic, ids]) => [topic, digest(ids.sort())])) },
|
|
109
|
+
actors: digest(sorted(actors)),
|
|
110
|
+
agentRows: digest(sorted(agentRows)),
|
|
111
|
+
personas: digest(sorted(personas)),
|
|
112
|
+
statusOverrides: digest(sorted(statusOverrides)),
|
|
113
|
+
facts: Object.fromEntries([...facts].map(([kind, keys]) => [kind, digest(sorted(keys))])),
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A log row (or a connector command) as a reconcile entry; `digest` is the sha256 hex of a post or message body. */
|
|
119
|
+
export function reconcileEntry(type, key, payload = {}, digest = null) {
|
|
120
|
+
return {
|
|
121
|
+
type, key, channel: payload.channel ?? null, id: payload.id ?? null, topic: payload.topic ?? null,
|
|
122
|
+
factKey: payload.key ?? null, kind: payload.kind ?? null, actorId: payload.actorId ?? null, digest,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The sha256 hex of a body, as the state tables store it (agent_hub_posts.body_digest). */
|
|
127
|
+
export function bodyDigest(body) {
|
|
128
|
+
return createHash('sha256').update(String(body ?? ''), 'utf8').digest('hex');
|
|
129
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CREDENTIAL_REPLACEMENT, SECRET_RULES, WITHHELD_MARKER, redactSecretBlocks, redactSecretLines, secretKeyReplacement, secretLeafReplacement,
|
|
3
|
+
secretSubtreeFamily, v1GateFires,
|
|
4
|
+
} from './mirrorContract.js';
|
|
5
|
+
|
|
6
|
+
function truncateUtf8(value, maxBytes) {
|
|
7
|
+
const bytes = Buffer.from(value, 'utf8');
|
|
8
|
+
if (bytes.length <= maxBytes) return { text: value, truncated: false };
|
|
9
|
+
if (maxBytes <= 0) return { text: '', truncated: true };
|
|
10
|
+
return {
|
|
11
|
+
text: bytes.subarray(0, maxBytes).toString('utf8').replace(/\uFFFD$/, ''),
|
|
12
|
+
truncated: true,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The contract's passes over one string, without the v1 gate: the block pass, the secret table, the line net, then each
|
|
18
|
+
* held credential by identity. Every upload path goes through redactSecrets, which adds the gate.
|
|
19
|
+
*/
|
|
20
|
+
export function redactSecretPasses(input, { secrets = [] } = {}) {
|
|
21
|
+
const blocks = redactSecretBlocks(String(input ?? ''));
|
|
22
|
+
let { text, redactions } = blocks;
|
|
23
|
+
for (const rule of SECRET_RULES) {
|
|
24
|
+
if (!rule.probe.test(text)) continue;
|
|
25
|
+
text = text.replace(rule.pattern, (...match) => {
|
|
26
|
+
redactions += 1;
|
|
27
|
+
const groups = typeof match.at(-1) === 'object' ? match.at(-1) : {};
|
|
28
|
+
return `${groups.keep ?? ''}${rule.replacement}${groups.tail ?? ''}`;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const lines = redactSecretLines(text);
|
|
32
|
+
text = lines.text;
|
|
33
|
+
redactions += lines.redactions;
|
|
34
|
+
for (const secret of secrets) {
|
|
35
|
+
if (!secret || !text.includes(secret)) continue;
|
|
36
|
+
redactions += text.split(secret).length - 1;
|
|
37
|
+
text = text.split(secret).join(CREDENTIAL_REPLACEMENT);
|
|
38
|
+
}
|
|
39
|
+
return blocks.withheld ? { text, redactions, withheld: true } : { text, redactions };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The contract's passes over one string (redactSecretPasses), then its v1 gate. Returns the text, how many matches were
|
|
44
|
+
* replaced, and `withheld` (only when true) when a block had no end or v1's pattern still matches with the markers
|
|
45
|
+
* deleted: the item holding the text is withheld, and a gated text is the withheld marker alone. No replacement matches
|
|
46
|
+
* a rule, so redacting twice changes nothing.
|
|
47
|
+
*/
|
|
48
|
+
export function redactSecrets(input, options = {}) {
|
|
49
|
+
const result = redactSecretPasses(input, options);
|
|
50
|
+
if (result.withheld || !v1GateFires(result.text)) return result;
|
|
51
|
+
return { text: WITHHELD_MARKER, redactions: result.redactions + 1, withheld: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every string value and key of a JSON value through redactSecrets, the whole string value of an entry whose key names
|
|
56
|
+
* a secret through secretKeyReplacement, and every string leaf under such a key through secretLeafReplacement; other
|
|
57
|
+
* values are kept as they are. `withheld` says some string held a block with no end.
|
|
58
|
+
*/
|
|
59
|
+
export function redactSecretsDeep(value, options = {}) {
|
|
60
|
+
let redactions = 0;
|
|
61
|
+
let withheld = false;
|
|
62
|
+
const text = (entry) => {
|
|
63
|
+
const result = redactSecrets(entry, options);
|
|
64
|
+
redactions += result.redactions;
|
|
65
|
+
withheld ||= result.withheld === true;
|
|
66
|
+
return result.text;
|
|
67
|
+
};
|
|
68
|
+
const walk = (entry, family = null) => {
|
|
69
|
+
if (typeof entry === 'string') {
|
|
70
|
+
const replacement = family && secretLeafReplacement(family, entry);
|
|
71
|
+
if (!replacement) return text(entry);
|
|
72
|
+
redactions += 1;
|
|
73
|
+
return replacement;
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(entry)) return entry.map((item) => walk(item, family));
|
|
76
|
+
if (entry && typeof entry === 'object') {
|
|
77
|
+
return Object.fromEntries(Object.entries(entry).map(([key, item]) => {
|
|
78
|
+
const replacement = secretKeyReplacement(key, item);
|
|
79
|
+
if (replacement) redactions += 1;
|
|
80
|
+
return [text(key), replacement ?? walk(item, family ?? secretSubtreeFamily(key, item))];
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
return entry;
|
|
84
|
+
};
|
|
85
|
+
const redacted = walk(value);
|
|
86
|
+
return withheld ? { value: redacted, redactions, withheld } : { value: redacted, redactions };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function redactText(input, { roots = [], maxBytes = 32 * 1024 } = {}) {
|
|
90
|
+
let { text, redactions } = redactSecrets(input);
|
|
91
|
+
for (const root of [...roots].filter(Boolean).sort((left, right) => right.length - left.length)) {
|
|
92
|
+
if (!text.includes(root)) continue;
|
|
93
|
+
redactions += text.split(root).length - 1;
|
|
94
|
+
text = text.split(root).join('<root>');
|
|
95
|
+
}
|
|
96
|
+
const bounded = truncateUtf8(text, maxBytes);
|
|
97
|
+
return { ...bounded, redactions };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function redactObject(value, options = {}) {
|
|
101
|
+
const maxBytes = options.maxBytes ?? 32 * 1024;
|
|
102
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 64) {
|
|
103
|
+
const error = new Error('Object redaction limit must be an integer of at least 64 bytes');
|
|
104
|
+
error.code = 'HUB_REDACTION_LIMIT_INVALID';
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
const roots = [...(options.roots || [])].filter(Boolean).sort((left, right) => right.length - left.length);
|
|
108
|
+
let withheld = false;
|
|
109
|
+
const text = (entry) => {
|
|
110
|
+
const result = redactSecrets(entry);
|
|
111
|
+
withheld ||= result.withheld === true;
|
|
112
|
+
return roots.reduce((out, root) => out.split(root).join('<root>'), result.text);
|
|
113
|
+
};
|
|
114
|
+
const walk = (entry, family = null) => {
|
|
115
|
+
if (typeof entry === 'string') return (family && secretLeafReplacement(family, entry)) || text(entry);
|
|
116
|
+
if (Array.isArray(entry)) return entry.map((item) => walk(item, family));
|
|
117
|
+
if (entry && typeof entry === 'object') {
|
|
118
|
+
return Object.fromEntries(Object.entries(entry).map(([key, item]) => [
|
|
119
|
+
text(key),
|
|
120
|
+
secretKeyReplacement(key, item) ?? walk(item, family ?? secretSubtreeFamily(key, item)),
|
|
121
|
+
]));
|
|
122
|
+
}
|
|
123
|
+
return entry;
|
|
124
|
+
};
|
|
125
|
+
const redacted = walk(value === undefined ? null : value);
|
|
126
|
+
if (!withheld && Buffer.byteLength(JSON.stringify(redacted), 'utf8') <= maxBytes) return redacted;
|
|
127
|
+
return { contentWithheld: true, redactionVersion: 1 };
|
|
128
|
+
}
|