@llblab/pi-telegram 0.51.6 → 0.52.1
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/AGENTS.md +15 -12
- package/BACKLOG.md +16 -9
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +17 -6
- package/dist/lib/activity-verbosity.d.ts +1 -5
- package/dist/lib/activity-verbosity.js +5 -7
- package/dist/lib/bindings.d.ts +5 -1
- package/dist/lib/bindings.js +7 -1
- package/dist/lib/bus-api.js +8 -5
- package/dist/lib/bus-follower.d.ts +54 -56
- package/dist/lib/bus-follower.js +206 -139
- package/dist/lib/bus-leader.d.ts +21 -2
- package/dist/lib/bus-leader.js +181 -25
- package/dist/lib/bus-transport.d.ts +4 -1
- package/dist/lib/bus-transport.js +28 -4
- package/dist/lib/bus.d.ts +52 -56
- package/dist/lib/bus.js +78 -246
- package/dist/lib/channel-posts.d.ts +1 -3
- package/dist/lib/channel-posts.js +23 -26
- package/dist/lib/command-templates.d.ts +0 -5
- package/dist/lib/command-templates.js +5 -5
- package/dist/lib/commands.d.ts +0 -23
- package/dist/lib/commands.js +15 -15
- package/dist/lib/config.d.ts +0 -17
- package/dist/lib/config.js +12 -14
- package/dist/lib/delivery.d.ts +0 -4
- package/dist/lib/delivery.js +2 -5
- package/dist/lib/extension.js +258 -61
- package/dist/lib/generative-apps.d.ts +0 -19
- package/dist/lib/inbound.d.ts +0 -2
- package/dist/lib/inbound.js +2 -2
- package/dist/lib/journal.d.ts +183 -16
- package/dist/lib/journal.js +1175 -248
- package/dist/lib/keyboard.d.ts +0 -2
- package/dist/lib/keyboard.js +2 -2
- package/dist/lib/lifecycle.js +1 -1
- package/dist/lib/locks.d.ts +100 -12
- package/dist/lib/locks.js +380 -14
- package/dist/lib/logging.d.ts +30 -12
- package/dist/lib/logging.js +129 -72
- package/dist/lib/media.d.ts +28 -5
- package/dist/lib/media.js +26 -8
- package/dist/lib/menu-model.d.ts +0 -11
- package/dist/lib/menu-model.js +8 -8
- package/dist/lib/menu-settings.d.ts +0 -18
- package/dist/lib/menu-settings.js +18 -18
- package/dist/lib/menu-status.d.ts +4 -4
- package/dist/lib/menu-status.js +12 -7
- package/dist/lib/menu-thinking.d.ts +1 -1
- package/dist/lib/menu-thinking.js +1 -1
- package/dist/lib/menu.d.ts +1 -0
- package/dist/lib/menu.js +4 -3
- package/dist/lib/outbound-attachments.d.ts +0 -1
- package/dist/lib/outbound-attachments.js +1 -1
- package/dist/lib/outbound-buttons.js +1 -8
- package/dist/lib/outbound-markup.d.ts +1 -6
- package/dist/lib/outbound-markup.js +4 -21
- package/dist/lib/outbound.d.ts +0 -8
- package/dist/lib/outbound.js +5 -5
- package/dist/lib/paths.d.ts +50 -7
- package/dist/lib/paths.js +155 -17
- package/dist/lib/pi.d.ts +1 -0
- package/dist/lib/pi.js +2 -1
- package/dist/lib/polling.d.ts +0 -5
- package/dist/lib/polling.js +5 -5
- package/dist/lib/preview.d.ts +0 -3
- package/dist/lib/preview.js +3 -3
- package/dist/lib/prompt-templates.d.ts +0 -1
- package/dist/lib/prompt-templates.js +1 -1
- package/dist/lib/queue.d.ts +5 -15
- package/dist/lib/queue.js +94 -11
- package/dist/lib/recovery.d.ts +29 -9
- package/dist/lib/recovery.js +104 -33
- package/dist/lib/replies.d.ts +0 -4
- package/dist/lib/replies.js +1 -1
- package/dist/lib/routing.d.ts +74 -8
- package/dist/lib/routing.js +2280 -307
- package/dist/lib/sections.d.ts +0 -2
- package/dist/lib/sections.js +1 -1
- package/dist/lib/status.d.ts +44 -9
- package/dist/lib/status.js +149 -27
- package/dist/lib/sync.d.ts +0 -5
- package/dist/lib/sync.js +5 -4
- package/dist/lib/telegram-api.d.ts +13 -3
- package/dist/lib/telegram-api.js +38 -23
- package/dist/lib/thread-cleanup-manager.d.ts +1 -0
- package/dist/lib/thread-cleanup-manager.js +18 -21
- package/dist/lib/thread-naming.d.ts +32 -5
- package/dist/lib/thread-naming.js +191 -7
- package/dist/lib/thread-reconciler.d.ts +2 -0
- package/dist/lib/thread-reconciler.js +1 -1
- package/dist/lib/threads.d.ts +293 -59
- package/dist/lib/threads.js +1689 -369
- package/dist/lib/turns.d.ts +0 -1
- package/dist/lib/turns.js +1 -1
- package/dist/lib/updates.d.ts +191 -28
- package/dist/lib/updates.js +1048 -114
- package/dist/lib/wire.d.ts +12 -0
- package/dist/lib/wire.js +21 -0
- package/dist/lib/workspace-admission.d.ts +25 -17
- package/dist/lib/workspace-admission.js +95 -20
- package/dist/lib/workspace-identity.d.ts +20 -0
- package/dist/lib/workspace-identity.js +103 -0
- package/dist/lib/workspace-retirement.d.ts +43 -8
- package/dist/lib/workspace-retirement.js +148 -52
- package/dist/package.json +6 -6
- package/dist/skills/generated-control-surface/SKILL.md +3 -3
- package/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/docs/README.md +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +156 -36
- package/docs/callback-namespaces.md +1 -1
- package/docs/delivery.md +1 -1
- package/docs/generative-apps.md +2 -2
- package/docs/inbound.md +1 -1
- package/docs/multi-instance-bus.md +125 -19
- package/docs/public-api.md +3 -1
- package/docs/ui-style.md +23 -9
- package/docs/updates.md +1 -1
- package/lib/activity-verbosity.ts +5 -9
- package/lib/bindings.ts +7 -0
- package/lib/bus-api.ts +9 -4
- package/lib/bus-follower.ts +243 -224
- package/lib/bus-leader.ts +178 -31
- package/lib/bus-transport.ts +30 -8
- package/lib/bus.ts +110 -334
- package/lib/channel-posts.ts +29 -29
- package/lib/command-templates.ts +5 -5
- package/lib/commands.ts +15 -15
- package/lib/config.ts +12 -17
- package/lib/delivery.ts +2 -8
- package/lib/extension.ts +261 -72
- package/lib/generative-apps.ts +0 -22
- package/lib/inbound.ts +2 -2
- package/lib/journal.ts +1250 -325
- package/lib/keyboard.ts +2 -2
- package/lib/lifecycle.ts +1 -1
- package/lib/locks.ts +402 -23
- package/lib/logging.ts +151 -101
- package/lib/media.ts +51 -9
- package/lib/menu-model.ts +8 -8
- package/lib/menu-settings.ts +18 -18
- package/lib/menu-status.ts +11 -0
- package/lib/menu-thinking.ts +2 -2
- package/lib/menu.ts +5 -1
- package/lib/outbound-attachments.ts +1 -1
- package/lib/outbound-buttons.ts +1 -10
- package/lib/outbound-markup.ts +5 -24
- package/lib/outbound.ts +5 -5
- package/lib/paths.ts +177 -17
- package/lib/pi.ts +5 -2
- package/lib/polling.ts +5 -5
- package/lib/preview.ts +3 -3
- package/lib/prompt-templates.ts +1 -1
- package/lib/queue.ts +93 -12
- package/lib/recovery.ts +104 -44
- package/lib/replies.ts +1 -1
- package/lib/routing.ts +2010 -375
- package/lib/sections.ts +1 -1
- package/lib/status.ts +156 -36
- package/lib/sync.ts +4 -4
- package/lib/telegram-api.ts +53 -33
- package/lib/thread-cleanup-manager.ts +24 -21
- package/lib/thread-naming.ts +267 -9
- package/lib/thread-reconciler.ts +3 -1
- package/lib/threads.ts +1736 -488
- package/lib/turns.ts +1 -1
- package/lib/updates.ts +1094 -151
- package/lib/wire.ts +28 -0
- package/lib/workspace-admission.ts +97 -47
- package/lib/workspace-identity.ts +147 -0
- package/lib/workspace-retirement.ts +188 -89
- package/package.json +6 -6
- package/scripts/audit-exports.mjs +100 -0
- package/scripts/check-downgrade.mjs +80 -43
- package/skills/generated-control-surface/SKILL.md +3 -3
- package/skills/telegram-bridge/references/diagnosis.md +3 -3
package/dist/lib/journal.js
CHANGED
|
@@ -6,28 +6,29 @@
|
|
|
6
6
|
* It does not own polling, update execution, queue admission, or follower routing.
|
|
7
7
|
*/
|
|
8
8
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
9
|
-
import { chmodSync, constants, closeSync, fstatSync, lstatSync, openSync, opendirSync, readSync, realpathSync, mkdirSync, readFileSync, readdirSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
9
|
+
import { chmodSync, constants, closeSync, fstatSync, lstatSync, openSync, opendirSync, readSync, realpathSync, mkdirSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
10
10
|
import { basename, dirname, join, resolve, relative, isAbsolute, sep } from "node:path";
|
|
11
11
|
import { isDeepStrictEqual } from "node:util";
|
|
12
12
|
import { renameTelegramPathWithRetry, withTelegramFileTransaction, } from "./locks.js";
|
|
13
13
|
import { getTelegramProcessLiveness, } from "./bus.js";
|
|
14
|
-
import { getTelegramProfilePathSuffix, TELEGRAM_DEFAULT_PROFILE_NAME, } from "./paths.js";
|
|
14
|
+
import { decodeTelegramSessionDirectoryName, getTelegramProfilePathSuffix, TELEGRAM_DEFAULT_PROFILE_NAME, } from "./paths.js";
|
|
15
15
|
import { runWithTelegramWorkspaceAdmissions, } from "./workspace-admission.js";
|
|
16
|
+
import { isWireRecord as isRecord, hasOnlyWireKeys as hasOnlyKeys, isNonNegativeWireInteger as isSafeNonNegativeInteger, isNonEmptyWireString as isNonEmptyString, } from "./wire.js";
|
|
16
17
|
export const TELEGRAM_UPDATE_JOURNAL_VERSION = 1;
|
|
17
18
|
export const TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION = 2;
|
|
18
19
|
export const TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION = 3;
|
|
19
20
|
// Receipt and binding identities survive storage-schema upgrades.
|
|
20
21
|
const TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION = 1;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
const TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES = 10_000;
|
|
23
|
+
const TELEGRAM_UPDATE_JOURNAL_MAX_BYTES = 32 * 1024 * 1024;
|
|
24
|
+
const TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH = 128;
|
|
24
25
|
export const TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH = 256;
|
|
25
|
-
|
|
26
|
+
const TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH = 256;
|
|
26
27
|
const TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES = 4 * 1024;
|
|
27
28
|
const TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES = 64;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH = 128;
|
|
30
|
+
const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH = 32;
|
|
31
|
+
const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH = 256;
|
|
31
32
|
export const TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH = 128;
|
|
32
33
|
export const TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH = 512;
|
|
33
34
|
function escapeTelegramJournalPathPattern(value) {
|
|
@@ -68,7 +69,93 @@ export function discoverTelegramFollowerJournalPaths(input) {
|
|
|
68
69
|
}
|
|
69
70
|
return { paths: Array.from(paths).sort(), complete };
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
+
/** Bounded read-only session-family discovery, not writer closure or deletion authority. */
|
|
73
|
+
export function discoverTelegramSessionJournalPaths(input) {
|
|
74
|
+
const maxEntries = input.maxDirectoryEntries ?? 10_000;
|
|
75
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1)
|
|
76
|
+
return { paths: [], complete: false };
|
|
77
|
+
const root = join(input.directory, "sessions");
|
|
78
|
+
const suffix = getTelegramProfilePathSuffix(input.profileName);
|
|
79
|
+
const paths = new Set();
|
|
80
|
+
let remaining = maxEntries;
|
|
81
|
+
let complete = true;
|
|
82
|
+
const scan = (directory) => {
|
|
83
|
+
const entries = [];
|
|
84
|
+
const handle = opendirSync(directory);
|
|
85
|
+
try {
|
|
86
|
+
for (;;) {
|
|
87
|
+
const entry = handle.readSync();
|
|
88
|
+
if (!entry)
|
|
89
|
+
break;
|
|
90
|
+
if (--remaining < 0) {
|
|
91
|
+
complete = false;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
entries.push(entry);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
handle.closeSync();
|
|
99
|
+
}
|
|
100
|
+
return entries;
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
const stat = lstatSync(root);
|
|
104
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
105
|
+
return { paths: [], complete: false };
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return { paths: [], complete: error.code === "ENOENT" };
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
for (const session of scan(root)) {
|
|
112
|
+
if (!complete)
|
|
113
|
+
break;
|
|
114
|
+
if (!session.isDirectory() || decodeTelegramSessionDirectoryName(session.name) === undefined) {
|
|
115
|
+
complete = false;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
const directory = join(root, session.name);
|
|
119
|
+
const sessionStat = lstatSync(directory);
|
|
120
|
+
if (!sessionStat.isDirectory() || sessionStat.isSymbolicLink()) {
|
|
121
|
+
complete = false;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
for (const entry of scan(directory)) {
|
|
125
|
+
if (!complete)
|
|
126
|
+
break;
|
|
127
|
+
// The leader polling journal and legacy recovery folders are not recipient custody.
|
|
128
|
+
if (entry.name === "recovery" || /^inbox(?:\.[a-zA-Z0-9._-]+)?\.json(?:\.segments|\.retained)?$/u.test(entry.name))
|
|
129
|
+
continue;
|
|
130
|
+
const match = /^journal\.([a-f0-9]{16})(\.[a-zA-Z0-9._-]+)?\.json(\.segments|\.retained)?$/u.exec(entry.name);
|
|
131
|
+
if (!match) {
|
|
132
|
+
complete = false;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
const stat = lstatSync(join(directory, entry.name));
|
|
136
|
+
if (stat.isSymbolicLink() || (match[3] ? !stat.isDirectory() : !stat.isFile())) {
|
|
137
|
+
complete = false;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
if ((match[2] ?? "") !== suffix)
|
|
141
|
+
continue;
|
|
142
|
+
paths.add(join(directory, entry.name.replace(/\.(?:segments|retained)$/u, "")));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
complete = false;
|
|
148
|
+
}
|
|
149
|
+
return { paths: Array.from(paths).sort(), complete };
|
|
150
|
+
}
|
|
151
|
+
/** Transitional discovery covers both retained flat recipients and session-owned families. */
|
|
152
|
+
export function discoverTelegramRecipientJournalPaths(input) {
|
|
153
|
+
const legacy = discoverTelegramFollowerJournalPaths(input);
|
|
154
|
+
const sessions = discoverTelegramSessionJournalPaths(input);
|
|
155
|
+
return { paths: Array.from(new Set([...legacy.paths, ...sessions.paths])).sort(),
|
|
156
|
+
complete: legacy.complete && sessions.complete };
|
|
157
|
+
}
|
|
158
|
+
const TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH = 256;
|
|
72
159
|
export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_COUNT = 256;
|
|
73
160
|
export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_BYTES = 4 * 1024 * 1024;
|
|
74
161
|
export class TelegramUpdateJournalError extends Error {
|
|
@@ -157,7 +244,7 @@ export function getTelegramUpdateJournalAdmissionScopes(updates) {
|
|
|
157
244
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
158
245
|
.map(([, scope]) => scope);
|
|
159
246
|
}
|
|
160
|
-
|
|
247
|
+
function listTelegramUpdateJournalLegacyCustodyCandidates(snapshot) {
|
|
161
248
|
return snapshot.entries.flatMap(entry => {
|
|
162
249
|
const evidence = createTelegramUpdateJournalLegacyCustodyEvidence(entry);
|
|
163
250
|
return evidence ? [{ updateId: evidence.updateId, state: evidence.state,
|
|
@@ -194,25 +281,37 @@ export function normalizeTelegramUpdateJournalLegacyCustodyDispositionAuthority(
|
|
|
194
281
|
evidenceSha256: expected.evidenceSha256, action: value.action,
|
|
195
282
|
operatorAuthorityId: value.operatorAuthorityId, authorizedAtMs: value.authorizedAtMs };
|
|
196
283
|
}
|
|
197
|
-
|
|
198
|
-
|
|
284
|
+
export const TELEGRAM_ROUTING_INPUT_TTL_MS = 60 * 60 * 1000;
|
|
285
|
+
export function createTelegramUpdateJournalEntryDigest(entry) {
|
|
286
|
+
return { updateId: entry.updateId, sourceSha256: createHash("sha256").update(JSON.stringify(entry)).digest("hex") };
|
|
199
287
|
}
|
|
200
|
-
function
|
|
201
|
-
|
|
288
|
+
function inspectRoutingInputExpiry(file, journalBindingKey, updateId) {
|
|
289
|
+
const disposition = file.operatorDispositions?.find(value => value.updateId === updateId &&
|
|
290
|
+
"dispositionKind" in value && value.dispositionKind === "legacy-custody" && value.action === "discard" &&
|
|
291
|
+
value.failureId === `routing-expiry:${value.evidenceSha256}`);
|
|
292
|
+
if (!disposition || !("operatorAuthorityId" in disposition) || file.entries.some(entry => entry.updateId === updateId))
|
|
293
|
+
return undefined;
|
|
294
|
+
return { journalBindingKey, updateId, operatorAuthorityId: disposition.operatorAuthorityId };
|
|
202
295
|
}
|
|
203
|
-
function
|
|
204
|
-
|
|
205
|
-
|
|
296
|
+
function inspectRoutingGroupExpiry(file, journalBindingKey, updateIds) {
|
|
297
|
+
if (!Array.isArray(updateIds) || !updateIds.length || updateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
|
|
298
|
+
updateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= updateIds[index - 1])))
|
|
299
|
+
return undefined;
|
|
300
|
+
if (file.entries.some(entry => updateIds.includes(entry.updateId)))
|
|
301
|
+
return undefined;
|
|
302
|
+
const expired = updateIds.map(id => inspectRoutingInputExpiry(file, journalBindingKey, id)).filter(value => value !== undefined);
|
|
303
|
+
if (!expired.length || expired.some(value => value.operatorAuthorityId !== expired[0].operatorAuthorityId))
|
|
304
|
+
return undefined;
|
|
305
|
+
// Some members may have been acknowledged earlier. Whole donor absence plus one exact expiry ends this known cohort,
|
|
306
|
+
// not recipient custody; fresh Thread protection still independently gates deletion.
|
|
307
|
+
return updateIds.map(updateId => ({ journalBindingKey, updateId, operatorAuthorityId: expired[0].operatorAuthorityId }));
|
|
206
308
|
}
|
|
207
|
-
function
|
|
208
|
-
return
|
|
309
|
+
function createJournalError(code, path, detail, cause) {
|
|
310
|
+
return new TelegramUpdateJournalError(code, path, `Telegram update journal ${detail}: ${path}`, cause === undefined ? undefined : { cause });
|
|
209
311
|
}
|
|
210
312
|
function isSafePositiveInteger(value) {
|
|
211
313
|
return Number.isSafeInteger(value) && value > 0;
|
|
212
314
|
}
|
|
213
|
-
function isNonEmptyString(value) {
|
|
214
|
-
return typeof value === "string" && value.length > 0;
|
|
215
|
-
}
|
|
216
315
|
function isBoundedString(value, maxLength) {
|
|
217
316
|
return isNonEmptyString(value) && value.length <= maxLength;
|
|
218
317
|
}
|
|
@@ -433,8 +532,14 @@ function validateJournalOperatorDisposition(value, path) {
|
|
|
433
532
|
terminalReason: value.terminalReason };
|
|
434
533
|
}
|
|
435
534
|
function journalRequiresExclusion(version) {
|
|
436
|
-
return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION ||
|
|
437
|
-
|
|
535
|
+
return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION || journalHasCustodyFields(version);
|
|
536
|
+
}
|
|
537
|
+
function journalHasCustodyFields(version) {
|
|
538
|
+
return version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
|
|
539
|
+
}
|
|
540
|
+
/** Only legacy v1 carries routing-lifetime, scoped-ACK and pairing-free queue-receipt semantics. */
|
|
541
|
+
export function isTelegramUpdateJournalLegacyFamilyVersion(version) {
|
|
542
|
+
return version === TELEGRAM_UPDATE_JOURNAL_VERSION;
|
|
438
543
|
}
|
|
439
544
|
function validateJournalInputClaim(value, path, updateId) {
|
|
440
545
|
if (!isRecord(value) ||
|
|
@@ -494,6 +599,7 @@ function validateJournalEntry(value, path, version) {
|
|
|
494
599
|
"updateId",
|
|
495
600
|
"update",
|
|
496
601
|
"admittedAtMs",
|
|
602
|
+
...(isTelegramUpdateJournalLegacyFamilyVersion(version) ? ["routingInput"] : []),
|
|
497
603
|
"state",
|
|
498
604
|
"queueKind",
|
|
499
605
|
"queueReceiptId",
|
|
@@ -505,7 +611,7 @@ function validateJournalEntry(value, path, version) {
|
|
|
505
611
|
"terminalReason",
|
|
506
612
|
"terminalFailureId",
|
|
507
613
|
...(journalRequiresExclusion(version) ? ["preApprovalExcluded"] : []),
|
|
508
|
-
...(version
|
|
614
|
+
...(journalHasCustodyFields(version)
|
|
509
615
|
? ["inputClaim", "inputProvenance"] : []),
|
|
510
616
|
]) ||
|
|
511
617
|
!isSafeNonNegativeInteger(value.updateId) ||
|
|
@@ -520,6 +626,20 @@ function validateJournalEntry(value, path, version) {
|
|
|
520
626
|
if (update.update_id !== value.updateId) {
|
|
521
627
|
throw createJournalError("invalid", path, "contains an entry/update id mismatch");
|
|
522
628
|
}
|
|
629
|
+
let routingInput;
|
|
630
|
+
if (value.routingInput !== undefined) {
|
|
631
|
+
const routing = value.routingInput;
|
|
632
|
+
if (!isRecord(routing) || !hasOnlyKeys(routing, ["operatorUserId", "publishedAtMs", "expiresAtMs", "phase"]) ||
|
|
633
|
+
!isSafePositiveInteger(routing.operatorUserId) || !isSafeNonNegativeInteger(routing.publishedAtMs) ||
|
|
634
|
+
routing.publishedAtMs < value.admittedAtMs || !isSafeNonNegativeInteger(routing.expiresAtMs) ||
|
|
635
|
+
routing.expiresAtMs !== routing.publishedAtMs + TELEGRAM_ROUTING_INPUT_TTL_MS ||
|
|
636
|
+
(routing.phase !== "waiting" && routing.phase !== "selected") ||
|
|
637
|
+
(routing.phase === "waiting" && value.state === "queued")) {
|
|
638
|
+
throw createJournalError("invalid", path, "contains invalid routing input lifetime");
|
|
639
|
+
}
|
|
640
|
+
routingInput = { operatorUserId: routing.operatorUserId, publishedAtMs: routing.publishedAtMs,
|
|
641
|
+
expiresAtMs: routing.expiresAtMs, phase: routing.phase };
|
|
642
|
+
}
|
|
523
643
|
const queueKind = value.queueKind;
|
|
524
644
|
const queueReceiptId = value.queueReceiptId;
|
|
525
645
|
const hasQueueMetadata = queueKind !== undefined ||
|
|
@@ -595,6 +715,7 @@ function validateJournalEntry(value, path, version) {
|
|
|
595
715
|
...(inputClaim ? { inputClaim } : {}),
|
|
596
716
|
...(inputProvenance ? { inputProvenance } : {}),
|
|
597
717
|
admittedAtMs: value.admittedAtMs,
|
|
718
|
+
...(routingInput ? { routingInput } : {}),
|
|
598
719
|
state: value.state,
|
|
599
720
|
...(queueKind === "prompt" || queueKind === "control"
|
|
600
721
|
? { queueKind }
|
|
@@ -634,6 +755,29 @@ function assertSupportedJournalVersion(value, path, version = TELEGRAM_UPDATE_JO
|
|
|
634
755
|
throw createJournalError("unsupported-version", path, `uses unsupported version ${String(value.version)}`);
|
|
635
756
|
}
|
|
636
757
|
}
|
|
758
|
+
function validateJournalSourceCompletion(value, path) {
|
|
759
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["updateId", "sourceSha256", "completionSha256"]) ||
|
|
760
|
+
!isSafeNonNegativeInteger(value.updateId) || typeof value.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.sourceSha256) ||
|
|
761
|
+
typeof value.completionSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.completionSha256)) {
|
|
762
|
+
throw createJournalError("invalid", path, "has invalid source completion evidence");
|
|
763
|
+
}
|
|
764
|
+
return { updateId: value.updateId, sourceSha256: value.sourceSha256, completionSha256: value.completionSha256 };
|
|
765
|
+
}
|
|
766
|
+
function validateJournalSourceCompletions(value, path, version, allowEmpty = false) {
|
|
767
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version) || !Array.isArray(value) || !allowEmpty && !value.length) {
|
|
768
|
+
throw createJournalError("invalid", path, "has invalid source completion collection");
|
|
769
|
+
}
|
|
770
|
+
const completions = value.map(item => validateJournalSourceCompletion(item, path));
|
|
771
|
+
const scopes = new Set();
|
|
772
|
+
for (let index = 0; index < completions.length; index++) {
|
|
773
|
+
const completion = completions[index];
|
|
774
|
+
if ((index > 0 && completions[index - 1].updateId >= completion.updateId) || scopes.has(completion.completionSha256)) {
|
|
775
|
+
throw createJournalError("invalid", path, "has duplicate or unordered source completion evidence");
|
|
776
|
+
}
|
|
777
|
+
scopes.add(completion.completionSha256);
|
|
778
|
+
}
|
|
779
|
+
return completions;
|
|
780
|
+
}
|
|
637
781
|
function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION) {
|
|
638
782
|
assertSupportedJournalVersion(value, path, version);
|
|
639
783
|
if (journalRequiresExclusion(version) &&
|
|
@@ -649,6 +793,7 @@ function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION
|
|
|
649
793
|
"botIdentity",
|
|
650
794
|
"entries",
|
|
651
795
|
"operatorDispositions",
|
|
796
|
+
"sourceCompletions",
|
|
652
797
|
]) ||
|
|
653
798
|
!Number.isSafeInteger(value.version) ||
|
|
654
799
|
(value.revision !== undefined &&
|
|
@@ -707,6 +852,14 @@ function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION
|
|
|
707
852
|
}
|
|
708
853
|
}
|
|
709
854
|
const operatorDispositions = (value.operatorDispositions ?? []).map((disposition) => validateJournalOperatorDisposition(disposition, path));
|
|
855
|
+
const sourceCompletions = value.sourceCompletions === undefined ? [] : validateJournalSourceCompletions(value.sourceCompletions, path, version);
|
|
856
|
+
const botIdentity = validateBotIdentity(value.botIdentity, path);
|
|
857
|
+
const activeIds = new Set(entries.map(entry => entry.updateId));
|
|
858
|
+
const discardedIds = new Set(operatorDispositions.filter(disposition => disposition.action === "discard").map(disposition => disposition.updateId));
|
|
859
|
+
if (sourceCompletions.some(completion => activeIds.has(completion.updateId) || discardedIds.has(completion.updateId) ||
|
|
860
|
+
value.acceptedThroughUpdateId !== undefined && completion.updateId > value.acceptedThroughUpdateId)) {
|
|
861
|
+
throw createJournalError("invalid", path, "has contradictory source completion evidence");
|
|
862
|
+
}
|
|
710
863
|
const dispositionFailureIds = new Set();
|
|
711
864
|
const entriesByUpdateId = new Map(entries.map((entry) => [entry.updateId, entry]));
|
|
712
865
|
for (const disposition of operatorDispositions) {
|
|
@@ -729,10 +882,64 @@ function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION
|
|
|
729
882
|
? { acceptedThroughUpdateId: value.acceptedThroughUpdateId }
|
|
730
883
|
: {}),
|
|
731
884
|
profile: value.profile,
|
|
732
|
-
botIdentity
|
|
885
|
+
botIdentity,
|
|
733
886
|
entries,
|
|
734
887
|
...(operatorDispositions.length > 0 ? { operatorDispositions } : {}),
|
|
888
|
+
...(sourceCompletions.length > 0 ? { sourceCompletions } : {}),
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
function assertJournalRoutingInputContinuity(file, entries, path) {
|
|
892
|
+
const previous = new Map(file.entries.map(entry => [entry.updateId, entry.routingInput]));
|
|
893
|
+
for (const entry of entries) {
|
|
894
|
+
const retained = previous.get(entry.updateId), next = entry.routingInput;
|
|
895
|
+
if (retained && (!next || next.operatorUserId !== retained.operatorUserId || next.publishedAtMs !== retained.publishedAtMs ||
|
|
896
|
+
next.expiresAtMs !== retained.expiresAtMs || (retained.phase === "selected" && next.phase !== "selected"))) {
|
|
897
|
+
throw createJournalError("invalid", path, "regresses retained routing input lifetime");
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
function assertJournalSourceCompletionContinuity(file, segment, path) {
|
|
902
|
+
assertJournalRoutingInputContinuity(file, segment.upsertedEntries, path);
|
|
903
|
+
if (segment.sourceCompletions === undefined)
|
|
904
|
+
return;
|
|
905
|
+
const previous = new Map((file.sourceCompletions ?? []).map(completion => [completion.updateId, completion]));
|
|
906
|
+
const next = new Map(segment.sourceCompletions.map(completion => [completion.updateId, completion]));
|
|
907
|
+
if ([...previous].some(([id, completion]) => !isDeepStrictEqual(completion, next.get(id)))) {
|
|
908
|
+
throw createJournalError("invalid", path, "regresses retained source completion evidence");
|
|
909
|
+
}
|
|
910
|
+
const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
|
|
911
|
+
const removed = new Set(segment.removedUpdateIds), upserted = new Set(segment.upsertedEntries.map(entry => entry.updateId));
|
|
912
|
+
const upsertedReceipts = new Set(segment.upsertedEntries.map(entry => entry.queueReceiptId));
|
|
913
|
+
// Grouped markers share one whole-receipt check rather than rescanning the group per source.
|
|
914
|
+
const queueGroups = new Map(), queueRemovals = new Map();
|
|
915
|
+
for (const entry of file.entries) {
|
|
916
|
+
if (!entry.queueReceiptId)
|
|
917
|
+
continue;
|
|
918
|
+
const group = queueGroups.get(entry.queueReceiptId) ?? [];
|
|
919
|
+
group.push(entry);
|
|
920
|
+
queueGroups.set(entry.queueReceiptId, group);
|
|
921
|
+
}
|
|
922
|
+
const queueWasRemoved = (source) => {
|
|
923
|
+
const receiptId = source.queueReceiptId;
|
|
924
|
+
if (!receiptId || !source.queueOwner)
|
|
925
|
+
return false;
|
|
926
|
+
if (queueRemovals.has(receiptId))
|
|
927
|
+
return queueRemovals.get(receiptId);
|
|
928
|
+
const group = queueGroups.get(receiptId);
|
|
929
|
+
const valid = !!group?.length && !upsertedReceipts.has(receiptId) && group.every(entry => removed.has(entry.updateId) && !upserted.has(entry.updateId) && entry.state === "queued" && entry.queueKind === source.queueKind &&
|
|
930
|
+
entry.queueHandoff === undefined && !!entry.queueOwner && areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, source.queueOwner));
|
|
931
|
+
queueRemovals.set(receiptId, valid);
|
|
932
|
+
return valid;
|
|
735
933
|
};
|
|
934
|
+
for (const completion of segment.sourceCompletions) {
|
|
935
|
+
if (previous.has(completion.updateId))
|
|
936
|
+
continue;
|
|
937
|
+
const source = entries.get(completion.updateId);
|
|
938
|
+
if (!removed.has(completion.updateId) || !source || (source.state === "queued" && !queueWasRemoved(source)) || source.state === "failed" ||
|
|
939
|
+
createTelegramUpdateJournalEntryDigest(source).sourceSha256 !== completion.sourceSha256) {
|
|
940
|
+
throw createJournalError("invalid", path, "source completion does not match its removal revision");
|
|
941
|
+
}
|
|
942
|
+
}
|
|
736
943
|
}
|
|
737
944
|
function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION) {
|
|
738
945
|
assertSupportedJournalVersion(value, path, version);
|
|
@@ -751,6 +958,7 @@ function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERS
|
|
|
751
958
|
"upsertedEntries",
|
|
752
959
|
"removedUpdateIds",
|
|
753
960
|
"operatorDispositions",
|
|
961
|
+
"sourceCompletions",
|
|
754
962
|
]) ||
|
|
755
963
|
value.version !== version ||
|
|
756
964
|
!isSafePositiveInteger(value.revision) ||
|
|
@@ -790,6 +998,7 @@ function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERS
|
|
|
790
998
|
removedUpdateIds.push(updateId);
|
|
791
999
|
}
|
|
792
1000
|
const operatorDispositions = (value.operatorDispositions ?? []).map((disposition) => validateJournalOperatorDisposition(disposition, path));
|
|
1001
|
+
const segmentBotIdentity = validateBotIdentity(value.botIdentity, path);
|
|
793
1002
|
return {
|
|
794
1003
|
version,
|
|
795
1004
|
revision: value.revision,
|
|
@@ -798,12 +1007,13 @@ function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERS
|
|
|
798
1007
|
? { acceptedThroughUpdateId: value.acceptedThroughUpdateId }
|
|
799
1008
|
: {}),
|
|
800
1009
|
profile: value.profile,
|
|
801
|
-
botIdentity:
|
|
1010
|
+
botIdentity: segmentBotIdentity,
|
|
802
1011
|
upsertedEntries,
|
|
803
1012
|
removedUpdateIds,
|
|
804
1013
|
...(value.operatorDispositions !== undefined
|
|
805
1014
|
? { operatorDispositions }
|
|
806
1015
|
: {}),
|
|
1016
|
+
...(value.sourceCompletions !== undefined ? { sourceCompletions: validateJournalSourceCompletions(value.sourceCompletions, path, version, true) } : {}),
|
|
807
1017
|
};
|
|
808
1018
|
}
|
|
809
1019
|
/**
|
|
@@ -833,6 +1043,11 @@ export function inspectTelegramInputCustodySourceStatus(input) {
|
|
|
833
1043
|
export function inspectTelegramUpdateJournalFamily(input) {
|
|
834
1044
|
return acquireTelegramUpdateJournalFamily(input).evidence;
|
|
835
1045
|
}
|
|
1046
|
+
/** Classifies private originals against exact discard tombstones; never replay or deletion authority. */
|
|
1047
|
+
export function inspectTelegramUpdateJournalRetention(input) {
|
|
1048
|
+
const acquired = acquireTelegramUpdateJournalFamily(input, undefined, true);
|
|
1049
|
+
return { evidence: acquired.evidence, retainedInputs: acquired.retainedInputs ?? [] };
|
|
1050
|
+
}
|
|
836
1051
|
/**
|
|
837
1052
|
* Read-only source evidence for cooperating writers serialized by the caller through
|
|
838
1053
|
* consumption; never readiness, recovery, or permission to publish. Ancestors retain
|
|
@@ -845,7 +1060,7 @@ export function inspectTelegramUpdateJournalFamily(input) {
|
|
|
845
1060
|
*/
|
|
846
1061
|
export function readTelegramUpdateJournalSource(input) {
|
|
847
1062
|
const version = input.version;
|
|
848
|
-
if (version !== 1 && version !== 2 && version !== 3) {
|
|
1063
|
+
if (version !== 1 && version !== 2 && version !== 3 && version !== 4) {
|
|
849
1064
|
throw createJournalError("unsupported-version", input.path, "requires a selected source version");
|
|
850
1065
|
}
|
|
851
1066
|
return acquireTelegramUpdateJournalFamily({
|
|
@@ -854,7 +1069,7 @@ export function readTelegramUpdateJournalSource(input) {
|
|
|
854
1069
|
limits: { ...input.limits },
|
|
855
1070
|
}, version).evidence;
|
|
856
1071
|
}
|
|
857
|
-
function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
1072
|
+
function acquireTelegramUpdateJournalFamily(input, selectedVersion, inspectRetention = false) {
|
|
858
1073
|
const { path, limits } = input;
|
|
859
1074
|
const acquiredSegments = [];
|
|
860
1075
|
let snapshotRevision = 0;
|
|
@@ -906,7 +1121,7 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
|
906
1121
|
const charge = (value) => {
|
|
907
1122
|
if (!isRecord(value))
|
|
908
1123
|
return;
|
|
909
|
-
for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions"]) {
|
|
1124
|
+
for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions", "sourceCompletions"]) {
|
|
910
1125
|
const collection = value[key];
|
|
911
1126
|
if (!Array.isArray(collection))
|
|
912
1127
|
continue;
|
|
@@ -948,18 +1163,18 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
|
948
1163
|
}
|
|
949
1164
|
};
|
|
950
1165
|
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
951
|
-
const enumerate = () => {
|
|
1166
|
+
const enumerate = (target = segmentDirectory, pattern = /^\d{16}\.json$/u, usedFiles = files) => {
|
|
952
1167
|
const names = [];
|
|
953
|
-
const directory = opendirSync(
|
|
1168
|
+
const directory = opendirSync(target, { bufferSize: 1 });
|
|
954
1169
|
try {
|
|
955
1170
|
for (;;) {
|
|
956
1171
|
const entry = directory.readSync();
|
|
957
1172
|
if (!entry)
|
|
958
1173
|
break;
|
|
959
|
-
if (names.length >= limits.maxFiles -
|
|
1174
|
+
if (names.length >= limits.maxFiles - usedFiles)
|
|
960
1175
|
capacity();
|
|
961
|
-
if (
|
|
962
|
-
fail("has an unexpected
|
|
1176
|
+
if (!pattern.test(entry.name) || !entry.isFile() || entry.isSymbolicLink())
|
|
1177
|
+
fail("has an unexpected journal evidence entry");
|
|
963
1178
|
names.push(entry.name);
|
|
964
1179
|
}
|
|
965
1180
|
}
|
|
@@ -1027,6 +1242,7 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
|
1027
1242
|
if (segment.acceptedThroughUpdateId !== undefined && file.acceptedThroughUpdateId !== undefined &&
|
|
1028
1243
|
segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId)
|
|
1029
1244
|
fail("regresses the admission cursor");
|
|
1245
|
+
assertJournalSourceCompletionContinuity(file, segment, target);
|
|
1030
1246
|
const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
|
|
1031
1247
|
for (const id of segment.removedUpdateIds)
|
|
1032
1248
|
entries.delete(id);
|
|
@@ -1046,16 +1262,70 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
|
1046
1262
|
...(segment.acceptedThroughUpdateId !== undefined ? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId } : {}),
|
|
1047
1263
|
entries: [...entries.values()].sort((a, b) => a.updateId - b.updateId),
|
|
1048
1264
|
operatorDispositions: segment.operatorDispositions ?? file.operatorDispositions,
|
|
1265
|
+
sourceCompletions: segment.sourceCompletions ?? file.sourceCompletions,
|
|
1049
1266
|
};
|
|
1050
1267
|
charge(next);
|
|
1051
1268
|
file = parseJournalFile(next, target, file.version);
|
|
1052
1269
|
}
|
|
1053
1270
|
}
|
|
1271
|
+
const retainedInputs = [];
|
|
1272
|
+
const retentionDirectory = `${path}.retained`;
|
|
1273
|
+
const retention = inspectRetention ? observe(retentionDirectory, true, true) : undefined;
|
|
1274
|
+
const retentionPattern = /^abandon-[a-f0-9]{64}\.json$/u;
|
|
1275
|
+
const retentionNames = retention ? enumerate(retentionDirectory, retentionPattern) : [];
|
|
1276
|
+
if (retentionNames.length > limits.maxEntries)
|
|
1277
|
+
capacity();
|
|
1278
|
+
if (retention) {
|
|
1279
|
+
if (!file || file.version !== 1)
|
|
1280
|
+
fail("private retention requires its exact v1 journal snapshot");
|
|
1281
|
+
const current = file;
|
|
1282
|
+
const bindingKeys = new Set([expected, current.botIdentity].map(botIdentity => createTelegramUpdateJournalBindingKey({ path, profileName: input.profile, botIdentity })));
|
|
1283
|
+
const updateIds = new Set();
|
|
1284
|
+
for (const name of retentionNames) {
|
|
1285
|
+
const target = join(retentionDirectory, name);
|
|
1286
|
+
const before = observe(target, false);
|
|
1287
|
+
if (before.nlink !== 1n)
|
|
1288
|
+
fail("has linked private retention evidence");
|
|
1289
|
+
const raw = read(target, before);
|
|
1290
|
+
files += 1;
|
|
1291
|
+
if (limits.maxWork - work < 2)
|
|
1292
|
+
capacity();
|
|
1293
|
+
work += 2;
|
|
1294
|
+
if (!isRecord(raw))
|
|
1295
|
+
throw createJournalError("invalid", target, "private retention is not an evidence record");
|
|
1296
|
+
if (typeof raw.journalBindingKey !== "string" || !bindingKeys.has(raw.journalBindingKey))
|
|
1297
|
+
throw createJournalError("invalid", target, "has foreign private retention evidence");
|
|
1298
|
+
const entry = validateJournalEntry(raw.entry, target, 1);
|
|
1299
|
+
const disposition = validateJournalOperatorDisposition(raw.requestedDisposition, target);
|
|
1300
|
+
const digest = createHash("sha256").update(JSON.stringify({ journalBindingKey: raw.journalBindingKey, entry })).digest("hex");
|
|
1301
|
+
if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
|
|
1302
|
+
disposition.action !== "discard" || entry.state !== "pending" || disposition.updateId !== entry.updateId ||
|
|
1303
|
+
disposition.evidenceSha256 !== digest || disposition.failureId !== `abandon-${digest}` ||
|
|
1304
|
+
name !== `${disposition.failureId}.json` || !isDeepStrictEqual(raw, { version: 1, kind: "pending-input-retention",
|
|
1305
|
+
journalBindingKey: raw.journalBindingKey, entry, requestedDisposition: disposition }))
|
|
1306
|
+
fail("has inconsistent private retention evidence");
|
|
1307
|
+
if (updateIds.has(entry.updateId))
|
|
1308
|
+
fail("has duplicate private retention sources");
|
|
1309
|
+
updateIds.add(entry.updateId);
|
|
1310
|
+
const committed = current.operatorDispositions?.find(value => value.failureId === disposition.failureId || value.updateId === entry.updateId);
|
|
1311
|
+
if (committed && (!isDeepStrictEqual(committed, disposition) || current.entries.some(value => value.updateId === entry.updateId)))
|
|
1312
|
+
fail("private retention contradicts its journal disposition");
|
|
1313
|
+
retainedInputs.push({ path: target, journalBindingKey: raw.journalBindingKey,
|
|
1314
|
+
failureId: disposition.failureId, updateId: entry.updateId, state: committed ? "committed" : "uncommitted" });
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
if (inspectRetention && file?.operatorDispositions?.some(disposition => "dispositionKind" in disposition && disposition.dispositionKind === "legacy-custody" &&
|
|
1318
|
+
disposition.action === "discard" && /^abandon-[a-f0-9]{64}$/u.test(disposition.failureId) &&
|
|
1319
|
+
!retainedInputs.some(original => original.updateId === disposition.updateId && original.state === "committed")))
|
|
1320
|
+
fail("committed abandonment lost its private original");
|
|
1054
1321
|
// Re-enumeration has the same bound, rather than allocating an unbounded census.
|
|
1055
1322
|
files = snapshot ? 1 : 0;
|
|
1056
1323
|
if (segments && !isDeepStrictEqual(names, enumerate()))
|
|
1057
1324
|
fail("changed segment namespace");
|
|
1058
1325
|
files += names.length;
|
|
1326
|
+
if (retention && !isDeepStrictEqual(retentionNames, enumerate(retentionDirectory, retentionPattern)))
|
|
1327
|
+
fail("changed private retention namespace");
|
|
1328
|
+
files += retentionNames.length;
|
|
1059
1329
|
for (const [target, before] of observed) {
|
|
1060
1330
|
const after = status(target);
|
|
1061
1331
|
const compare = selectedVersion !== undefined && ancestors.has(target) ? sameAncestor : same;
|
|
@@ -1079,6 +1349,7 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
|
1079
1349
|
evidence: file ? { kind: "present", file, ...(knownBotId !== undefined ? { knownBotId } : {}), accounting: { files, bytes, work } } : { kind: "absent" },
|
|
1080
1350
|
snapshotRevision,
|
|
1081
1351
|
segments: acquiredSegments,
|
|
1352
|
+
...(inspectRetention ? { retainedInputs } : {}),
|
|
1082
1353
|
};
|
|
1083
1354
|
}
|
|
1084
1355
|
catch (error) {
|
|
@@ -1087,13 +1358,14 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
|
1087
1358
|
throw createJournalError("io", path, "could not establish strict journal evidence", error);
|
|
1088
1359
|
}
|
|
1089
1360
|
}
|
|
1090
|
-
/**
|
|
1091
|
-
* Canonical namespace evidence only, never source readiness or authorization.
|
|
1092
|
-
* Caller-proven serialization/quiescence is mandatory through consumption.
|
|
1093
|
-
* Recensus detects observable changes, not hostile same-user path swaps; arbitrary
|
|
1094
|
-
* consumer references and archive consumption require separate audits.
|
|
1095
|
-
*/
|
|
1096
1361
|
export function inspectTelegramProfileJournalNamespace(input) {
|
|
1362
|
+
return inspectTelegramJournalNamespace(input, false);
|
|
1363
|
+
}
|
|
1364
|
+
/** Read-only evidence over polling, retained flat recipients and role-neutral session journals. */
|
|
1365
|
+
export function inspectTelegramSessionJournalNamespace(input) {
|
|
1366
|
+
return inspectTelegramJournalNamespace(input, true);
|
|
1367
|
+
}
|
|
1368
|
+
function inspectTelegramJournalNamespace(input, includeSessions) {
|
|
1097
1369
|
const { directory, profile, limits } = input;
|
|
1098
1370
|
const fail = (message) => { throw createJournalError("invalid", directory, message); };
|
|
1099
1371
|
const capacity = () => { throw createJournalError("capacity", directory, "exceeds namespace inspection resource limits"); };
|
|
@@ -1114,7 +1386,15 @@ export function inspectTelegramProfileJournalNamespace(input) {
|
|
|
1114
1386
|
size: value.size, mtimeNs: value.mtimeNs, ctimeNs: value.ctimeNs };
|
|
1115
1387
|
};
|
|
1116
1388
|
const suffix = profile === "default" ? "" : `.${profile}`;
|
|
1117
|
-
const
|
|
1389
|
+
const pollingPath = input.pollingPath ?? join(directory, `inbox${suffix}.json`);
|
|
1390
|
+
const polling = relative(directory, pollingPath);
|
|
1391
|
+
{
|
|
1392
|
+
const parts = polling.split(sep);
|
|
1393
|
+
if (!isAbsolute(pollingPath) || resolve(pollingPath) !== pollingPath || parts.at(-1) !== `inbox${suffix}.json` ||
|
|
1394
|
+
!(parts.length === 1 || (parts.length === 3 && parts[0] === "sessions" &&
|
|
1395
|
+
decodeTelegramSessionDirectoryName(parts[1]) !== undefined)))
|
|
1396
|
+
fail("requires a canonical polling journal path");
|
|
1397
|
+
}
|
|
1118
1398
|
try {
|
|
1119
1399
|
const root = lstatSync(directory);
|
|
1120
1400
|
if (!root.isDirectory() || root.isSymbolicLink() || realpathSync(directory) !== directory)
|
|
@@ -1122,51 +1402,106 @@ export function inspectTelegramProfileJournalNamespace(input) {
|
|
|
1122
1402
|
const beforeRoot = metadata(directory);
|
|
1123
1403
|
const census = () => {
|
|
1124
1404
|
const entries = new Map();
|
|
1125
|
-
const
|
|
1126
|
-
const
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
fail("has a noncanonical journal-like entry");
|
|
1143
|
-
const value = lstatSync(target);
|
|
1144
|
-
if (value.isSymbolicLink() || !(match[3] ? value.isDirectory() : value.isFile()))
|
|
1145
|
-
fail("has a linked or unexpected journal type");
|
|
1146
|
-
if ((match[2] ?? "default") !== profile)
|
|
1147
|
-
continue;
|
|
1148
|
-
if (match[1] !== "inbox")
|
|
1149
|
-
followers.add(entry.name.replace(/\.segments$/u, ""));
|
|
1405
|
+
const families = new Map(), profiles = new Set();
|
|
1406
|
+
const scan = (parent, visit) => {
|
|
1407
|
+
const handle = opendirSync(parent, { bufferSize: 1 });
|
|
1408
|
+
try {
|
|
1409
|
+
for (;;) {
|
|
1410
|
+
const entry = handle.readSync();
|
|
1411
|
+
if (!entry)
|
|
1412
|
+
break;
|
|
1413
|
+
if (entries.size >= limits.maxDirectoryEntries)
|
|
1414
|
+
capacity();
|
|
1415
|
+
const target = join(parent, entry.name);
|
|
1416
|
+
const key = relative(directory, target);
|
|
1417
|
+
if (entries.has(key))
|
|
1418
|
+
fail("repeated an observed namespace entry");
|
|
1419
|
+
entries.set(key, metadata(target));
|
|
1420
|
+
visit(entry.name, target);
|
|
1421
|
+
}
|
|
1150
1422
|
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1423
|
+
finally {
|
|
1424
|
+
handle.closeSync();
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
const requireType = (target, isDirectory) => {
|
|
1428
|
+
const value = lstatSync(target);
|
|
1429
|
+
if (value.isSymbolicLink() || !(isDirectory ? value.isDirectory() : value.isFile()) ||
|
|
1430
|
+
(!isDirectory && value.nlink !== 1))
|
|
1431
|
+
fail("has a linked or unexpected journal type");
|
|
1432
|
+
};
|
|
1433
|
+
const evidenceDirectory = (target, retained) => scan(target, (name, path) => {
|
|
1434
|
+
if (!(retained ? /^abandon-[a-f0-9]{64}\.json$/u : /^\d{16}\.json$/u).test(name))
|
|
1435
|
+
fail("has an unclassified journal evidence entry");
|
|
1436
|
+
requireType(path, false);
|
|
1437
|
+
});
|
|
1438
|
+
const session = (target) => scan(target, (name, path) => {
|
|
1439
|
+
if (name === "recovery")
|
|
1440
|
+
return;
|
|
1441
|
+
const match = /^(?:journal\.[a-f0-9]{16}|inbox)(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
|
|
1442
|
+
if (!match || match[1] === "default")
|
|
1443
|
+
fail("has an unclassified session journal entry");
|
|
1444
|
+
requireType(path, !!match[2]);
|
|
1445
|
+
if (match[2])
|
|
1446
|
+
evidenceDirectory(path, match[2] === ".retained");
|
|
1447
|
+
const base = path.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match[1] ?? "default";
|
|
1448
|
+
profiles.add(familyProfile);
|
|
1449
|
+
if (familyProfile === profile && base !== pollingPath)
|
|
1450
|
+
families.set(base, "session");
|
|
1451
|
+
});
|
|
1452
|
+
scan(directory, (name, target) => {
|
|
1453
|
+
// Legacy quarantine folders are disposable housekeeping, never journal authority.
|
|
1454
|
+
if (name.toLowerCase() === "recovery")
|
|
1455
|
+
return;
|
|
1456
|
+
if (name.toLowerCase() === "sessions") {
|
|
1457
|
+
if (!includeSessions)
|
|
1458
|
+
fail("requires session-qualified namespace inspection");
|
|
1459
|
+
if (name !== "sessions")
|
|
1460
|
+
fail("has a noncanonical session root");
|
|
1461
|
+
requireType(target, true);
|
|
1462
|
+
scan(target, (sessionName, sessionPath) => {
|
|
1463
|
+
if (decodeTelegramSessionDirectoryName(sessionName) === undefined)
|
|
1464
|
+
fail("has a noncanonical session directory");
|
|
1465
|
+
requireType(sessionPath, true);
|
|
1466
|
+
session(sessionPath);
|
|
1467
|
+
});
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
if (includeSessions && /^journal\./iu.test(name))
|
|
1471
|
+
fail("has a misplaced session journal");
|
|
1472
|
+
if (!/inbox/iu.test(name))
|
|
1473
|
+
return;
|
|
1474
|
+
const match = /^(inbox|follower-inbox-[a-f0-9]{16})(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
|
|
1475
|
+
if (!match || match[2] === "default" || (!includeSessions && match[3] === ".retained"))
|
|
1476
|
+
fail("has a noncanonical journal-like entry");
|
|
1477
|
+
requireType(target, !!match[3]);
|
|
1478
|
+
if (includeSessions && match[3])
|
|
1479
|
+
evidenceDirectory(target, match[3] === ".retained");
|
|
1480
|
+
const base = target.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match[2] ?? "default";
|
|
1481
|
+
profiles.add(familyProfile);
|
|
1482
|
+
if (familyProfile === profile && base !== pollingPath)
|
|
1483
|
+
families.set(base, match[1] === "inbox" ? "session" : "follower");
|
|
1484
|
+
});
|
|
1485
|
+
return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0),
|
|
1486
|
+
families: [...families].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), profiles: [...profiles].sort() };
|
|
1156
1487
|
};
|
|
1157
1488
|
const before = census();
|
|
1489
|
+
const sourceInventory = [[pollingPath, "polling"], ...before.families];
|
|
1158
1490
|
const sources = [];
|
|
1491
|
+
const retainedInputs = [];
|
|
1159
1492
|
const accounting = { directoryEntries: before.entries.length, files: 0, bytes: 0, work: 0 };
|
|
1160
1493
|
let knownBotId = expected.botId;
|
|
1161
|
-
for (const
|
|
1494
|
+
for (const [path, role] of sourceInventory) {
|
|
1162
1495
|
const remaining = { maxFiles: limits.maxFiles - accounting.files, maxBytes: limits.maxBytes - accounting.bytes,
|
|
1163
1496
|
maxWork: limits.maxWork - accounting.work, maxEntries: limits.maxEntries };
|
|
1164
1497
|
if (remaining.maxFiles <= 0 || remaining.maxBytes <= 0 || remaining.maxWork <= 0)
|
|
1165
1498
|
capacity();
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1499
|
+
const acquire = () => acquireTelegramUpdateJournalFamily({ directory, path, profile,
|
|
1500
|
+
botIdentity: { ...expected, ...(knownBotId !== undefined ? { botId: knownBotId } : {}) }, limits: remaining }, undefined, includeSessions);
|
|
1501
|
+
const acquired = input.withSourceReference ? input.withSourceReference(path, acquire) : acquire();
|
|
1502
|
+
const evidence = acquired.evidence;
|
|
1503
|
+
retainedInputs.push(...(acquired.retainedInputs ?? []).map(original => ({ ...original, journalPath: path })));
|
|
1504
|
+
if (evidence.kind === "absent" && (role !== "polling" || before.entries.some(([entry]) => entry === polling || entry === `${polling}.segments`))) {
|
|
1170
1505
|
fail("lost a discovered journal family");
|
|
1171
1506
|
}
|
|
1172
1507
|
if (evidence.kind === "present") {
|
|
@@ -1175,11 +1510,20 @@ export function inspectTelegramProfileJournalNamespace(input) {
|
|
|
1175
1510
|
accounting.bytes += evidence.accounting.bytes;
|
|
1176
1511
|
}
|
|
1177
1512
|
accounting.work += evidence.kind === "present" ? Math.max(1, evidence.accounting.work) : 1;
|
|
1178
|
-
sources.push({ role
|
|
1513
|
+
sources.push({ role, path, evidence });
|
|
1179
1514
|
}
|
|
1180
|
-
|
|
1515
|
+
const namespaceCurrent = () => {
|
|
1516
|
+
try {
|
|
1517
|
+
return isDeepStrictEqual(beforeRoot, metadata(directory)) && isDeepStrictEqual(before, census()) &&
|
|
1518
|
+
isDeepStrictEqual(beforeRoot, metadata(directory));
|
|
1519
|
+
}
|
|
1520
|
+
catch {
|
|
1521
|
+
return false;
|
|
1522
|
+
}
|
|
1523
|
+
};
|
|
1524
|
+
if (!namespaceCurrent())
|
|
1181
1525
|
fail("changed observed root namespace or identity");
|
|
1182
|
-
return { sources, accounting, ...(knownBotId !== undefined ? { knownBotId } : {}) };
|
|
1526
|
+
return { sources, accounting, ...(includeSessions ? { retainedInputs } : {}), ...(knownBotId !== undefined ? { knownBotId } : {}) };
|
|
1183
1527
|
}
|
|
1184
1528
|
catch (error) {
|
|
1185
1529
|
if (error instanceof TelegramUpdateJournalError)
|
|
@@ -1187,6 +1531,28 @@ export function inspectTelegramProfileJournalNamespace(input) {
|
|
|
1187
1531
|
throw createJournalError("io", directory, "could not establish canonical namespace evidence", error);
|
|
1188
1532
|
}
|
|
1189
1533
|
}
|
|
1534
|
+
/** Complete-empty protection only; caller holds source serialization through consumption. Never deletion authority. */
|
|
1535
|
+
export function isTelegramThreadCleanupJournalNamespaceClear(input) {
|
|
1536
|
+
if (!input.requiredJournalBindingKeys.length || input.requiredJournalBindingKeys.length > input.limits.maxDirectoryEntries)
|
|
1537
|
+
throw new Error("Telegram temporary cleanup requires bounded exact journal references.");
|
|
1538
|
+
const requiredPaths = new Set();
|
|
1539
|
+
for (const key of input.requiredJournalBindingKeys) {
|
|
1540
|
+
const path = getTelegramUpdateJournalBindingPath(key);
|
|
1541
|
+
const identities = [input.botIdentity, { tokenSha256: input.botIdentity.tokenSha256 }];
|
|
1542
|
+
if (!path || !identities.some(botIdentity => key === createTelegramUpdateJournalBindingKey({
|
|
1543
|
+
path, profileName: input.profile, botIdentity,
|
|
1544
|
+
})))
|
|
1545
|
+
throw new Error("Telegram temporary cleanup has foreign or malformed journal references.");
|
|
1546
|
+
requiredPaths.add(path);
|
|
1547
|
+
}
|
|
1548
|
+
const namespace = inspectTelegramSessionJournalNamespace(input);
|
|
1549
|
+
if (namespace.sources.filter(source => source.role === "polling").length !== 1 ||
|
|
1550
|
+
namespace.retainedInputs?.some(original => original.state !== "committed") ||
|
|
1551
|
+
[...requiredPaths].some(path => !namespace.sources.some(source => source.path === path && source.evidence.kind === "present")))
|
|
1552
|
+
return false;
|
|
1553
|
+
// Original Telegram targets do not certify execution ownership. No nonempty family gets a source exemption.
|
|
1554
|
+
return namespace.sources.every(source => source.evidence.kind === "absent" || source.evidence.file.entries.length === 0);
|
|
1555
|
+
}
|
|
1190
1556
|
function cloneEntry(entry) {
|
|
1191
1557
|
return {
|
|
1192
1558
|
...entry,
|
|
@@ -1201,6 +1567,7 @@ function cloneEntry(entry) {
|
|
|
1201
1567
|
? { queueHandoff: cloneJournalQueueHandoff(entry.queueHandoff) }
|
|
1202
1568
|
: {}),
|
|
1203
1569
|
...(entry.failure ? { failure: { ...entry.failure } } : {}),
|
|
1570
|
+
...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
|
|
1204
1571
|
};
|
|
1205
1572
|
}
|
|
1206
1573
|
function cloneFile(file) {
|
|
@@ -1213,6 +1580,7 @@ function cloneFile(file) {
|
|
|
1213
1580
|
profile: file.profile,
|
|
1214
1581
|
botIdentity: { ...file.botIdentity },
|
|
1215
1582
|
entries: file.entries.map(cloneEntry),
|
|
1583
|
+
...(file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions.map(completion => ({ ...completion })) } : {}),
|
|
1216
1584
|
...(file.operatorDispositions?.length
|
|
1217
1585
|
? {
|
|
1218
1586
|
operatorDispositions: file.operatorDispositions.map((disposition) => ({ ...disposition })),
|
|
@@ -1312,6 +1680,53 @@ function mergeBotIdentity(stored, current) {
|
|
|
1312
1680
|
tokenSha256: current.tokenSha256,
|
|
1313
1681
|
};
|
|
1314
1682
|
}
|
|
1683
|
+
function createPendingRetentionReference(input) {
|
|
1684
|
+
const { path, journalBindingKey, entry, maxBytes } = input;
|
|
1685
|
+
const evidenceSha256 = createHash("sha256").update(JSON.stringify({ journalBindingKey, entry })).digest("hex");
|
|
1686
|
+
const failureId = `abandon-${evidenceSha256}`;
|
|
1687
|
+
const retainedDirectory = `${path}.retained`;
|
|
1688
|
+
const retainedPath = join(retainedDirectory, `${failureId}.json`);
|
|
1689
|
+
const read = () => {
|
|
1690
|
+
let directory;
|
|
1691
|
+
try {
|
|
1692
|
+
directory = lstatSync(retainedDirectory);
|
|
1693
|
+
}
|
|
1694
|
+
catch (error) {
|
|
1695
|
+
if (error.code === "ENOENT")
|
|
1696
|
+
return undefined;
|
|
1697
|
+
throw error;
|
|
1698
|
+
}
|
|
1699
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) {
|
|
1700
|
+
throw createJournalError("invalid", retainedDirectory, "retention directory is not a regular directory");
|
|
1701
|
+
}
|
|
1702
|
+
let stat;
|
|
1703
|
+
try {
|
|
1704
|
+
stat = lstatSync(retainedPath);
|
|
1705
|
+
}
|
|
1706
|
+
catch (error) {
|
|
1707
|
+
if (error.code === "ENOENT")
|
|
1708
|
+
return undefined;
|
|
1709
|
+
throw error;
|
|
1710
|
+
}
|
|
1711
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
|
|
1712
|
+
throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
|
|
1713
|
+
}
|
|
1714
|
+
const retained = JSON.parse(readFileSync(retainedPath, "utf8"));
|
|
1715
|
+
if (!isRecord(retained))
|
|
1716
|
+
throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
|
|
1717
|
+
const disposition = validateJournalOperatorDisposition(retained.requestedDisposition, retainedPath);
|
|
1718
|
+
if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
|
|
1719
|
+
disposition.failureId !== failureId || disposition.updateId !== entry.updateId ||
|
|
1720
|
+
disposition.action !== "discard" || disposition.evidenceSha256 !== evidenceSha256 ||
|
|
1721
|
+
!isDeepStrictEqual(retained, { version: 1, kind: "pending-input-retention", journalBindingKey,
|
|
1722
|
+
entry, requestedDisposition: disposition })) {
|
|
1723
|
+
throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
|
|
1724
|
+
}
|
|
1725
|
+
return { version: 1, kind: "pending-input-retention", journalBindingKey, entry,
|
|
1726
|
+
requestedDisposition: disposition };
|
|
1727
|
+
};
|
|
1728
|
+
return { evidenceSha256, failureId, retainedPath, read };
|
|
1729
|
+
}
|
|
1315
1730
|
function writeJournalFile(path, serialized, onPublicationBoundary, stagingPath = path) {
|
|
1316
1731
|
const tempPath = `${stagingPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1317
1732
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
@@ -1432,6 +1847,26 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(deps) {
|
|
|
1432
1847
|
});
|
|
1433
1848
|
const path = deps.getJournalPath(configuredProfileName);
|
|
1434
1849
|
const workspaceAdmission = deps.getWorkspaceAdmission?.();
|
|
1850
|
+
const sourceAccess = { directory: dirname(path), limits: {
|
|
1851
|
+
maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
|
|
1852
|
+
maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
|
|
1853
|
+
} };
|
|
1854
|
+
const options = { path, profileName, botIdentity,
|
|
1855
|
+
...(deps.getQueueRuntimeIdentity ? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() } : {}),
|
|
1856
|
+
...(workspaceAdmission ? { workspaceAdmission } : {}),
|
|
1857
|
+
...(deps.withWriterAdmission ? { withWriterAdmission: deps.withWriterAdmission } : {}),
|
|
1858
|
+
...(deps.withSourceSerialization ? { withSourceSerialization: deps.withSourceSerialization } : {}),
|
|
1859
|
+
...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}) };
|
|
1860
|
+
const journal = createTelegramUpdateJournalStore(options);
|
|
1861
|
+
// Scoped completion and receipt observation use strict private handles where the platform provides no-follow
|
|
1862
|
+
// evidence; elsewhere (Windows) the same capabilities read through the ordinary journal so queue receipts still work.
|
|
1863
|
+
const strict = deps.strictSourceAccess ?? Boolean(constants.O_NOFOLLOW && constants.O_NONBLOCK);
|
|
1864
|
+
const completionJournal = deps.withSourceSerialization
|
|
1865
|
+
? createTelegramUpdateJournalStore({ ...options, ...(strict ? { sourceAccess } : {}) }) : undefined;
|
|
1866
|
+
// Queue receipt readiness gates every ordinary prompt; without strict handles it uses the ordinary journal read.
|
|
1867
|
+
const observeQueuedReceipt = (expected) => strict
|
|
1868
|
+
? completionJournal.inspectQueuedReceipt(expected)
|
|
1869
|
+
: inspectJournalQueuedReceipt(journal.read(), { ...expected, queueOwner: validateQueuedReceiptObservation(expected, path) }, path);
|
|
1435
1870
|
return {
|
|
1436
1871
|
runtimeKey: JSON.stringify({
|
|
1437
1872
|
path,
|
|
@@ -1446,27 +1881,25 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(deps) {
|
|
|
1446
1881
|
readForProtection() {
|
|
1447
1882
|
// Protection must never turn corruption/recovery into empty-work evidence.
|
|
1448
1883
|
const evidence = inspectTelegramUpdateJournalFamily({
|
|
1449
|
-
|
|
1450
|
-
limits: {
|
|
1451
|
-
maxFiles: 1024,
|
|
1452
|
-
maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
|
|
1453
|
-
maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES,
|
|
1454
|
-
maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
|
|
1455
|
-
},
|
|
1884
|
+
...sourceAccess, path, profile: profileName, botIdentity,
|
|
1456
1885
|
});
|
|
1457
|
-
return { entries: evidence.kind === "present" ? evidence.file.entries : [] };
|
|
1886
|
+
return { entries: evidence.kind === "present" ? evidence.file.entries : [], exists: evidence.kind === "present" };
|
|
1458
1887
|
},
|
|
1459
|
-
journal:
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1888
|
+
journal: completionJournal ? { ...journal,
|
|
1889
|
+
removeCompletedExact(updateIds, expectedSources, completions) {
|
|
1890
|
+
return completions !== undefined ? completionJournal.removeCompletedExact(updateIds, expectedSources, completions)
|
|
1891
|
+
: journal.removeCompletedExact(updateIds, expectedSources);
|
|
1892
|
+
},
|
|
1893
|
+
completeQueuedExact(receipts, completions) { return completionJournal.completeQueuedExact(receipts, completions); },
|
|
1894
|
+
inspectSourceCompletion(expected) { return completionJournal.inspectSourceCompletion(expected); },
|
|
1895
|
+
inspectQueuedReceipt: observeQueuedReceipt,
|
|
1896
|
+
isQueueReceiptCurrent(receipt, owner) {
|
|
1897
|
+
if (receipt.journalBindingKey !== createTelegramUpdateJournalBindingKey({ path, profileName, botIdentity }))
|
|
1898
|
+
return false;
|
|
1899
|
+
return observeQueuedReceipt({ queueKind: receipt.queueKind, receiptId: receipt.receiptId,
|
|
1900
|
+
sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...owner } }) !== undefined;
|
|
1901
|
+
},
|
|
1902
|
+
} : journal,
|
|
1470
1903
|
};
|
|
1471
1904
|
};
|
|
1472
1905
|
}
|
|
@@ -1511,42 +1944,203 @@ export function withTelegramResolvedUpdateJournalReference(input) {
|
|
|
1511
1944
|
return input.registry.withReference({ referenceClass: input.referenceClass,
|
|
1512
1945
|
recoveryKey: binding.recoveryKey }, () => input.operation(binding));
|
|
1513
1946
|
}
|
|
1947
|
+
function validateQueuedReceiptObservation(expected, path) {
|
|
1948
|
+
if (!isRecord(expected) || !hasOnlyKeys(expected, ["queueKind", "receiptId", "sourceUpdateIds", "queueOwner"]) || (expected.queueKind !== "prompt" && expected.queueKind !== "control") ||
|
|
1949
|
+
!isNonEmptyString(expected.receiptId) ||
|
|
1950
|
+
!Array.isArray(expected.sourceUpdateIds) || expected.sourceUpdateIds.length === 0 ||
|
|
1951
|
+
expected.sourceUpdateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
|
|
1952
|
+
expected.sourceUpdateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= expected.sourceUpdateIds[index - 1]))) {
|
|
1953
|
+
throw createJournalError("invalid", path, "received invalid queue receipt observation authority");
|
|
1954
|
+
}
|
|
1955
|
+
return validateJournalQueueOwner(expected.queueOwner, path);
|
|
1956
|
+
}
|
|
1957
|
+
function inspectJournalQueuedReceipt(file, expected, path) {
|
|
1958
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version))
|
|
1959
|
+
throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
|
|
1960
|
+
const { queueOwner } = expected;
|
|
1961
|
+
const entries = file.entries.filter(entry => entry.queueReceiptId === expected.receiptId);
|
|
1962
|
+
if (entries.length !== expected.sourceUpdateIds.length || entries.some((entry, index) => entry.updateId !== expected.sourceUpdateIds[index] || entry.state !== "queued" || entry.queueKind !== expected.queueKind ||
|
|
1963
|
+
entry.queueHandoff !== undefined || !isDeepStrictEqual(entry.queueOwner, queueOwner)))
|
|
1964
|
+
return undefined;
|
|
1965
|
+
return { receipt: { queueKind: expected.queueKind, receiptId: expected.receiptId,
|
|
1966
|
+
sourceUpdateIds: [...expected.sourceUpdateIds], queueOwner: { ...queueOwner } },
|
|
1967
|
+
sources: entries.map(createTelegramUpdateJournalEntryDigest),
|
|
1968
|
+
queueOwnerSha256: createHash("sha256").update(JSON.stringify(queueOwner)).digest("hex") };
|
|
1969
|
+
}
|
|
1970
|
+
function inspectJournalSourceCompletion(file, completion, path) {
|
|
1971
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version))
|
|
1972
|
+
throw createJournalError("invalid", path, "source completion requires a legacy v1 source");
|
|
1973
|
+
const retained = file.sourceCompletions?.find(value => value.updateId === completion.updateId);
|
|
1974
|
+
if (!retained)
|
|
1975
|
+
return undefined;
|
|
1976
|
+
if (!isDeepStrictEqual(retained, completion))
|
|
1977
|
+
throw createJournalError("conflict", path, "source completion belongs to another exact scope");
|
|
1978
|
+
return { ...retained };
|
|
1979
|
+
}
|
|
1980
|
+
/** Operator-distinct authority: routing never treats it as an owner cancellation. */
|
|
1981
|
+
export const TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX = "session-successor:";
|
|
1982
|
+
function isAdoptablePendingEntry(entry) {
|
|
1983
|
+
return entry.state === "pending" && !entry.inputClaim && !entry.inputProvenance && !entry.routingInput &&
|
|
1984
|
+
!entry.queueOwner && !entry.queueHandoff && !entry.queueReceiptId && !entry.queueKind && !entry.failure &&
|
|
1985
|
+
entry.preApprovalExcluded !== true;
|
|
1986
|
+
}
|
|
1514
1987
|
export function createTelegramUpdateJournalBindingRuntime(deps) {
|
|
1515
1988
|
const resolveLeader = createTelegramUpdateJournalRuntimeBindingResolver({
|
|
1516
1989
|
...deps.base,
|
|
1517
1990
|
getJournalPath: deps.getLeaderJournalPath,
|
|
1518
1991
|
});
|
|
1519
|
-
const createFollowerResolver = (bindingKey, includeQueueRuntimeIdentity) =>
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
:
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1992
|
+
const createFollowerResolver = (bindingKey, includeQueueRuntimeIdentity, sessionId, legacy = false) => {
|
|
1993
|
+
// Session-aware composition must not fall back to a process journal before preparation supplies its ID.
|
|
1994
|
+
if (!legacy && deps.getActiveFollowerSessionId && !sessionId)
|
|
1995
|
+
return () => undefined;
|
|
1996
|
+
return createTelegramUpdateJournalRuntimeBindingResolver({
|
|
1997
|
+
getProfileName: deps.base.getProfileName,
|
|
1998
|
+
getBotToken: deps.base.getBotToken,
|
|
1999
|
+
getBotId: deps.base.getBotId,
|
|
2000
|
+
...(includeQueueRuntimeIdentity && deps.base.getQueueRuntimeIdentity
|
|
2001
|
+
? { getQueueRuntimeIdentity: deps.base.getQueueRuntimeIdentity }
|
|
2002
|
+
: {}),
|
|
2003
|
+
...(deps.base.withWriterAdmission
|
|
2004
|
+
? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
|
|
2005
|
+
// Follower journals need the same strict exact-receipt ports as the leader; without them queue publication cannot complete.
|
|
2006
|
+
...(deps.base.withSourceSerialization
|
|
2007
|
+
? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
|
|
2008
|
+
...(deps.base.getWorkspaceAdmission
|
|
2009
|
+
? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
|
|
2010
|
+
: {}),
|
|
2011
|
+
...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
|
|
2012
|
+
getJournalPath(profileName) {
|
|
2013
|
+
return deps.getFollowerJournalPath(bindingKey, profileName, sessionId);
|
|
2014
|
+
},
|
|
2015
|
+
});
|
|
2016
|
+
};
|
|
2017
|
+
const resolveFollower = () => createFollowerResolver(deps.getActiveFollowerBindingKey(), true, deps.getActiveFollowerSessionId?.())();
|
|
1537
2018
|
const resolveActive = () => deps.isFollowerRegistered() ? resolveFollower() : resolveLeader();
|
|
1538
|
-
|
|
2019
|
+
const inspectHistoricalSource = (journalBindingKey, operation) => {
|
|
2020
|
+
const path = getTelegramUpdateJournalBindingPath(journalBindingKey);
|
|
2021
|
+
const token = deps.base.getBotToken(), botId = deps.base.getBotId();
|
|
2022
|
+
const profile = deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME;
|
|
2023
|
+
if (!path || !token)
|
|
2024
|
+
return undefined;
|
|
2025
|
+
const botIdentity = createTelegramUpdateJournalBotIdentity({ botToken: token, botId });
|
|
2026
|
+
const keys = [botIdentity, { tokenSha256: botIdentity.tokenSha256 }].map(identity => createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: identity }));
|
|
2027
|
+
if (!keys.includes(journalBindingKey))
|
|
2028
|
+
return undefined;
|
|
2029
|
+
const getRoot = () => deps.getRuntimeDir?.() ?? dirname(deps.getLeaderJournalPath(profile));
|
|
2030
|
+
const root = getRoot();
|
|
2031
|
+
const parts = relative(root, path).split(sep);
|
|
2032
|
+
const suffix = escapeTelegramJournalPathPattern(getTelegramProfilePathSuffix(profile));
|
|
2033
|
+
const flat = new RegExp(`^(?:inbox|follower-inbox-[a-f0-9]{16})${suffix}\\.json$`, "u");
|
|
2034
|
+
const session = new RegExp(`^(?:journal\\.[a-f0-9]{16}|inbox)${suffix}\\.json$`, "u");
|
|
2035
|
+
if (!isAbsolute(root) || resolve(root) !== root || !isAbsolute(path) || resolve(path) !== path ||
|
|
2036
|
+
!(parts.length === 1 && flat.test(parts[0]) || parts.length === 3 && parts[0] === "sessions" &&
|
|
2037
|
+
decodeTelegramSessionDirectoryName(parts[1]) !== undefined && session.test(parts[2])))
|
|
2038
|
+
return undefined;
|
|
2039
|
+
const current = () => deps.base.getBotToken() === token && deps.base.getBotId() === botId &&
|
|
2040
|
+
(deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME) === profile && getRoot() === root;
|
|
2041
|
+
const inspect = () => {
|
|
2042
|
+
if (!current())
|
|
2043
|
+
return undefined;
|
|
2044
|
+
const observed = inspectTelegramUpdateJournalRetention({ directory: root, path, profile, botIdentity,
|
|
2045
|
+
limits: { maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
|
|
2046
|
+
maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024 } });
|
|
2047
|
+
if (observed.evidence.kind !== "present" || !current())
|
|
2048
|
+
return undefined;
|
|
2049
|
+
return operation(observed.evidence.file, observed.retainedInputs, path);
|
|
2050
|
+
};
|
|
2051
|
+
const result = deps.base.withSourceSerialization ? deps.base.withSourceSerialization(inspect) : inspect();
|
|
2052
|
+
return current() ? result : undefined;
|
|
2053
|
+
};
|
|
2054
|
+
let preparedFollower;
|
|
2055
|
+
const runtime = {
|
|
1539
2056
|
resolveLeader,
|
|
1540
2057
|
resolveFollower,
|
|
1541
2058
|
resolveActive,
|
|
1542
2059
|
getActiveRecoveryKey: () => resolveActive()?.recoveryKey,
|
|
1543
|
-
|
|
2060
|
+
prepareActiveFollowerSuccession(isCurrent) {
|
|
2061
|
+
const recipientBindingKey = deps.getActiveFollowerBindingKey();
|
|
2062
|
+
const sessionId = deps.getActiveFollowerSessionId?.();
|
|
2063
|
+
if (!sessionId)
|
|
2064
|
+
return undefined;
|
|
2065
|
+
const previous = preparedFollower;
|
|
2066
|
+
const result = previous && previous.recipientBindingKey === recipientBindingKey && previous.sessionId !== sessionId
|
|
2067
|
+
? runtime.adoptPredecessorPending({ recipientBindingKey, predecessorSessionId: previous.sessionId,
|
|
2068
|
+
successorSessionId: sessionId, isCurrent })
|
|
2069
|
+
: undefined;
|
|
2070
|
+
// Only a completed adoption advances the predecessor; failures retry idempotently on the next preparation.
|
|
2071
|
+
preparedFollower = { recipientBindingKey, sessionId };
|
|
2072
|
+
return result;
|
|
2073
|
+
},
|
|
2074
|
+
inspectSourceAbandonment(journalBindingKey, updateId) {
|
|
2075
|
+
if (!isSafeNonNegativeInteger(updateId))
|
|
2076
|
+
throw new Error("Telegram abandonment proof requires an exact update ID.");
|
|
2077
|
+
return inspectHistoricalSource(journalBindingKey, (file, originals) => {
|
|
2078
|
+
const original = originals.find(value => value.updateId === updateId && value.state === "committed" &&
|
|
2079
|
+
value.journalBindingKey === journalBindingKey);
|
|
2080
|
+
if (!original)
|
|
2081
|
+
return undefined;
|
|
2082
|
+
const disposition = file.operatorDispositions?.find(value => value.failureId === original.failureId);
|
|
2083
|
+
if (!disposition || !("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody")
|
|
2084
|
+
return undefined;
|
|
2085
|
+
return { journalBindingKey, updateId, retainedPath: original.path, operatorAuthorityId: disposition.operatorAuthorityId };
|
|
2086
|
+
});
|
|
2087
|
+
},
|
|
2088
|
+
inspectSourceGroupExpiry(journalBindingKey, updateIds) {
|
|
2089
|
+
return inspectHistoricalSource(journalBindingKey, file => inspectRoutingGroupExpiry(file, journalBindingKey, updateIds));
|
|
2090
|
+
},
|
|
2091
|
+
inspectSourceCompletion(journalBindingKey, expected) {
|
|
2092
|
+
const completion = validateJournalSourceCompletion(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
|
|
2093
|
+
return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalSourceCompletion(file, completion, path));
|
|
2094
|
+
},
|
|
2095
|
+
inspectQueuedReceipt(journalBindingKey, expected) {
|
|
2096
|
+
const queueOwner = validateQueuedReceiptObservation(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
|
|
2097
|
+
return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalQueuedReceipt(file, { ...expected, queueOwner }, path));
|
|
2098
|
+
},
|
|
2099
|
+
adoptPredecessorPending(input) {
|
|
2100
|
+
const { recipientBindingKey, predecessorSessionId, successorSessionId, isCurrent } = input;
|
|
2101
|
+
if (!recipientBindingKey || !predecessorSessionId || !successorSessionId ||
|
|
2102
|
+
predecessorSessionId === successorSessionId || typeof isCurrent !== "function") {
|
|
2103
|
+
throw new Error("Telegram session adoption requires distinct exact session identities.");
|
|
2104
|
+
}
|
|
2105
|
+
const assertCurrent = () => {
|
|
2106
|
+
if (!isCurrent())
|
|
2107
|
+
throw new Error("Telegram session adoption lost successor authority.");
|
|
2108
|
+
};
|
|
2109
|
+
assertCurrent();
|
|
2110
|
+
const predecessor = createFollowerResolver(recipientBindingKey, false, predecessorSessionId)();
|
|
2111
|
+
const successor = createFollowerResolver(recipientBindingKey, false, successorSessionId)();
|
|
2112
|
+
if (!predecessor?.readForProtection || !successor || predecessor.recoveryKey === successor.recoveryKey) {
|
|
2113
|
+
throw new Error("Telegram session adoption requires exact predecessor and successor journals.");
|
|
2114
|
+
}
|
|
2115
|
+
const result = { adoptedUpdateIds: [], retainedUpdateIds: [] };
|
|
2116
|
+
// Non-repairing selection; the abandonment CAS rechecks each exact entry under the source lock.
|
|
2117
|
+
const candidates = predecessor.readForProtection().entries.filter(isAdoptablePendingEntry)
|
|
2118
|
+
.sort((left, right) => left.updateId - right.updateId);
|
|
2119
|
+
for (const entry of candidates) {
|
|
2120
|
+
assertCurrent();
|
|
2121
|
+
// Commit away first: a crash afterwards preserves the private original instead of duplicating execution.
|
|
2122
|
+
const committed = predecessor.journal.abandonPending({ journalBindingKey: predecessor.recoveryKey, entry,
|
|
2123
|
+
operatorAuthorityId: `${TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX}${successorSessionId}`, isCurrent });
|
|
2124
|
+
if (committed.duplicate) {
|
|
2125
|
+
result.retainedUpdateIds.push(entry.updateId);
|
|
2126
|
+
continue;
|
|
2127
|
+
}
|
|
2128
|
+
assertCurrent();
|
|
2129
|
+
successor.journal.appendBatch([entry.update]);
|
|
2130
|
+
result.adoptedUpdateIds.push(entry.updateId);
|
|
2131
|
+
}
|
|
2132
|
+
return result;
|
|
2133
|
+
},
|
|
2134
|
+
createRecipientResolver: (bindingKey, sessionId) => createFollowerResolver(bindingKey, false, sessionId),
|
|
2135
|
+
createLegacyRecipientResolver: (bindingKey) => createFollowerResolver(bindingKey, false, undefined, true),
|
|
1544
2136
|
createPathResolver: (path) => createTelegramUpdateJournalRuntimeBindingResolver({
|
|
1545
2137
|
getProfileName: deps.base.getProfileName,
|
|
1546
2138
|
getBotToken: deps.base.getBotToken,
|
|
1547
2139
|
getBotId: deps.base.getBotId,
|
|
1548
2140
|
...(deps.base.withWriterAdmission
|
|
1549
2141
|
? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
|
|
2142
|
+
...(deps.base.withSourceSerialization
|
|
2143
|
+
? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
|
|
1550
2144
|
...(deps.base.getWorkspaceAdmission
|
|
1551
2145
|
? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
|
|
1552
2146
|
: {}),
|
|
@@ -1554,6 +2148,7 @@ export function createTelegramUpdateJournalBindingRuntime(deps) {
|
|
|
1554
2148
|
getJournalPath: () => path,
|
|
1555
2149
|
}),
|
|
1556
2150
|
};
|
|
2151
|
+
return runtime;
|
|
1557
2152
|
}
|
|
1558
2153
|
export function createTelegramUpdateJournalStore(options) {
|
|
1559
2154
|
return createJournalStoreCore(options).journal;
|
|
@@ -1673,6 +2268,9 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
1673
2268
|
if ((file.operatorDispositions?.length ?? 0) > maxEntries) {
|
|
1674
2269
|
throw createJournalError("capacity", path, `exceeds its ${maxEntries}-operator-disposition limit`);
|
|
1675
2270
|
}
|
|
2271
|
+
if ((file.sourceCompletions?.length ?? 0) > maxEntries) {
|
|
2272
|
+
throw createJournalError("capacity", path, `exceeds its ${maxEntries}-source-completion limit`);
|
|
2273
|
+
}
|
|
1676
2274
|
const serializedBytes = Buffer.byteLength(serialized);
|
|
1677
2275
|
if (serializedBytes > maxBytes) {
|
|
1678
2276
|
throw createJournalError("capacity", path, `exceeds its ${maxBytes}-byte limit`);
|
|
@@ -1685,7 +2283,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
1685
2283
|
botIdentity: { ...expectedIdentity },
|
|
1686
2284
|
entries: [],
|
|
1687
2285
|
});
|
|
1688
|
-
const readCurrentStrict = () => {
|
|
2286
|
+
const readCurrentStrict = (allowReconciliation = true) => {
|
|
1689
2287
|
let source;
|
|
1690
2288
|
let recoveringMissingSnapshot = false;
|
|
1691
2289
|
try {
|
|
@@ -1711,6 +2309,8 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
1711
2309
|
throw createJournalError("io", segmentDirectory, "could not be read while the journal snapshot is missing", segmentError);
|
|
1712
2310
|
}
|
|
1713
2311
|
if (orphanedSegmentNames.length > 0) {
|
|
2312
|
+
if (!allowReconciliation)
|
|
2313
|
+
throw createJournalError("invalid", path, "requires explicit missing-snapshot reconciliation");
|
|
1714
2314
|
if (withPairingAdmission)
|
|
1715
2315
|
throw createJournalError("pairing-evidence", path, "requires explicit missing-snapshot reconciliation");
|
|
1716
2316
|
recoveringMissingSnapshot = true;
|
|
@@ -1788,6 +2388,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
1788
2388
|
(withPairingAdmission && segment.botIdentity.tokenSha256 !== storedIdentity.tokenSha256)) {
|
|
1789
2389
|
throw createJournalError("identity-mismatch", segmentPath, "belongs to another journal identity");
|
|
1790
2390
|
}
|
|
2391
|
+
assertJournalSourceCompletionContinuity(file, segment, segmentPath);
|
|
1791
2392
|
const entriesById = new Map(file.entries.map((entry) => [entry.updateId, entry]));
|
|
1792
2393
|
for (const updateId of segment.removedUpdateIds) {
|
|
1793
2394
|
if (recoveringMissingSnapshot && !entriesById.has(updateId)) {
|
|
@@ -1824,14 +2425,16 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
1824
2425
|
: file.operatorDispositions?.length
|
|
1825
2426
|
? { operatorDispositions: file.operatorDispositions }
|
|
1826
2427
|
: {}),
|
|
2428
|
+
...(segment.sourceCompletions !== undefined ? { sourceCompletions: segment.sourceCompletions }
|
|
2429
|
+
: file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions } : {}),
|
|
1827
2430
|
}, segmentPath, version);
|
|
1828
2431
|
revision = segment.revision;
|
|
1829
2432
|
}
|
|
1830
2433
|
const identityChanged = storedProfile !== profile ||
|
|
1831
2434
|
!identitiesMatch(file.botIdentity, expectedIdentity) ||
|
|
1832
|
-
(withPairingAdmission && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
|
|
2435
|
+
((withPairingAdmission || !!file.sourceCompletions?.length) && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
|
|
1833
2436
|
if (identityChanged) {
|
|
1834
|
-
if (file.entries.length > 0 || withPairingAdmission) {
|
|
2437
|
+
if (file.entries.length > 0 || file.sourceCompletions?.length || withPairingAdmission || !allowReconciliation) {
|
|
1835
2438
|
throw createJournalError("identity-mismatch", path, storedProfile !== profile
|
|
1836
2439
|
? `belongs to profile ${storedProfile}, not ${profile}`
|
|
1837
2440
|
: "belongs to another Telegram bot identity");
|
|
@@ -1970,39 +2573,40 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
1970
2573
|
// Only known schema/JSON corruption may fall through to reset.
|
|
1971
2574
|
}
|
|
1972
2575
|
}
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
const
|
|
1976
|
-
const
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
throw createJournalError("io", path, "could not quarantine an unrecoverable journal snapshot", error);
|
|
1980
|
-
}
|
|
1981
|
-
if (!renameTelegramPathWithRetry(segmentDirectory, segmentQuarantinePath)) {
|
|
1982
|
-
if (snapshotExists) {
|
|
1983
|
-
renameTelegramPathWithRetry(snapshotQuarantinePath, path);
|
|
2576
|
+
// Approved corruption policy: delete damaged evidence instead of quarantining it under recovery/.
|
|
2577
|
+
// Segments go first so a fresh revisionless snapshot can never replay them; a crash repeats the reset.
|
|
2578
|
+
const deletedPaths = [];
|
|
2579
|
+
const deleteDamaged = (target, label) => {
|
|
2580
|
+
try {
|
|
2581
|
+
lstatSync(target);
|
|
1984
2582
|
}
|
|
1985
|
-
|
|
1986
|
-
|
|
2583
|
+
catch (statError) {
|
|
2584
|
+
if (statError.code === "ENOENT")
|
|
2585
|
+
return;
|
|
2586
|
+
throw createJournalError("io", target, `could not inspect an unrecoverable ${label}`, statError);
|
|
2587
|
+
}
|
|
2588
|
+
try {
|
|
2589
|
+
rmSync(target, { recursive: true, force: true });
|
|
2590
|
+
}
|
|
2591
|
+
catch (removeError) {
|
|
2592
|
+
throw createJournalError("io", target, `could not delete an unrecoverable ${label}`, removeError);
|
|
2593
|
+
}
|
|
2594
|
+
deletedPaths.push(target);
|
|
2595
|
+
};
|
|
2596
|
+
deleteDamaged(segmentDirectory, "journal segment history");
|
|
2597
|
+
// A regular snapshot is replaced atomically below; anything else cannot be a rename target.
|
|
2598
|
+
if (snapshotExists && !lstatSync(path).isFile())
|
|
2599
|
+
deleteDamaged(path, "journal snapshot");
|
|
1987
2600
|
const reset = emptyFile();
|
|
1988
2601
|
const serialized = serializeJournalFile(reset);
|
|
1989
2602
|
const serializedBytes = assertCapacity(reset, serialized);
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
catch (publicationError) {
|
|
1994
|
-
const segmentsRestored = renameTelegramPathWithRetry(segmentQuarantinePath, segmentDirectory);
|
|
1995
|
-
const snapshotRestored = !snapshotExists ||
|
|
1996
|
-
renameTelegramPathWithRetry(snapshotQuarantinePath, path);
|
|
1997
|
-
if (!segmentsRestored || !snapshotRestored) {
|
|
1998
|
-
throw createJournalError("io", path, "reset publication failed after journal evidence was quarantined", publicationError);
|
|
1999
|
-
}
|
|
2000
|
-
throw publicationError;
|
|
2001
|
-
}
|
|
2603
|
+
writeJournalFile(path, serialized, onPublicationBoundary);
|
|
2604
|
+
if (snapshotExists && !deletedPaths.includes(path))
|
|
2605
|
+
deletedPaths.push(path);
|
|
2002
2606
|
notifyRecovery({
|
|
2003
2607
|
kind: "reset",
|
|
2004
2608
|
path,
|
|
2005
|
-
|
|
2609
|
+
deletedPaths,
|
|
2006
2610
|
reason: error.message,
|
|
2007
2611
|
});
|
|
2008
2612
|
return { file: reset, exists: true, serializedBytes };
|
|
@@ -2048,7 +2652,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2048
2652
|
const inputHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH);
|
|
2049
2653
|
const inputBindingHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH);
|
|
2050
2654
|
const inputHandoffHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH);
|
|
2051
|
-
const getFileWork = (file) => file.entries.length + (file.operatorDispositions?.length ?? 0);
|
|
2655
|
+
const getFileWork = (file) => file.entries.length + (file.operatorDispositions?.length ?? 0) + (file.sourceCompletions?.length ?? 0);
|
|
2052
2656
|
const advanceInputHeadroomState = (state, entries, projectionSlack) => {
|
|
2053
2657
|
const revision = (state.file.revision ?? 0) + 1;
|
|
2054
2658
|
if (!isSafePositiveInteger(revision))
|
|
@@ -2190,7 +2794,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2190
2794
|
const createInputHeadroomSegment = (previous, next) => {
|
|
2191
2795
|
const previousEntries = new Map(previous.entries.map(entry => [entry.updateId, entry]));
|
|
2192
2796
|
const nextEntries = new Map(next.entries.map(entry => [entry.updateId, entry]));
|
|
2193
|
-
return { version
|
|
2797
|
+
return { version,
|
|
2194
2798
|
revision: next.revision, previousRevision: next.revision - 1,
|
|
2195
2799
|
profile: next.profile, botIdentity: next.botIdentity,
|
|
2196
2800
|
upsertedEntries: next.entries.filter(entry => !previousEntries.has(entry.updateId) || !isDeepStrictEqual(previousEntries.get(entry.updateId), entry)),
|
|
@@ -2218,7 +2822,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2218
2822
|
: { files: 1, bytes: publishedBytes, work: getFileWork(publishedFile) };
|
|
2219
2823
|
if (publishedSegment) {
|
|
2220
2824
|
const segmentWork = publishedSegment.upsertedEntries.length + publishedSegment.removedUpdateIds.length +
|
|
2221
|
-
(publishedSegment.operatorDispositions?.length ?? 0);
|
|
2825
|
+
(publishedSegment.operatorDispositions?.length ?? 0) + (publishedSegment.sourceCompletions?.length ?? 0);
|
|
2222
2826
|
const segmentBytes = Buffer.byteLength(`${JSON.stringify(publishedSegment, null, 2)}\n`);
|
|
2223
2827
|
baseRetainedCount += 1;
|
|
2224
2828
|
baseRetainedBytes += segmentBytes;
|
|
@@ -2271,9 +2875,10 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2271
2875
|
const segmentText = `${JSON.stringify(segment, null, 2)}\n`;
|
|
2272
2876
|
const segmentBytes = Buffer.byteLength(segmentText);
|
|
2273
2877
|
const collections = [segment.upsertedEntries.length, segment.removedUpdateIds.length,
|
|
2274
|
-
segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0
|
|
2275
|
-
|
|
2276
|
-
const
|
|
2878
|
+
segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0,
|
|
2879
|
+
segment.sourceCompletions?.length ?? 0, file.sourceCompletions?.length ?? 0];
|
|
2880
|
+
const segmentWork = collections[0] + collections[1] + collections[2] + collections[5];
|
|
2881
|
+
const fileWork = collections[3] + collections[4] + collections[6];
|
|
2277
2882
|
const accounting = source.evidence.accounting;
|
|
2278
2883
|
// Both the segment-first state and snapshot-first cleanup residue must fit.
|
|
2279
2884
|
assertSourceResources(accounting.files + 1, accounting.bytes + segmentBytes, accounting.work + segmentWork + fileWork, collections);
|
|
@@ -2311,12 +2916,13 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2311
2916
|
}
|
|
2312
2917
|
return { file: revisedFile, serializedBytes };
|
|
2313
2918
|
};
|
|
2314
|
-
const publishMutation = (current, entries, contentChanged, operatorDispositions = current.file.operatorDispositions, acceptedThroughUpdateId = current.file.acceptedThroughUpdateId, publicationBoundary = onPublicationBoundary, inputHeadroom = version
|
|
2919
|
+
const publishMutation = (current, entries, contentChanged, operatorDispositions = current.file.operatorDispositions, acceptedThroughUpdateId = current.file.acceptedThroughUpdateId, publicationBoundary = onPublicationBoundary, inputHeadroom = journalHasCustodyFields(version) ? "all" : false, sourceCompletions = current.file.sourceCompletions) => {
|
|
2315
2920
|
const botIdentity = current.source ? current.file.botIdentity : mergeBotIdentity(current.file.botIdentity, expectedIdentity);
|
|
2316
2921
|
if (!contentChanged &&
|
|
2317
2922
|
isDeepStrictEqual(current.file.botIdentity, botIdentity) &&
|
|
2318
2923
|
isDeepStrictEqual(current.file.operatorDispositions ?? [], operatorDispositions ?? []) &&
|
|
2319
|
-
current.file.acceptedThroughUpdateId === acceptedThroughUpdateId
|
|
2924
|
+
current.file.acceptedThroughUpdateId === acceptedThroughUpdateId &&
|
|
2925
|
+
isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? [])) {
|
|
2320
2926
|
return { file: current.file, serializedBytes: current.serializedBytes };
|
|
2321
2927
|
}
|
|
2322
2928
|
const file = {
|
|
@@ -2330,21 +2936,24 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2330
2936
|
...(operatorDispositions?.length
|
|
2331
2937
|
? { operatorDispositions }
|
|
2332
2938
|
: {}),
|
|
2939
|
+
...(sourceCompletions?.length ? { sourceCompletions } : {}),
|
|
2333
2940
|
};
|
|
2941
|
+
assertJournalRoutingInputContinuity(current.file, entries, path);
|
|
2334
2942
|
if (withPairingAdmission || current.source)
|
|
2335
2943
|
parseJournalFile(file, path, version);
|
|
2336
2944
|
const serialized = serializeJournalFile(file);
|
|
2337
2945
|
const serializedBytes = assertCapacity(file, serialized);
|
|
2338
2946
|
const changed = contentChanged ||
|
|
2339
2947
|
current.file.acceptedThroughUpdateId !== acceptedThroughUpdateId ||
|
|
2340
|
-
!isDeepStrictEqual(current.file.botIdentity, file.botIdentity)
|
|
2948
|
+
!isDeepStrictEqual(current.file.botIdentity, file.botIdentity) ||
|
|
2949
|
+
!isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []);
|
|
2341
2950
|
if (!changed) {
|
|
2342
2951
|
return { file, serializedBytes: current.serializedBytes };
|
|
2343
2952
|
}
|
|
2344
2953
|
if (!current.exists) {
|
|
2345
2954
|
if (current.source) {
|
|
2346
|
-
const collections = [file.entries.length, file.operatorDispositions?.length ?? 0];
|
|
2347
|
-
assertSourceResources(1, serializedBytes, collections[0] + collections[1], collections);
|
|
2955
|
+
const collections = [file.entries.length, file.operatorDispositions?.length ?? 0, file.sourceCompletions?.length ?? 0];
|
|
2956
|
+
assertSourceResources(1, serializedBytes, collections[0] + collections[1] + collections[2], collections);
|
|
2348
2957
|
}
|
|
2349
2958
|
if (inputHeadroom !== false)
|
|
2350
2959
|
assertInputProgressHeadroom(current, file, inputHeadroom);
|
|
@@ -2379,6 +2988,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2379
2988
|
...(!isDeepStrictEqual(current.file.operatorDispositions ?? [], operatorDispositions ?? [])
|
|
2380
2989
|
? { operatorDispositions: operatorDispositions ?? [] }
|
|
2381
2990
|
: {}),
|
|
2991
|
+
...(!isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []) ? { sourceCompletions: sourceCompletions ?? [] } : {}),
|
|
2382
2992
|
};
|
|
2383
2993
|
if (current.source) {
|
|
2384
2994
|
if (!isSafePositiveInteger(revision))
|
|
@@ -2386,7 +2996,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2386
2996
|
const revisedFile = { ...file, revision };
|
|
2387
2997
|
if (inputHeadroom !== false)
|
|
2388
2998
|
assertInputProgressHeadroom(current, revisedFile, inputHeadroom, segment);
|
|
2389
|
-
return publishSourceSegment(current, file, segment, publicationBoundary, version
|
|
2999
|
+
return publishSourceSegment(current, file, segment, publicationBoundary, journalHasCustodyFields(version));
|
|
2390
3000
|
}
|
|
2391
3001
|
publishTelegramUpdateJournalSegmentUnlocked(path, segment, publicationBoundary);
|
|
2392
3002
|
const revisedFile = { ...file, revision };
|
|
@@ -2429,6 +3039,175 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2429
3039
|
}
|
|
2430
3040
|
return { file: revisedFile, serializedBytes };
|
|
2431
3041
|
};
|
|
3042
|
+
const removeCompleted = (updateIds, expectedSources, exact = false, completionInput) => runMutation((readCurrent) => {
|
|
3043
|
+
const current = exact && !sourceAccess ? readCurrentStrict(false) : readCurrent();
|
|
3044
|
+
if (completionInput !== undefined && !sourceAccess)
|
|
3045
|
+
throw createJournalError("invalid", path, "source completion requires an exact source handle");
|
|
3046
|
+
const completions = completionInput === undefined ? undefined : validateJournalSourceCompletions(completionInput, path, version);
|
|
3047
|
+
if (exact && expectedSources === undefined)
|
|
3048
|
+
throw createJournalError("invalid", path, "received no exact completion sources");
|
|
3049
|
+
const requestedIds = new Set();
|
|
3050
|
+
for (const updateId of updateIds) {
|
|
3051
|
+
if (!isSafeNonNegativeInteger(updateId))
|
|
3052
|
+
throw createJournalError("invalid", path, "received an invalid removal update id");
|
|
3053
|
+
requestedIds.add(updateId);
|
|
3054
|
+
}
|
|
3055
|
+
if (expectedSources !== undefined) {
|
|
3056
|
+
if (!Array.isArray(expectedSources) || !expectedSources.length)
|
|
3057
|
+
throw createJournalError("invalid", path, "received no exact completion sources");
|
|
3058
|
+
const expectedIds = new Set();
|
|
3059
|
+
for (const expected of expectedSources) {
|
|
3060
|
+
if (!isRecord(expected) || Object.keys(expected).some(key => !["updateId", "sourceSha256"].includes(key)) ||
|
|
3061
|
+
!isSafeNonNegativeInteger(expected.updateId) || !requestedIds.has(expected.updateId) || expectedIds.has(expected.updateId) ||
|
|
3062
|
+
typeof expected.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(expected.sourceSha256)) {
|
|
3063
|
+
throw createJournalError("invalid", path, "received an invalid exact completion source");
|
|
3064
|
+
}
|
|
3065
|
+
expectedIds.add(expected.updateId);
|
|
3066
|
+
const entry = current.file.entries.find(value => value.updateId === expected.updateId);
|
|
3067
|
+
if (!entry || createTelegramUpdateJournalEntryDigest(entry).sourceSha256 !== expected.sourceSha256) {
|
|
3068
|
+
throw createJournalError("conflict", path, "exact completion source changed or disappeared");
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
if (completions) {
|
|
3073
|
+
const guards = new Map((expectedSources ?? []).map(source => [source.updateId, source.sourceSha256]));
|
|
3074
|
+
if (completions.some(completion => guards.get(completion.updateId) !== completion.sourceSha256)) {
|
|
3075
|
+
throw createJournalError("invalid", path, "source completion has no matching exact guard");
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
const requestedEntries = current.file.entries.filter(entry => requestedIds.has(entry.updateId));
|
|
3079
|
+
const protectedEntry = requestedEntries.find(entry => entry.state === "failed" || entry.state === "queued");
|
|
3080
|
+
if (protectedEntry)
|
|
3081
|
+
throw createJournalError("conflict", path, protectedEntry.state === "failed"
|
|
3082
|
+
? `cannot complete terminal update ${protectedEntry.updateId} without an operator disposition`
|
|
3083
|
+
: `cannot complete queued update ${protectedEntry.updateId} without its exact owner receipt`);
|
|
3084
|
+
const removedUpdateIds = requestedEntries.map(entry => entry.updateId);
|
|
3085
|
+
const sourceCompletions = completions
|
|
3086
|
+
? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId) : current.file.sourceCompletions;
|
|
3087
|
+
if (sourceCompletions?.length)
|
|
3088
|
+
validateJournalSourceCompletions(sourceCompletions, path, version);
|
|
3089
|
+
const published = publishMutation(current, current.file.entries.filter(entry => !requestedIds.has(entry.updateId)), removedUpdateIds.length > 0, current.file.operatorDispositions, current.file.acceptedThroughUpdateId, onPublicationBoundary, false, sourceCompletions);
|
|
3090
|
+
return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
|
|
3091
|
+
...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
|
|
3092
|
+
});
|
|
3093
|
+
const completeQueued = (receipts, completionInput, exact = false) => runMutation((readCurrent) => {
|
|
3094
|
+
if (exact && !sourceAccess)
|
|
3095
|
+
throw createJournalError("invalid", path, "source completion requires an exact source handle");
|
|
3096
|
+
const completions = exact ? validateJournalSourceCompletions(completionInput, path, version) : undefined;
|
|
3097
|
+
if (!Array.isArray(receipts) || receipts.length === 0)
|
|
3098
|
+
throw createJournalError("invalid", path, "received no queued receipts to complete");
|
|
3099
|
+
const current = readCurrent();
|
|
3100
|
+
const receiptIds = new Set(), requestedUpdateIds = new Set();
|
|
3101
|
+
for (const receipt of receipts) {
|
|
3102
|
+
if ((receipt.queueKind !== "prompt" && receipt.queueKind !== "control") || !isNonEmptyString(receipt.receiptId) ||
|
|
3103
|
+
receiptIds.has(receipt.receiptId) || !Array.isArray(receipt.sourceUpdateIds) || receipt.sourceUpdateIds.length === 0) {
|
|
3104
|
+
throw createJournalError("invalid", path, "received an invalid queued completion receipt");
|
|
3105
|
+
}
|
|
3106
|
+
receiptIds.add(receipt.receiptId);
|
|
3107
|
+
const queueOwner = validateJournalQueueOwner(receipt.queueOwner, path);
|
|
3108
|
+
if (queueRuntimeIdentity && (queueOwner.instanceId !== queueRuntimeIdentity.instanceId || queueOwner.processId !== queueRuntimeIdentity.processId ||
|
|
3109
|
+
queueOwner.processBirthId !== queueRuntimeIdentity.processBirthId)) {
|
|
3110
|
+
throw createJournalError("conflict", path, `cannot complete foreign queue receipt ${receipt.receiptId}`);
|
|
3111
|
+
}
|
|
3112
|
+
const sourceUpdateIds = new Set();
|
|
3113
|
+
for (const updateId of receipt.sourceUpdateIds) {
|
|
3114
|
+
if (!isSafeNonNegativeInteger(updateId) || sourceUpdateIds.has(updateId) || requestedUpdateIds.has(updateId)) {
|
|
3115
|
+
throw createJournalError("invalid", path, "received overlapping queued completion update ids");
|
|
3116
|
+
}
|
|
3117
|
+
sourceUpdateIds.add(updateId);
|
|
3118
|
+
requestedUpdateIds.add(updateId);
|
|
3119
|
+
}
|
|
3120
|
+
const persistedReceiptEntries = current.file.entries.filter(entry => entry.queueReceiptId === receipt.receiptId);
|
|
3121
|
+
if (persistedReceiptEntries.length !== sourceUpdateIds.size || persistedReceiptEntries.some(entry => !sourceUpdateIds.has(entry.updateId) || entry.state !== "queued" || entry.queueKind !== receipt.queueKind || !entry.queueOwner ||
|
|
3122
|
+
entry.queueHandoff !== undefined || !areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, queueOwner))) {
|
|
3123
|
+
throw createJournalError("conflict", path, `cannot complete stale or foreign queue receipt ${receipt.receiptId}`);
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
const removedEntries = current.file.entries.filter(entry => requestedUpdateIds.has(entry.updateId));
|
|
3127
|
+
const removedUpdateIds = removedEntries.map(entry => entry.updateId);
|
|
3128
|
+
if (removedUpdateIds.length !== requestedUpdateIds.size)
|
|
3129
|
+
throw createJournalError("conflict", path, "queued completion did not resolve every source update");
|
|
3130
|
+
if (completions) {
|
|
3131
|
+
const sources = new Map(removedEntries.map(entry => [entry.updateId, createTelegramUpdateJournalEntryDigest(entry).sourceSha256]));
|
|
3132
|
+
if (completions.some(completion => sources.get(completion.updateId) !== completion.sourceSha256)) {
|
|
3133
|
+
throw createJournalError("conflict", path, "queued completion source changed or belongs to another receipt");
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
const sourceCompletions = completions ? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId)
|
|
3137
|
+
: current.file.sourceCompletions;
|
|
3138
|
+
if (sourceCompletions?.length)
|
|
3139
|
+
validateJournalSourceCompletions(sourceCompletions, path, version);
|
|
3140
|
+
const published = publishMutation(current, current.file.entries.filter(entry => !requestedUpdateIds.has(entry.updateId)), true, current.file.operatorDispositions, current.file.acceptedThroughUpdateId, onPublicationBoundary, exact ? false : undefined, sourceCompletions);
|
|
3141
|
+
return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
|
|
3142
|
+
...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
|
|
3143
|
+
});
|
|
3144
|
+
const mutateRoutingInputs = (input, select, publishedAtMs) => runMutation(readCurrent => {
|
|
3145
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version))
|
|
3146
|
+
throw createJournalError("unsupported-version", path, "routing input lifetime requires a private v1 source");
|
|
3147
|
+
const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
3148
|
+
if (input.journalBindingKey !== binding || !isSafePositiveInteger(input.operatorUserId) ||
|
|
3149
|
+
typeof input.isCurrent !== "function" || !Array.isArray(input.entries) || !input.entries.length || input.entries.length > maxEntries) {
|
|
3150
|
+
throw createJournalError("invalid", path, "invalid routing input lifetime authority");
|
|
3151
|
+
}
|
|
3152
|
+
const assertCurrent = () => { if (!input.isCurrent())
|
|
3153
|
+
throw createJournalError("conflict", path, "routing input lifetime authority ended"); };
|
|
3154
|
+
assertCurrent();
|
|
3155
|
+
const expected = input.entries.map(entry => validateJournalEntry(entry, path, version));
|
|
3156
|
+
if (new Set(expected.map(entry => entry.updateId)).size !== expected.length)
|
|
3157
|
+
throw createJournalError("invalid", path, "duplicate routing input sources");
|
|
3158
|
+
const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
|
|
3159
|
+
const nowMs = select ? getNowMs() : publishedAtMs;
|
|
3160
|
+
if (!isSafeNonNegativeInteger(nowMs) || !Number.isSafeInteger(nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS) || (!select && nowMs > getNowMs()))
|
|
3161
|
+
throw createJournalError("invalid", path, "invalid routing input lifetime clock");
|
|
3162
|
+
let issued = false;
|
|
3163
|
+
const changed = new Map();
|
|
3164
|
+
const result = expected.map(original => {
|
|
3165
|
+
const entry = current.file.entries.find(value => value.updateId === original.updateId);
|
|
3166
|
+
if (!entry || entry.state !== "pending" || original.state !== "pending" || entry.inputClaim || entry.inputProvenance || entry.queueOwner) {
|
|
3167
|
+
throw createJournalError("conflict", path, "routing input source is not unclaimed pending work");
|
|
3168
|
+
}
|
|
3169
|
+
const withoutLifetime = (value) => { const copy = cloneEntry(value); delete copy.routingInput; return copy; };
|
|
3170
|
+
if (!isDeepStrictEqual(entry, original) && (!isDeepStrictEqual(withoutLifetime(entry), withoutLifetime(original)) ||
|
|
3171
|
+
(original.routingInput && !isDeepStrictEqual(original.routingInput, entry.routingInput)))) {
|
|
3172
|
+
throw createJournalError("conflict", path, "routing input source changed");
|
|
3173
|
+
}
|
|
3174
|
+
const lifetime = entry.routingInput;
|
|
3175
|
+
if (lifetime && lifetime.operatorUserId !== input.operatorUserId)
|
|
3176
|
+
throw createJournalError("conflict", path, "routing input belongs to another operator");
|
|
3177
|
+
if (createPendingRetentionReference({ path, journalBindingKey: binding, entry, maxBytes }).read()) {
|
|
3178
|
+
throw createJournalError("conflict", path, "routing input has protected retention");
|
|
3179
|
+
}
|
|
3180
|
+
if (select) {
|
|
3181
|
+
if (!lifetime || (lifetime.phase === "waiting" && nowMs >= lifetime.expiresAtMs))
|
|
3182
|
+
throw createJournalError("conflict", path, "routing input choice expired or was not armed");
|
|
3183
|
+
if (lifetime.phase === "selected")
|
|
3184
|
+
return cloneEntry(entry);
|
|
3185
|
+
issued = true;
|
|
3186
|
+
const selected = { ...cloneEntry(entry), routingInput: { ...lifetime, phase: "selected" } };
|
|
3187
|
+
changed.set(entry.updateId, selected);
|
|
3188
|
+
return cloneEntry(selected);
|
|
3189
|
+
}
|
|
3190
|
+
if (lifetime) {
|
|
3191
|
+
if (lifetime.phase !== "waiting")
|
|
3192
|
+
throw createJournalError("conflict", path, "selected routing input cannot renew its choice");
|
|
3193
|
+
return cloneEntry(entry);
|
|
3194
|
+
}
|
|
3195
|
+
if (nowMs < entry.admittedAtMs)
|
|
3196
|
+
throw createJournalError("conflict", path, "routing input publication predates admission");
|
|
3197
|
+
issued = true;
|
|
3198
|
+
const armed = { ...cloneEntry(entry), routingInput: { operatorUserId: input.operatorUserId,
|
|
3199
|
+
publishedAtMs: nowMs, expiresAtMs: nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS, phase: "waiting" } };
|
|
3200
|
+
changed.set(entry.updateId, armed);
|
|
3201
|
+
return cloneEntry(armed);
|
|
3202
|
+
});
|
|
3203
|
+
assertCurrent();
|
|
3204
|
+
if (select && issued && result.some(entry => !changed.has(entry.updateId)))
|
|
3205
|
+
throw createJournalError("conflict", path, "routing input selection has mixed prior phases");
|
|
3206
|
+
if (changed.size)
|
|
3207
|
+
publishMutation(current, current.file.entries.map(entry => changed.get(entry.updateId) ?? entry), true, current.file.operatorDispositions, current.file.acceptedThroughUpdateId, (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
|
|
3208
|
+
assertCurrent();
|
|
3209
|
+
return { issued, entries: result };
|
|
3210
|
+
});
|
|
2432
3211
|
const journal = {
|
|
2433
3212
|
read() {
|
|
2434
3213
|
return runMutation((readCurrent) => {
|
|
@@ -2467,9 +3246,12 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2467
3246
|
const current = readCurrent();
|
|
2468
3247
|
const entriesById = new Map(current.file.entries.map((entry) => [entry.updateId, entry]));
|
|
2469
3248
|
const previousAcceptedThroughUpdateId = current.file.acceptedThroughUpdateId;
|
|
2470
|
-
const discardedUpdateIds = new Set(
|
|
2471
|
-
|
|
2472
|
-
|
|
3249
|
+
const discardedUpdateIds = new Set([
|
|
3250
|
+
...(current.file.operatorDispositions ?? [])
|
|
3251
|
+
.filter((disposition) => disposition.action === "discard")
|
|
3252
|
+
.map((disposition) => disposition.updateId),
|
|
3253
|
+
...(current.file.sourceCompletions ?? []).map(completion => completion.updateId),
|
|
3254
|
+
]);
|
|
2473
3255
|
let admittedAtMs;
|
|
2474
3256
|
const addedUpdateIds = [];
|
|
2475
3257
|
const duplicateUpdateIds = [];
|
|
@@ -2643,6 +3425,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2643
3425
|
update: entry.update,
|
|
2644
3426
|
admittedAtMs: entry.admittedAtMs,
|
|
2645
3427
|
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
3428
|
+
...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
|
|
2646
3429
|
state: "queued",
|
|
2647
3430
|
queueKind: receipt.queueKind,
|
|
2648
3431
|
queueReceiptId: receipt.receiptId,
|
|
@@ -2719,6 +3502,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2719
3502
|
updateId: entry.updateId,
|
|
2720
3503
|
update: entry.update,
|
|
2721
3504
|
admittedAtMs: entry.admittedAtMs,
|
|
3505
|
+
...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
|
|
2722
3506
|
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
2723
3507
|
state: input.disposition,
|
|
2724
3508
|
failure,
|
|
@@ -2808,8 +3592,8 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2808
3592
|
applyLegacyCustodyDisposition(authority) {
|
|
2809
3593
|
return runMutation((readCurrent) => {
|
|
2810
3594
|
const current = readCurrent();
|
|
2811
|
-
if (current.file.version
|
|
2812
|
-
throw createJournalError("conflict", path, "legacy custody disposition requires a
|
|
3595
|
+
if (!journalHasCustodyFields(current.file.version))
|
|
3596
|
+
throw createJournalError("conflict", path, "legacy custody disposition requires a custody journal");
|
|
2813
3597
|
const existing = current.file.operatorDispositions?.find(candidate => candidate.failureId === authority.dispositionId);
|
|
2814
3598
|
if (existing) {
|
|
2815
3599
|
if (!("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody")
|
|
@@ -3018,67 +3802,8 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
3018
3802
|
};
|
|
3019
3803
|
});
|
|
3020
3804
|
},
|
|
3021
|
-
completeQueued(receipts) {
|
|
3022
|
-
|
|
3023
|
-
if (!Array.isArray(receipts) || receipts.length === 0) {
|
|
3024
|
-
throw createJournalError("invalid", path, "received no queued receipts to complete");
|
|
3025
|
-
}
|
|
3026
|
-
const current = readCurrent();
|
|
3027
|
-
const receiptIds = new Set();
|
|
3028
|
-
const requestedUpdateIds = new Set();
|
|
3029
|
-
for (const receipt of receipts) {
|
|
3030
|
-
if ((receipt.queueKind !== "prompt" &&
|
|
3031
|
-
receipt.queueKind !== "control") ||
|
|
3032
|
-
!isNonEmptyString(receipt.receiptId) ||
|
|
3033
|
-
receiptIds.has(receipt.receiptId) ||
|
|
3034
|
-
!Array.isArray(receipt.sourceUpdateIds) ||
|
|
3035
|
-
receipt.sourceUpdateIds.length === 0) {
|
|
3036
|
-
throw createJournalError("invalid", path, "received an invalid queued completion receipt");
|
|
3037
|
-
}
|
|
3038
|
-
receiptIds.add(receipt.receiptId);
|
|
3039
|
-
const queueOwner = validateJournalQueueOwner(receipt.queueOwner, path);
|
|
3040
|
-
if (queueRuntimeIdentity &&
|
|
3041
|
-
(queueOwner.instanceId !== queueRuntimeIdentity.instanceId ||
|
|
3042
|
-
queueOwner.processId !== queueRuntimeIdentity.processId ||
|
|
3043
|
-
queueOwner.processBirthId !==
|
|
3044
|
-
queueRuntimeIdentity.processBirthId)) {
|
|
3045
|
-
throw createJournalError("conflict", path, `cannot complete foreign queue receipt ${receipt.receiptId}`);
|
|
3046
|
-
}
|
|
3047
|
-
const sourceUpdateIds = new Set();
|
|
3048
|
-
for (const updateId of receipt.sourceUpdateIds) {
|
|
3049
|
-
if (!isSafeNonNegativeInteger(updateId) ||
|
|
3050
|
-
sourceUpdateIds.has(updateId) ||
|
|
3051
|
-
requestedUpdateIds.has(updateId)) {
|
|
3052
|
-
throw createJournalError("invalid", path, "received overlapping queued completion update ids");
|
|
3053
|
-
}
|
|
3054
|
-
sourceUpdateIds.add(updateId);
|
|
3055
|
-
requestedUpdateIds.add(updateId);
|
|
3056
|
-
}
|
|
3057
|
-
const persistedReceiptEntries = current.file.entries.filter((entry) => entry.queueReceiptId === receipt.receiptId);
|
|
3058
|
-
if (persistedReceiptEntries.length !== sourceUpdateIds.size ||
|
|
3059
|
-
persistedReceiptEntries.some((entry) => !sourceUpdateIds.has(entry.updateId) ||
|
|
3060
|
-
entry.state !== "queued" ||
|
|
3061
|
-
entry.queueKind !== receipt.queueKind ||
|
|
3062
|
-
!entry.queueOwner ||
|
|
3063
|
-
entry.queueHandoff !== undefined ||
|
|
3064
|
-
!areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, queueOwner))) {
|
|
3065
|
-
throw createJournalError("conflict", path, `cannot complete stale or foreign queue receipt ${receipt.receiptId}`);
|
|
3066
|
-
}
|
|
3067
|
-
}
|
|
3068
|
-
const removedUpdateIds = current.file.entries
|
|
3069
|
-
.filter((entry) => requestedUpdateIds.has(entry.updateId))
|
|
3070
|
-
.map((entry) => entry.updateId);
|
|
3071
|
-
if (removedUpdateIds.length !== requestedUpdateIds.size) {
|
|
3072
|
-
throw createJournalError("conflict", path, "queued completion did not resolve every source update");
|
|
3073
|
-
}
|
|
3074
|
-
const published = publishMutation(current, current.file.entries.filter((entry) => !requestedUpdateIds.has(entry.updateId)), true);
|
|
3075
|
-
return {
|
|
3076
|
-
removedUpdateIds,
|
|
3077
|
-
entryCount: published.file.entries.length,
|
|
3078
|
-
serializedBytes: published.serializedBytes,
|
|
3079
|
-
};
|
|
3080
|
-
});
|
|
3081
|
-
},
|
|
3805
|
+
completeQueued(receipts) { return completeQueued(receipts); },
|
|
3806
|
+
completeQueuedExact(receipts, completions) { return completeQueued(receipts, completions, true); },
|
|
3082
3807
|
discardQueued(input) {
|
|
3083
3808
|
return runMutation((readCurrent) => {
|
|
3084
3809
|
if ((input.queueKind !== "prompt" &&
|
|
@@ -3194,33 +3919,235 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
3194
3919
|
};
|
|
3195
3920
|
});
|
|
3196
3921
|
},
|
|
3197
|
-
|
|
3922
|
+
inspectAbandonedPending(updateId) {
|
|
3198
3923
|
return runMutation((readCurrent) => {
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
for (const updateId of updateIds) {
|
|
3202
|
-
if (!isSafeNonNegativeInteger(updateId)) {
|
|
3203
|
-
throw createJournalError("invalid", path, "received an invalid removal update id");
|
|
3204
|
-
}
|
|
3205
|
-
requestedIds.add(updateId);
|
|
3924
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
3925
|
+
throw createJournalError("unsupported-version", path, "abandonment inspection requires the legacy v1 source");
|
|
3206
3926
|
}
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3927
|
+
if (!isSafeNonNegativeInteger(updateId))
|
|
3928
|
+
throw createJournalError("invalid", path, "invalid abandonment update id");
|
|
3929
|
+
// Inspection never repairs or quarantines source evidence.
|
|
3930
|
+
const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
|
|
3931
|
+
const matches = (current.file.operatorDispositions ?? []).filter(item => item.updateId === updateId);
|
|
3932
|
+
const disposition = matches[0];
|
|
3933
|
+
if (matches.length !== 1 || !disposition || !("dispositionKind" in disposition) ||
|
|
3934
|
+
disposition.dispositionKind !== "legacy-custody" || disposition.action !== "discard" ||
|
|
3935
|
+
disposition.failureId !== `abandon-${disposition.evidenceSha256}` ||
|
|
3936
|
+
current.file.entries.some(entry => entry.updateId === updateId))
|
|
3937
|
+
return undefined;
|
|
3938
|
+
const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
3939
|
+
const retainedPath = join(`${path}.retained`, `${disposition.failureId}.json`);
|
|
3940
|
+
let stat;
|
|
3941
|
+
try {
|
|
3942
|
+
stat = lstatSync(retainedPath);
|
|
3213
3943
|
}
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3944
|
+
catch (error) {
|
|
3945
|
+
if (error.code === "ENOENT")
|
|
3946
|
+
throw createJournalError("conflict", retainedPath, "committed abandonment lost its retained input");
|
|
3947
|
+
throw error;
|
|
3948
|
+
}
|
|
3949
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
|
|
3950
|
+
throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
|
|
3951
|
+
}
|
|
3952
|
+
const parsed = JSON.parse(readFileSync(retainedPath, "utf8"));
|
|
3953
|
+
if (!isRecord(parsed))
|
|
3954
|
+
throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
|
|
3955
|
+
const entry = validateJournalEntry(parsed.entry, retainedPath, version);
|
|
3956
|
+
const reference = createPendingRetentionReference({ path, journalBindingKey, entry, maxBytes });
|
|
3957
|
+
const retained = reference.read();
|
|
3958
|
+
if (entry.updateId !== updateId || reference.retainedPath !== retainedPath ||
|
|
3959
|
+
!isDeepStrictEqual(retained?.requestedDisposition, disposition)) {
|
|
3960
|
+
throw createJournalError("conflict", retainedPath, "retained input does not match committed abandonment");
|
|
3961
|
+
}
|
|
3962
|
+
return { journalBindingKey, updateId, retainedPath, operatorAuthorityId: disposition.operatorAuthorityId };
|
|
3963
|
+
});
|
|
3964
|
+
},
|
|
3965
|
+
inspectPendingRetention(entry) {
|
|
3966
|
+
return runMutation(() => {
|
|
3967
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
3968
|
+
throw createJournalError("unsupported-version", path, "pending retention requires the legacy v1 source");
|
|
3969
|
+
}
|
|
3970
|
+
const expected = validateJournalEntry(entry, path, version);
|
|
3971
|
+
if (expected.state !== "pending")
|
|
3972
|
+
throw createJournalError("conflict", path, "retention inspection requires a pending source");
|
|
3973
|
+
const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
3974
|
+
const reference = createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
|
|
3975
|
+
const retained = reference.read();
|
|
3976
|
+
return retained ? { journalBindingKey, retainedPath: reference.retainedPath,
|
|
3977
|
+
requestedDisposition: retained.requestedDisposition } : undefined;
|
|
3978
|
+
});
|
|
3979
|
+
},
|
|
3980
|
+
abandonPending(input) {
|
|
3981
|
+
return runMutation((readCurrent) => {
|
|
3982
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
3983
|
+
throw createJournalError("unsupported-version", path, "pending abandonment requires the legacy v1 source");
|
|
3984
|
+
}
|
|
3985
|
+
const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
3986
|
+
if (input.journalBindingKey !== journalBindingKey) {
|
|
3987
|
+
throw createJournalError("identity-mismatch", path, "pending abandonment belongs to another source");
|
|
3988
|
+
}
|
|
3989
|
+
if (!isBoundedString(input.operatorAuthorityId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
3990
|
+
!input.operatorAuthorityId.trim() || typeof input.isCurrent !== "function") {
|
|
3991
|
+
throw createJournalError("invalid", path, "pending abandonment requires explicit operator authority");
|
|
3992
|
+
}
|
|
3993
|
+
const assertCurrent = () => {
|
|
3994
|
+
if (!input.isCurrent())
|
|
3995
|
+
throw createJournalError("conflict", path, "pending abandonment lost authority");
|
|
3996
|
+
};
|
|
3997
|
+
assertCurrent();
|
|
3998
|
+
const expected = validateJournalEntry(input.entry, path, version);
|
|
3999
|
+
if (expected.state !== "pending" || expected.inputClaim || expected.inputProvenance ||
|
|
4000
|
+
expected.queueOwner || expected.queueHandoff || expected.queueReceiptId || expected.failure) {
|
|
4001
|
+
throw createJournalError("conflict", path, "only an exact unclaimed pending source may be abandoned");
|
|
4002
|
+
}
|
|
4003
|
+
// Never repair/quarantine source evidence while deciding an operator cancellation.
|
|
4004
|
+
const readStrict = sourceAccess ? readCurrent : () => readCurrentStrict(false);
|
|
4005
|
+
const current = readStrict();
|
|
4006
|
+
const { evidenceSha256, failureId, retainedPath, read: readRetention } = createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
|
|
4007
|
+
const existing = current.file.operatorDispositions?.find(item => item.failureId === failureId || item.updateId === expected.updateId);
|
|
4008
|
+
const currentEntry = current.file.entries.find(entry => entry.updateId === expected.updateId);
|
|
4009
|
+
const assertRetention = (retained, disposition) => {
|
|
4010
|
+
if (!retained || !isDeepStrictEqual(retained.requestedDisposition, disposition)) {
|
|
4011
|
+
throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
|
|
4012
|
+
}
|
|
3220
4013
|
};
|
|
4014
|
+
if (existing) {
|
|
4015
|
+
if (currentEntry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" ||
|
|
4016
|
+
existing.failureId !== failureId || existing.action !== "discard" || existing.evidenceSha256 !== evidenceSha256) {
|
|
4017
|
+
throw createJournalError("conflict", path, "pending source already has another disposition");
|
|
4018
|
+
}
|
|
4019
|
+
assertRetention(readRetention(), existing);
|
|
4020
|
+
assertCurrent();
|
|
4021
|
+
return { disposition: existing, retainedPath, duplicate: true,
|
|
4022
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
4023
|
+
}
|
|
4024
|
+
if (!currentEntry || !isDeepStrictEqual(currentEntry, expected)) {
|
|
4025
|
+
throw createJournalError("conflict", path, "pending source changed before abandonment");
|
|
4026
|
+
}
|
|
4027
|
+
const nowMs = getNowMs();
|
|
4028
|
+
if (!isSafeNonNegativeInteger(nowMs))
|
|
4029
|
+
throw createJournalError("invalid", path, "invalid abandonment timestamp");
|
|
4030
|
+
const retained = readRetention();
|
|
4031
|
+
let disposition;
|
|
4032
|
+
if (retained) {
|
|
4033
|
+
disposition = retained.requestedDisposition;
|
|
4034
|
+
}
|
|
4035
|
+
else {
|
|
4036
|
+
// Reuse the existing legacy discard tombstone so older v1 readers also refuse replay.
|
|
4037
|
+
// This is an operator disposition, not a fabricated execution failure or completion.
|
|
4038
|
+
disposition = { dispositionKind: "legacy-custody", failureId, updateId: expected.updateId,
|
|
4039
|
+
action: "discard", committedAtMs: nowMs, evidenceSha256,
|
|
4040
|
+
operatorAuthorityId: input.operatorAuthorityId, authorizedAtMs: nowMs };
|
|
4041
|
+
const copy = { version: 1, kind: "pending-input-retention",
|
|
4042
|
+
journalBindingKey, entry: expected, requestedDisposition: disposition };
|
|
4043
|
+
writeJournalFile(retainedPath, `${JSON.stringify(copy)}\n`, (boundary, target) => {
|
|
4044
|
+
onPublicationBoundary?.(boundary, target);
|
|
4045
|
+
assertCurrent();
|
|
4046
|
+
});
|
|
4047
|
+
}
|
|
4048
|
+
assertCurrent();
|
|
4049
|
+
assertRetention(readRetention(), disposition);
|
|
4050
|
+
const committed = (snapshot) => !snapshot.file.entries.some(entry => entry.updateId === expected.updateId) &&
|
|
4051
|
+
snapshot.file.operatorDispositions?.some(item => isDeepStrictEqual(item, disposition));
|
|
4052
|
+
try {
|
|
4053
|
+
const published = publishMutation(current, current.file.entries.filter(entry => entry.updateId !== expected.updateId), true, [...(current.file.operatorDispositions ?? []), disposition], current.file.acceptedThroughUpdateId, (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
|
|
4054
|
+
return { disposition, retainedPath, duplicate: false,
|
|
4055
|
+
entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
4056
|
+
}
|
|
4057
|
+
catch (error) {
|
|
4058
|
+
// A segment may be durable even if later snapshot compaction failed.
|
|
4059
|
+
// Only exact read-back can turn that unknown acknowledgement into success.
|
|
4060
|
+
let observed;
|
|
4061
|
+
try {
|
|
4062
|
+
observed = readStrict();
|
|
4063
|
+
}
|
|
4064
|
+
catch {
|
|
4065
|
+
throw error;
|
|
4066
|
+
}
|
|
4067
|
+
if (!committed(observed))
|
|
4068
|
+
throw error;
|
|
4069
|
+
assertRetention(readRetention(), disposition);
|
|
4070
|
+
return { disposition, retainedPath, duplicate: false,
|
|
4071
|
+
entryCount: observed.file.entries.length, serializedBytes: observed.serializedBytes };
|
|
4072
|
+
}
|
|
4073
|
+
});
|
|
4074
|
+
},
|
|
4075
|
+
removeCompleted(updateIds) { return removeCompleted(updateIds); },
|
|
4076
|
+
removeCompletedExact(updateIds, expectedSources, completions) { return removeCompleted(updateIds, expectedSources, true, completions); },
|
|
4077
|
+
inspectQueuedReceipt(expected) {
|
|
4078
|
+
return runMutationCore(readCurrent => {
|
|
4079
|
+
if (!sourceAccess || !isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
4080
|
+
throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
|
|
4081
|
+
}
|
|
4082
|
+
const queueOwner = validateQueuedReceiptObservation(expected, path);
|
|
4083
|
+
const current = readCurrent();
|
|
4084
|
+
return inspectJournalQueuedReceipt(current.file, { ...expected, queueOwner }, path);
|
|
4085
|
+
});
|
|
4086
|
+
},
|
|
4087
|
+
inspectSourceCompletion(expected) {
|
|
4088
|
+
return runMutation(readCurrent => {
|
|
4089
|
+
if (!sourceAccess)
|
|
4090
|
+
throw createJournalError("invalid", path, "source completion requires an exact source handle");
|
|
4091
|
+
const completion = validateJournalSourceCompletion(expected, path);
|
|
4092
|
+
const current = readCurrent();
|
|
4093
|
+
return inspectJournalSourceCompletion(current.file, completion, path);
|
|
3221
4094
|
});
|
|
3222
4095
|
},
|
|
3223
4096
|
};
|
|
4097
|
+
if (isTelegramUpdateJournalLegacyFamilyVersion(version))
|
|
4098
|
+
journal.routingInputs = {
|
|
4099
|
+
arm(input) { return mutateRoutingInputs(input, false, input.publishedAtMs).entries; },
|
|
4100
|
+
select(input) { return mutateRoutingInputs(input, true); },
|
|
4101
|
+
inspectExpiry(updateId) {
|
|
4102
|
+
return runMutation(readCurrent => {
|
|
4103
|
+
if (!isSafeNonNegativeInteger(updateId))
|
|
4104
|
+
throw createJournalError("invalid", path, "invalid routing expiry update ID");
|
|
4105
|
+
return inspectRoutingInputExpiry(readCurrent().file, createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateId);
|
|
4106
|
+
});
|
|
4107
|
+
},
|
|
4108
|
+
inspectGroupExpiry(updateIds) {
|
|
4109
|
+
return runMutation(readCurrent => inspectRoutingGroupExpiry(readCurrent().file, createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateIds));
|
|
4110
|
+
},
|
|
4111
|
+
expire(input) {
|
|
4112
|
+
return runMutation(readCurrent => {
|
|
4113
|
+
const original = validateJournalEntry(input.entry, path, version), lifetime = original.routingInput;
|
|
4114
|
+
const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
4115
|
+
if (!lifetime || original.state !== "pending" || original.inputClaim || original.inputProvenance || original.queueOwner ||
|
|
4116
|
+
original.queueReceiptId || original.queueHandoff || original.failure || input.journalBindingKey !== binding ||
|
|
4117
|
+
lifetime.operatorUserId !== input.operatorUserId || !isSafePositiveInteger(input.operatorUserId) || typeof input.isCurrent !== "function") {
|
|
4118
|
+
throw createJournalError("conflict", path, "routing expiry requires an exact unclaimed chooser source");
|
|
4119
|
+
}
|
|
4120
|
+
const assertCurrent = () => {
|
|
4121
|
+
const now = getNowMs();
|
|
4122
|
+
if (!isSafeNonNegativeInteger(now) || now < lifetime.expiresAtMs || !input.isCurrent())
|
|
4123
|
+
throw createJournalError("conflict", path, "routing source has not expired under current authority");
|
|
4124
|
+
};
|
|
4125
|
+
assertCurrent();
|
|
4126
|
+
const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
|
|
4127
|
+
const evidenceSha256 = createHash("sha256").update(JSON.stringify(original)).digest("hex");
|
|
4128
|
+
const failureId = `routing-expiry:${evidenceSha256}`;
|
|
4129
|
+
const existing = current.file.operatorDispositions?.find(value => value.updateId === original.updateId);
|
|
4130
|
+
const entry = current.file.entries.find(value => value.updateId === original.updateId);
|
|
4131
|
+
if (existing) {
|
|
4132
|
+
if (entry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" || existing.action !== "discard" ||
|
|
4133
|
+
existing.failureId !== failureId || existing.evidenceSha256 !== evidenceSha256 || existing.operatorAuthorityId !== `telegram-owner:${input.operatorUserId}`)
|
|
4134
|
+
throw createJournalError("conflict", path, "routing source has contradictory expiry evidence");
|
|
4135
|
+
assertCurrent();
|
|
4136
|
+
return { disposition: existing, duplicate: true, entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
4137
|
+
}
|
|
4138
|
+
if (!entry || !isDeepStrictEqual(entry, original))
|
|
4139
|
+
throw createJournalError("conflict", path, "routing source changed before expiry");
|
|
4140
|
+
// A discard tombstone prevents old readers and delayed reports from replaying; no prompt-body archive is written.
|
|
4141
|
+
const now = getNowMs();
|
|
4142
|
+
const disposition = { dispositionKind: "legacy-custody", failureId,
|
|
4143
|
+
updateId: original.updateId, action: "discard", committedAtMs: now, authorizedAtMs: now, evidenceSha256,
|
|
4144
|
+
operatorAuthorityId: `telegram-owner:${input.operatorUserId}` };
|
|
4145
|
+
const published = publishMutation(current, current.file.entries.filter(value => value.updateId !== original.updateId), true, [...(current.file.operatorDispositions ?? []), disposition], current.file.acceptedThroughUpdateId, (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
|
|
4146
|
+
assertCurrent();
|
|
4147
|
+
return { disposition, duplicate: false, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
4148
|
+
});
|
|
4149
|
+
},
|
|
4150
|
+
};
|
|
3224
4151
|
if (!getInputContext)
|
|
3225
4152
|
return { journal };
|
|
3226
4153
|
// Strict source acquisition already requires this exact stored receipt scope.
|
|
@@ -3376,7 +4303,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
3376
4303
|
const evidence = readTelegramUpdateJournalSource({
|
|
3377
4304
|
directory: sourceAccess.directory, path, profile,
|
|
3378
4305
|
botIdentity: expectedIdentity, limits: sourceAccess.limits,
|
|
3379
|
-
version
|
|
4306
|
+
version,
|
|
3380
4307
|
});
|
|
3381
4308
|
return evidence.kind === "present"
|
|
3382
4309
|
? listTelegramUpdateJournalLegacyCustodyCandidates(evidence.file) : [];
|