@llblab/pi-kit 0.27.6 → 0.28.0
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/BACKLOG.md +2 -5
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/node_modules/@llblab/pi-telegram/AGENTS.md +10 -8
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -9
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +13 -0
- package/node_modules/@llblab/pi-telegram/LICENSE +21 -0
- package/node_modules/@llblab/pi-telegram/README.md +15 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.d.ts +1 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.js +5 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +5 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +6 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +4 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +54 -56
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +206 -139
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +21 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +181 -25
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.d.ts +4 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.js +28 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +52 -56
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +78 -246
- package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.d.ts +1 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.js +23 -26
- package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.d.ts +0 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.js +5 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +0 -23
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +15 -15
- package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +0 -17
- package/node_modules/@llblab/pi-telegram/dist/lib/config.js +12 -14
- package/node_modules/@llblab/pi-telegram/dist/lib/delivery.d.ts +0 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/delivery.js +2 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +255 -61
- package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +0 -19
- package/node_modules/@llblab/pi-telegram/dist/lib/inbound.d.ts +0 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/inbound.js +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +177 -16
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +1115 -249
- package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.d.ts +0 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.js +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +100 -12
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +380 -14
- package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +30 -12
- package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +129 -72
- package/node_modules/@llblab/pi-telegram/dist/lib/media.d.ts +28 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/media.js +26 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.d.ts +0 -11
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.js +8 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +0 -18
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +18 -18
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.d.ts +4 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.js +12 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/menu.js +4 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +0 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-buttons.js +1 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.d.ts +1 -6
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.js +4 -21
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound.d.ts +0 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound.js +5 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +50 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +155 -17
- package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/pi.js +2 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +0 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +5 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/preview.d.ts +0 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/preview.js +3 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.d.ts +0 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +3 -15
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +69 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/recovery.d.ts +29 -9
- package/node_modules/@llblab/pi-telegram/dist/lib/recovery.js +104 -33
- package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +0 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +73 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +2206 -307
- package/node_modules/@llblab/pi-telegram/dist/lib/sections.d.ts +0 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/sections.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/status.d.ts +44 -9
- package/node_modules/@llblab/pi-telegram/dist/lib/status.js +149 -27
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +0 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +5 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +34 -23
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +18 -21
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.d.ts +32 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.js +191 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +291 -59
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +1647 -369
- package/node_modules/@llblab/pi-telegram/dist/lib/turns.d.ts +0 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/turns.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +184 -28
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +1032 -114
- package/node_modules/@llblab/pi-telegram/dist/lib/wire.d.ts +12 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/wire.js +21 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +25 -17
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +95 -20
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.d.ts +20 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.js +103 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +43 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +148 -52
- package/node_modules/@llblab/pi-telegram/dist/package.json +6 -6
- package/node_modules/@llblab/pi-telegram/dist/skills/generated-control-surface/SKILL.md +3 -3
- package/node_modules/@llblab/pi-telegram/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/node_modules/@llblab/pi-telegram/docs/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/activity.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +152 -34
- package/node_modules/@llblab/pi-telegram/docs/callback-namespaces.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/delivery.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/inbound.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +125 -19
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -1
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +22 -8
- package/node_modules/@llblab/pi-telegram/docs/updates.md +1 -1
- package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +5 -9
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +6 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +5 -2
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +243 -224
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +178 -31
- package/node_modules/@llblab/pi-telegram/lib/bus-transport.ts +30 -8
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +110 -334
- package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +29 -29
- package/node_modules/@llblab/pi-telegram/lib/command-templates.ts +5 -5
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +15 -15
- package/node_modules/@llblab/pi-telegram/lib/config.ts +12 -17
- package/node_modules/@llblab/pi-telegram/lib/delivery.ts +2 -8
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +257 -72
- package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +0 -22
- package/node_modules/@llblab/pi-telegram/lib/inbound.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +1182 -326
- package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +402 -23
- package/node_modules/@llblab/pi-telegram/lib/logging.ts +151 -101
- package/node_modules/@llblab/pi-telegram/lib/media.ts +51 -9
- package/node_modules/@llblab/pi-telegram/lib/menu-model.ts +8 -8
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +18 -18
- package/node_modules/@llblab/pi-telegram/lib/menu-status.ts +11 -0
- package/node_modules/@llblab/pi-telegram/lib/menu-thinking.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/menu.ts +5 -1
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +1 -10
- package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +5 -24
- package/node_modules/@llblab/pi-telegram/lib/outbound.ts +5 -5
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +177 -17
- package/node_modules/@llblab/pi-telegram/lib/pi.ts +5 -2
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +5 -5
- package/node_modules/@llblab/pi-telegram/lib/preview.ts +3 -3
- package/node_modules/@llblab/pi-telegram/lib/prompt-templates.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +67 -9
- package/node_modules/@llblab/pi-telegram/lib/recovery.ts +104 -44
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +1955 -375
- package/node_modules/@llblab/pi-telegram/lib/sections.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/status.ts +156 -36
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +4 -4
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +48 -33
- package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +24 -21
- package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +267 -9
- package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +3 -1
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +1697 -488
- package/node_modules/@llblab/pi-telegram/lib/turns.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +1072 -151
- package/node_modules/@llblab/pi-telegram/lib/wire.ts +28 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +97 -47
- package/node_modules/@llblab/pi-telegram/lib/workspace-identity.ts +147 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +188 -89
- package/node_modules/@llblab/pi-telegram/package.json +6 -6
- package/node_modules/@llblab/pi-telegram/scripts/audit-exports.mjs +100 -0
- package/node_modules/@llblab/pi-telegram/scripts/check-downgrade.mjs +80 -43
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +3 -3
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/package.json +2 -2
|
@@ -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,16 @@ export function normalizeTelegramUpdateJournalLegacyCustodyDispositionAuthority(
|
|
|
194
281
|
evidenceSha256: expected.evidenceSha256, action: value.action,
|
|
195
282
|
operatorAuthorityId: value.operatorAuthorityId, authorizedAtMs: value.authorizedAtMs };
|
|
196
283
|
}
|
|
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") };
|
|
287
|
+
}
|
|
197
288
|
function createJournalError(code, path, detail, cause) {
|
|
198
289
|
return new TelegramUpdateJournalError(code, path, `Telegram update journal ${detail}: ${path}`, cause === undefined ? undefined : { cause });
|
|
199
290
|
}
|
|
200
|
-
function isRecord(value) {
|
|
201
|
-
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
202
|
-
}
|
|
203
|
-
function hasOnlyKeys(value, allowedKeys) {
|
|
204
|
-
const allowed = new Set(allowedKeys);
|
|
205
|
-
return Object.keys(value).every((key) => allowed.has(key));
|
|
206
|
-
}
|
|
207
|
-
function isSafeNonNegativeInteger(value) {
|
|
208
|
-
return Number.isSafeInteger(value) && value >= 0;
|
|
209
|
-
}
|
|
210
291
|
function isSafePositiveInteger(value) {
|
|
211
292
|
return Number.isSafeInteger(value) && value > 0;
|
|
212
293
|
}
|
|
213
|
-
function isNonEmptyString(value) {
|
|
214
|
-
return typeof value === "string" && value.length > 0;
|
|
215
|
-
}
|
|
216
294
|
function isBoundedString(value, maxLength) {
|
|
217
295
|
return isNonEmptyString(value) && value.length <= maxLength;
|
|
218
296
|
}
|
|
@@ -433,8 +511,14 @@ function validateJournalOperatorDisposition(value, path) {
|
|
|
433
511
|
terminalReason: value.terminalReason };
|
|
434
512
|
}
|
|
435
513
|
function journalRequiresExclusion(version) {
|
|
436
|
-
return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION ||
|
|
437
|
-
|
|
514
|
+
return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION || journalHasCustodyFields(version);
|
|
515
|
+
}
|
|
516
|
+
function journalHasCustodyFields(version) {
|
|
517
|
+
return version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
|
|
518
|
+
}
|
|
519
|
+
/** Only legacy v1 carries routing-lifetime, scoped-ACK and pairing-free queue-receipt semantics. */
|
|
520
|
+
export function isTelegramUpdateJournalLegacyFamilyVersion(version) {
|
|
521
|
+
return version === TELEGRAM_UPDATE_JOURNAL_VERSION;
|
|
438
522
|
}
|
|
439
523
|
function validateJournalInputClaim(value, path, updateId) {
|
|
440
524
|
if (!isRecord(value) ||
|
|
@@ -494,6 +578,7 @@ function validateJournalEntry(value, path, version) {
|
|
|
494
578
|
"updateId",
|
|
495
579
|
"update",
|
|
496
580
|
"admittedAtMs",
|
|
581
|
+
...(isTelegramUpdateJournalLegacyFamilyVersion(version) ? ["routingInput"] : []),
|
|
497
582
|
"state",
|
|
498
583
|
"queueKind",
|
|
499
584
|
"queueReceiptId",
|
|
@@ -505,7 +590,7 @@ function validateJournalEntry(value, path, version) {
|
|
|
505
590
|
"terminalReason",
|
|
506
591
|
"terminalFailureId",
|
|
507
592
|
...(journalRequiresExclusion(version) ? ["preApprovalExcluded"] : []),
|
|
508
|
-
...(version
|
|
593
|
+
...(journalHasCustodyFields(version)
|
|
509
594
|
? ["inputClaim", "inputProvenance"] : []),
|
|
510
595
|
]) ||
|
|
511
596
|
!isSafeNonNegativeInteger(value.updateId) ||
|
|
@@ -520,6 +605,20 @@ function validateJournalEntry(value, path, version) {
|
|
|
520
605
|
if (update.update_id !== value.updateId) {
|
|
521
606
|
throw createJournalError("invalid", path, "contains an entry/update id mismatch");
|
|
522
607
|
}
|
|
608
|
+
let routingInput;
|
|
609
|
+
if (value.routingInput !== undefined) {
|
|
610
|
+
const routing = value.routingInput;
|
|
611
|
+
if (!isRecord(routing) || !hasOnlyKeys(routing, ["operatorUserId", "publishedAtMs", "expiresAtMs", "phase"]) ||
|
|
612
|
+
!isSafePositiveInteger(routing.operatorUserId) || !isSafeNonNegativeInteger(routing.publishedAtMs) ||
|
|
613
|
+
routing.publishedAtMs < value.admittedAtMs || !isSafeNonNegativeInteger(routing.expiresAtMs) ||
|
|
614
|
+
routing.expiresAtMs !== routing.publishedAtMs + TELEGRAM_ROUTING_INPUT_TTL_MS ||
|
|
615
|
+
(routing.phase !== "waiting" && routing.phase !== "selected") ||
|
|
616
|
+
(routing.phase === "waiting" && value.state === "queued")) {
|
|
617
|
+
throw createJournalError("invalid", path, "contains invalid routing input lifetime");
|
|
618
|
+
}
|
|
619
|
+
routingInput = { operatorUserId: routing.operatorUserId, publishedAtMs: routing.publishedAtMs,
|
|
620
|
+
expiresAtMs: routing.expiresAtMs, phase: routing.phase };
|
|
621
|
+
}
|
|
523
622
|
const queueKind = value.queueKind;
|
|
524
623
|
const queueReceiptId = value.queueReceiptId;
|
|
525
624
|
const hasQueueMetadata = queueKind !== undefined ||
|
|
@@ -595,6 +694,7 @@ function validateJournalEntry(value, path, version) {
|
|
|
595
694
|
...(inputClaim ? { inputClaim } : {}),
|
|
596
695
|
...(inputProvenance ? { inputProvenance } : {}),
|
|
597
696
|
admittedAtMs: value.admittedAtMs,
|
|
697
|
+
...(routingInput ? { routingInput } : {}),
|
|
598
698
|
state: value.state,
|
|
599
699
|
...(queueKind === "prompt" || queueKind === "control"
|
|
600
700
|
? { queueKind }
|
|
@@ -634,6 +734,29 @@ function assertSupportedJournalVersion(value, path, version = TELEGRAM_UPDATE_JO
|
|
|
634
734
|
throw createJournalError("unsupported-version", path, `uses unsupported version ${String(value.version)}`);
|
|
635
735
|
}
|
|
636
736
|
}
|
|
737
|
+
function validateJournalSourceCompletion(value, path) {
|
|
738
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["updateId", "sourceSha256", "completionSha256"]) ||
|
|
739
|
+
!isSafeNonNegativeInteger(value.updateId) || typeof value.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.sourceSha256) ||
|
|
740
|
+
typeof value.completionSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.completionSha256)) {
|
|
741
|
+
throw createJournalError("invalid", path, "has invalid source completion evidence");
|
|
742
|
+
}
|
|
743
|
+
return { updateId: value.updateId, sourceSha256: value.sourceSha256, completionSha256: value.completionSha256 };
|
|
744
|
+
}
|
|
745
|
+
function validateJournalSourceCompletions(value, path, version, allowEmpty = false) {
|
|
746
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version) || !Array.isArray(value) || !allowEmpty && !value.length) {
|
|
747
|
+
throw createJournalError("invalid", path, "has invalid source completion collection");
|
|
748
|
+
}
|
|
749
|
+
const completions = value.map(item => validateJournalSourceCompletion(item, path));
|
|
750
|
+
const scopes = new Set();
|
|
751
|
+
for (let index = 0; index < completions.length; index++) {
|
|
752
|
+
const completion = completions[index];
|
|
753
|
+
if ((index > 0 && completions[index - 1].updateId >= completion.updateId) || scopes.has(completion.completionSha256)) {
|
|
754
|
+
throw createJournalError("invalid", path, "has duplicate or unordered source completion evidence");
|
|
755
|
+
}
|
|
756
|
+
scopes.add(completion.completionSha256);
|
|
757
|
+
}
|
|
758
|
+
return completions;
|
|
759
|
+
}
|
|
637
760
|
function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION) {
|
|
638
761
|
assertSupportedJournalVersion(value, path, version);
|
|
639
762
|
if (journalRequiresExclusion(version) &&
|
|
@@ -649,6 +772,7 @@ function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION
|
|
|
649
772
|
"botIdentity",
|
|
650
773
|
"entries",
|
|
651
774
|
"operatorDispositions",
|
|
775
|
+
"sourceCompletions",
|
|
652
776
|
]) ||
|
|
653
777
|
!Number.isSafeInteger(value.version) ||
|
|
654
778
|
(value.revision !== undefined &&
|
|
@@ -707,6 +831,14 @@ function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION
|
|
|
707
831
|
}
|
|
708
832
|
}
|
|
709
833
|
const operatorDispositions = (value.operatorDispositions ?? []).map((disposition) => validateJournalOperatorDisposition(disposition, path));
|
|
834
|
+
const sourceCompletions = value.sourceCompletions === undefined ? [] : validateJournalSourceCompletions(value.sourceCompletions, path, version);
|
|
835
|
+
const botIdentity = validateBotIdentity(value.botIdentity, path);
|
|
836
|
+
const activeIds = new Set(entries.map(entry => entry.updateId));
|
|
837
|
+
const discardedIds = new Set(operatorDispositions.filter(disposition => disposition.action === "discard").map(disposition => disposition.updateId));
|
|
838
|
+
if (sourceCompletions.some(completion => activeIds.has(completion.updateId) || discardedIds.has(completion.updateId) ||
|
|
839
|
+
value.acceptedThroughUpdateId !== undefined && completion.updateId > value.acceptedThroughUpdateId)) {
|
|
840
|
+
throw createJournalError("invalid", path, "has contradictory source completion evidence");
|
|
841
|
+
}
|
|
710
842
|
const dispositionFailureIds = new Set();
|
|
711
843
|
const entriesByUpdateId = new Map(entries.map((entry) => [entry.updateId, entry]));
|
|
712
844
|
for (const disposition of operatorDispositions) {
|
|
@@ -729,11 +861,65 @@ function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION
|
|
|
729
861
|
? { acceptedThroughUpdateId: value.acceptedThroughUpdateId }
|
|
730
862
|
: {}),
|
|
731
863
|
profile: value.profile,
|
|
732
|
-
botIdentity
|
|
864
|
+
botIdentity,
|
|
733
865
|
entries,
|
|
734
866
|
...(operatorDispositions.length > 0 ? { operatorDispositions } : {}),
|
|
867
|
+
...(sourceCompletions.length > 0 ? { sourceCompletions } : {}),
|
|
735
868
|
};
|
|
736
869
|
}
|
|
870
|
+
function assertJournalRoutingInputContinuity(file, entries, path) {
|
|
871
|
+
const previous = new Map(file.entries.map(entry => [entry.updateId, entry.routingInput]));
|
|
872
|
+
for (const entry of entries) {
|
|
873
|
+
const retained = previous.get(entry.updateId), next = entry.routingInput;
|
|
874
|
+
if (retained && (!next || next.operatorUserId !== retained.operatorUserId || next.publishedAtMs !== retained.publishedAtMs ||
|
|
875
|
+
next.expiresAtMs !== retained.expiresAtMs || (retained.phase === "selected" && next.phase !== "selected"))) {
|
|
876
|
+
throw createJournalError("invalid", path, "regresses retained routing input lifetime");
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function assertJournalSourceCompletionContinuity(file, segment, path) {
|
|
881
|
+
assertJournalRoutingInputContinuity(file, segment.upsertedEntries, path);
|
|
882
|
+
if (segment.sourceCompletions === undefined)
|
|
883
|
+
return;
|
|
884
|
+
const previous = new Map((file.sourceCompletions ?? []).map(completion => [completion.updateId, completion]));
|
|
885
|
+
const next = new Map(segment.sourceCompletions.map(completion => [completion.updateId, completion]));
|
|
886
|
+
if ([...previous].some(([id, completion]) => !isDeepStrictEqual(completion, next.get(id)))) {
|
|
887
|
+
throw createJournalError("invalid", path, "regresses retained source completion evidence");
|
|
888
|
+
}
|
|
889
|
+
const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
|
|
890
|
+
const removed = new Set(segment.removedUpdateIds), upserted = new Set(segment.upsertedEntries.map(entry => entry.updateId));
|
|
891
|
+
const upsertedReceipts = new Set(segment.upsertedEntries.map(entry => entry.queueReceiptId));
|
|
892
|
+
// Grouped markers share one whole-receipt check rather than rescanning the group per source.
|
|
893
|
+
const queueGroups = new Map(), queueRemovals = new Map();
|
|
894
|
+
for (const entry of file.entries) {
|
|
895
|
+
if (!entry.queueReceiptId)
|
|
896
|
+
continue;
|
|
897
|
+
const group = queueGroups.get(entry.queueReceiptId) ?? [];
|
|
898
|
+
group.push(entry);
|
|
899
|
+
queueGroups.set(entry.queueReceiptId, group);
|
|
900
|
+
}
|
|
901
|
+
const queueWasRemoved = (source) => {
|
|
902
|
+
const receiptId = source.queueReceiptId;
|
|
903
|
+
if (!receiptId || !source.queueOwner)
|
|
904
|
+
return false;
|
|
905
|
+
if (queueRemovals.has(receiptId))
|
|
906
|
+
return queueRemovals.get(receiptId);
|
|
907
|
+
const group = queueGroups.get(receiptId);
|
|
908
|
+
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 &&
|
|
909
|
+
entry.queueHandoff === undefined && !!entry.queueOwner && areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, source.queueOwner));
|
|
910
|
+
queueRemovals.set(receiptId, valid);
|
|
911
|
+
return valid;
|
|
912
|
+
};
|
|
913
|
+
for (const completion of segment.sourceCompletions) {
|
|
914
|
+
if (previous.has(completion.updateId))
|
|
915
|
+
continue;
|
|
916
|
+
const source = entries.get(completion.updateId);
|
|
917
|
+
if (!removed.has(completion.updateId) || !source || (source.state === "queued" && !queueWasRemoved(source)) || source.state === "failed" ||
|
|
918
|
+
createTelegramUpdateJournalEntryDigest(source).sourceSha256 !== completion.sourceSha256) {
|
|
919
|
+
throw createJournalError("invalid", path, "source completion does not match its removal revision");
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
737
923
|
function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION) {
|
|
738
924
|
assertSupportedJournalVersion(value, path, version);
|
|
739
925
|
if (journalRequiresExclusion(version) &&
|
|
@@ -751,6 +937,7 @@ function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERS
|
|
|
751
937
|
"upsertedEntries",
|
|
752
938
|
"removedUpdateIds",
|
|
753
939
|
"operatorDispositions",
|
|
940
|
+
"sourceCompletions",
|
|
754
941
|
]) ||
|
|
755
942
|
value.version !== version ||
|
|
756
943
|
!isSafePositiveInteger(value.revision) ||
|
|
@@ -790,6 +977,7 @@ function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERS
|
|
|
790
977
|
removedUpdateIds.push(updateId);
|
|
791
978
|
}
|
|
792
979
|
const operatorDispositions = (value.operatorDispositions ?? []).map((disposition) => validateJournalOperatorDisposition(disposition, path));
|
|
980
|
+
const segmentBotIdentity = validateBotIdentity(value.botIdentity, path);
|
|
793
981
|
return {
|
|
794
982
|
version,
|
|
795
983
|
revision: value.revision,
|
|
@@ -798,12 +986,13 @@ function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERS
|
|
|
798
986
|
? { acceptedThroughUpdateId: value.acceptedThroughUpdateId }
|
|
799
987
|
: {}),
|
|
800
988
|
profile: value.profile,
|
|
801
|
-
botIdentity:
|
|
989
|
+
botIdentity: segmentBotIdentity,
|
|
802
990
|
upsertedEntries,
|
|
803
991
|
removedUpdateIds,
|
|
804
992
|
...(value.operatorDispositions !== undefined
|
|
805
993
|
? { operatorDispositions }
|
|
806
994
|
: {}),
|
|
995
|
+
...(value.sourceCompletions !== undefined ? { sourceCompletions: validateJournalSourceCompletions(value.sourceCompletions, path, version, true) } : {}),
|
|
807
996
|
};
|
|
808
997
|
}
|
|
809
998
|
/**
|
|
@@ -833,6 +1022,11 @@ export function inspectTelegramInputCustodySourceStatus(input) {
|
|
|
833
1022
|
export function inspectTelegramUpdateJournalFamily(input) {
|
|
834
1023
|
return acquireTelegramUpdateJournalFamily(input).evidence;
|
|
835
1024
|
}
|
|
1025
|
+
/** Classifies private originals against exact discard tombstones; never replay or deletion authority. */
|
|
1026
|
+
export function inspectTelegramUpdateJournalRetention(input) {
|
|
1027
|
+
const acquired = acquireTelegramUpdateJournalFamily(input, undefined, true);
|
|
1028
|
+
return { evidence: acquired.evidence, retainedInputs: acquired.retainedInputs ?? [] };
|
|
1029
|
+
}
|
|
836
1030
|
/**
|
|
837
1031
|
* Read-only source evidence for cooperating writers serialized by the caller through
|
|
838
1032
|
* consumption; never readiness, recovery, or permission to publish. Ancestors retain
|
|
@@ -845,7 +1039,7 @@ export function inspectTelegramUpdateJournalFamily(input) {
|
|
|
845
1039
|
*/
|
|
846
1040
|
export function readTelegramUpdateJournalSource(input) {
|
|
847
1041
|
const version = input.version;
|
|
848
|
-
if (version !== 1 && version !== 2 && version !== 3) {
|
|
1042
|
+
if (version !== 1 && version !== 2 && version !== 3 && version !== 4) {
|
|
849
1043
|
throw createJournalError("unsupported-version", input.path, "requires a selected source version");
|
|
850
1044
|
}
|
|
851
1045
|
return acquireTelegramUpdateJournalFamily({
|
|
@@ -854,7 +1048,7 @@ export function readTelegramUpdateJournalSource(input) {
|
|
|
854
1048
|
limits: { ...input.limits },
|
|
855
1049
|
}, version).evidence;
|
|
856
1050
|
}
|
|
857
|
-
function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
1051
|
+
function acquireTelegramUpdateJournalFamily(input, selectedVersion, inspectRetention = false) {
|
|
858
1052
|
const { path, limits } = input;
|
|
859
1053
|
const acquiredSegments = [];
|
|
860
1054
|
let snapshotRevision = 0;
|
|
@@ -906,7 +1100,7 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
|
906
1100
|
const charge = (value) => {
|
|
907
1101
|
if (!isRecord(value))
|
|
908
1102
|
return;
|
|
909
|
-
for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions"]) {
|
|
1103
|
+
for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions", "sourceCompletions"]) {
|
|
910
1104
|
const collection = value[key];
|
|
911
1105
|
if (!Array.isArray(collection))
|
|
912
1106
|
continue;
|
|
@@ -948,18 +1142,18 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
|
948
1142
|
}
|
|
949
1143
|
};
|
|
950
1144
|
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
951
|
-
const enumerate = () => {
|
|
1145
|
+
const enumerate = (target = segmentDirectory, pattern = /^\d{16}\.json$/u, usedFiles = files) => {
|
|
952
1146
|
const names = [];
|
|
953
|
-
const directory = opendirSync(
|
|
1147
|
+
const directory = opendirSync(target, { bufferSize: 1 });
|
|
954
1148
|
try {
|
|
955
1149
|
for (;;) {
|
|
956
1150
|
const entry = directory.readSync();
|
|
957
1151
|
if (!entry)
|
|
958
1152
|
break;
|
|
959
|
-
if (names.length >= limits.maxFiles -
|
|
1153
|
+
if (names.length >= limits.maxFiles - usedFiles)
|
|
960
1154
|
capacity();
|
|
961
|
-
if (
|
|
962
|
-
fail("has an unexpected
|
|
1155
|
+
if (!pattern.test(entry.name) || !entry.isFile() || entry.isSymbolicLink())
|
|
1156
|
+
fail("has an unexpected journal evidence entry");
|
|
963
1157
|
names.push(entry.name);
|
|
964
1158
|
}
|
|
965
1159
|
}
|
|
@@ -1027,6 +1221,7 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
|
1027
1221
|
if (segment.acceptedThroughUpdateId !== undefined && file.acceptedThroughUpdateId !== undefined &&
|
|
1028
1222
|
segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId)
|
|
1029
1223
|
fail("regresses the admission cursor");
|
|
1224
|
+
assertJournalSourceCompletionContinuity(file, segment, target);
|
|
1030
1225
|
const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
|
|
1031
1226
|
for (const id of segment.removedUpdateIds)
|
|
1032
1227
|
entries.delete(id);
|
|
@@ -1046,16 +1241,70 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
|
1046
1241
|
...(segment.acceptedThroughUpdateId !== undefined ? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId } : {}),
|
|
1047
1242
|
entries: [...entries.values()].sort((a, b) => a.updateId - b.updateId),
|
|
1048
1243
|
operatorDispositions: segment.operatorDispositions ?? file.operatorDispositions,
|
|
1244
|
+
sourceCompletions: segment.sourceCompletions ?? file.sourceCompletions,
|
|
1049
1245
|
};
|
|
1050
1246
|
charge(next);
|
|
1051
1247
|
file = parseJournalFile(next, target, file.version);
|
|
1052
1248
|
}
|
|
1053
1249
|
}
|
|
1250
|
+
const retainedInputs = [];
|
|
1251
|
+
const retentionDirectory = `${path}.retained`;
|
|
1252
|
+
const retention = inspectRetention ? observe(retentionDirectory, true, true) : undefined;
|
|
1253
|
+
const retentionPattern = /^abandon-[a-f0-9]{64}\.json$/u;
|
|
1254
|
+
const retentionNames = retention ? enumerate(retentionDirectory, retentionPattern) : [];
|
|
1255
|
+
if (retentionNames.length > limits.maxEntries)
|
|
1256
|
+
capacity();
|
|
1257
|
+
if (retention) {
|
|
1258
|
+
if (!file || file.version !== 1)
|
|
1259
|
+
fail("private retention requires its exact v1 journal snapshot");
|
|
1260
|
+
const current = file;
|
|
1261
|
+
const bindingKeys = new Set([expected, current.botIdentity].map(botIdentity => createTelegramUpdateJournalBindingKey({ path, profileName: input.profile, botIdentity })));
|
|
1262
|
+
const updateIds = new Set();
|
|
1263
|
+
for (const name of retentionNames) {
|
|
1264
|
+
const target = join(retentionDirectory, name);
|
|
1265
|
+
const before = observe(target, false);
|
|
1266
|
+
if (before.nlink !== 1n)
|
|
1267
|
+
fail("has linked private retention evidence");
|
|
1268
|
+
const raw = read(target, before);
|
|
1269
|
+
files += 1;
|
|
1270
|
+
if (limits.maxWork - work < 2)
|
|
1271
|
+
capacity();
|
|
1272
|
+
work += 2;
|
|
1273
|
+
if (!isRecord(raw))
|
|
1274
|
+
throw createJournalError("invalid", target, "private retention is not an evidence record");
|
|
1275
|
+
if (typeof raw.journalBindingKey !== "string" || !bindingKeys.has(raw.journalBindingKey))
|
|
1276
|
+
throw createJournalError("invalid", target, "has foreign private retention evidence");
|
|
1277
|
+
const entry = validateJournalEntry(raw.entry, target, 1);
|
|
1278
|
+
const disposition = validateJournalOperatorDisposition(raw.requestedDisposition, target);
|
|
1279
|
+
const digest = createHash("sha256").update(JSON.stringify({ journalBindingKey: raw.journalBindingKey, entry })).digest("hex");
|
|
1280
|
+
if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
|
|
1281
|
+
disposition.action !== "discard" || entry.state !== "pending" || disposition.updateId !== entry.updateId ||
|
|
1282
|
+
disposition.evidenceSha256 !== digest || disposition.failureId !== `abandon-${digest}` ||
|
|
1283
|
+
name !== `${disposition.failureId}.json` || !isDeepStrictEqual(raw, { version: 1, kind: "pending-input-retention",
|
|
1284
|
+
journalBindingKey: raw.journalBindingKey, entry, requestedDisposition: disposition }))
|
|
1285
|
+
fail("has inconsistent private retention evidence");
|
|
1286
|
+
if (updateIds.has(entry.updateId))
|
|
1287
|
+
fail("has duplicate private retention sources");
|
|
1288
|
+
updateIds.add(entry.updateId);
|
|
1289
|
+
const committed = current.operatorDispositions?.find(value => value.failureId === disposition.failureId || value.updateId === entry.updateId);
|
|
1290
|
+
if (committed && (!isDeepStrictEqual(committed, disposition) || current.entries.some(value => value.updateId === entry.updateId)))
|
|
1291
|
+
fail("private retention contradicts its journal disposition");
|
|
1292
|
+
retainedInputs.push({ path: target, journalBindingKey: raw.journalBindingKey,
|
|
1293
|
+
failureId: disposition.failureId, updateId: entry.updateId, state: committed ? "committed" : "uncommitted" });
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
if (inspectRetention && file?.operatorDispositions?.some(disposition => "dispositionKind" in disposition && disposition.dispositionKind === "legacy-custody" &&
|
|
1297
|
+
disposition.action === "discard" && /^abandon-[a-f0-9]{64}$/u.test(disposition.failureId) &&
|
|
1298
|
+
!retainedInputs.some(original => original.updateId === disposition.updateId && original.state === "committed")))
|
|
1299
|
+
fail("committed abandonment lost its private original");
|
|
1054
1300
|
// Re-enumeration has the same bound, rather than allocating an unbounded census.
|
|
1055
1301
|
files = snapshot ? 1 : 0;
|
|
1056
1302
|
if (segments && !isDeepStrictEqual(names, enumerate()))
|
|
1057
1303
|
fail("changed segment namespace");
|
|
1058
1304
|
files += names.length;
|
|
1305
|
+
if (retention && !isDeepStrictEqual(retentionNames, enumerate(retentionDirectory, retentionPattern)))
|
|
1306
|
+
fail("changed private retention namespace");
|
|
1307
|
+
files += retentionNames.length;
|
|
1059
1308
|
for (const [target, before] of observed) {
|
|
1060
1309
|
const after = status(target);
|
|
1061
1310
|
const compare = selectedVersion !== undefined && ancestors.has(target) ? sameAncestor : same;
|
|
@@ -1079,6 +1328,7 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
|
1079
1328
|
evidence: file ? { kind: "present", file, ...(knownBotId !== undefined ? { knownBotId } : {}), accounting: { files, bytes, work } } : { kind: "absent" },
|
|
1080
1329
|
snapshotRevision,
|
|
1081
1330
|
segments: acquiredSegments,
|
|
1331
|
+
...(inspectRetention ? { retainedInputs } : {}),
|
|
1082
1332
|
};
|
|
1083
1333
|
}
|
|
1084
1334
|
catch (error) {
|
|
@@ -1087,13 +1337,14 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
|
1087
1337
|
throw createJournalError("io", path, "could not establish strict journal evidence", error);
|
|
1088
1338
|
}
|
|
1089
1339
|
}
|
|
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
1340
|
export function inspectTelegramProfileJournalNamespace(input) {
|
|
1341
|
+
return inspectTelegramJournalNamespace(input, false);
|
|
1342
|
+
}
|
|
1343
|
+
/** Read-only evidence over polling, retained flat recipients and role-neutral session journals. */
|
|
1344
|
+
export function inspectTelegramSessionJournalNamespace(input) {
|
|
1345
|
+
return inspectTelegramJournalNamespace(input, true);
|
|
1346
|
+
}
|
|
1347
|
+
function inspectTelegramJournalNamespace(input, includeSessions) {
|
|
1097
1348
|
const { directory, profile, limits } = input;
|
|
1098
1349
|
const fail = (message) => { throw createJournalError("invalid", directory, message); };
|
|
1099
1350
|
const capacity = () => { throw createJournalError("capacity", directory, "exceeds namespace inspection resource limits"); };
|
|
@@ -1114,7 +1365,15 @@ export function inspectTelegramProfileJournalNamespace(input) {
|
|
|
1114
1365
|
size: value.size, mtimeNs: value.mtimeNs, ctimeNs: value.ctimeNs };
|
|
1115
1366
|
};
|
|
1116
1367
|
const suffix = profile === "default" ? "" : `.${profile}`;
|
|
1117
|
-
const
|
|
1368
|
+
const pollingPath = input.pollingPath ?? join(directory, `inbox${suffix}.json`);
|
|
1369
|
+
const polling = relative(directory, pollingPath);
|
|
1370
|
+
{
|
|
1371
|
+
const parts = polling.split(sep);
|
|
1372
|
+
if (!isAbsolute(pollingPath) || resolve(pollingPath) !== pollingPath || parts.at(-1) !== `inbox${suffix}.json` ||
|
|
1373
|
+
!(parts.length === 1 || (parts.length === 3 && parts[0] === "sessions" &&
|
|
1374
|
+
decodeTelegramSessionDirectoryName(parts[1]) !== undefined)))
|
|
1375
|
+
fail("requires a canonical polling journal path");
|
|
1376
|
+
}
|
|
1118
1377
|
try {
|
|
1119
1378
|
const root = lstatSync(directory);
|
|
1120
1379
|
if (!root.isDirectory() || root.isSymbolicLink() || realpathSync(directory) !== directory)
|
|
@@ -1122,51 +1381,106 @@ export function inspectTelegramProfileJournalNamespace(input) {
|
|
|
1122
1381
|
const beforeRoot = metadata(directory);
|
|
1123
1382
|
const census = () => {
|
|
1124
1383
|
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, ""));
|
|
1384
|
+
const families = new Map(), profiles = new Set();
|
|
1385
|
+
const scan = (parent, visit) => {
|
|
1386
|
+
const handle = opendirSync(parent, { bufferSize: 1 });
|
|
1387
|
+
try {
|
|
1388
|
+
for (;;) {
|
|
1389
|
+
const entry = handle.readSync();
|
|
1390
|
+
if (!entry)
|
|
1391
|
+
break;
|
|
1392
|
+
if (entries.size >= limits.maxDirectoryEntries)
|
|
1393
|
+
capacity();
|
|
1394
|
+
const target = join(parent, entry.name);
|
|
1395
|
+
const key = relative(directory, target);
|
|
1396
|
+
if (entries.has(key))
|
|
1397
|
+
fail("repeated an observed namespace entry");
|
|
1398
|
+
entries.set(key, metadata(target));
|
|
1399
|
+
visit(entry.name, target);
|
|
1400
|
+
}
|
|
1150
1401
|
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1402
|
+
finally {
|
|
1403
|
+
handle.closeSync();
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
const requireType = (target, isDirectory) => {
|
|
1407
|
+
const value = lstatSync(target);
|
|
1408
|
+
if (value.isSymbolicLink() || !(isDirectory ? value.isDirectory() : value.isFile()) ||
|
|
1409
|
+
(!isDirectory && value.nlink !== 1))
|
|
1410
|
+
fail("has a linked or unexpected journal type");
|
|
1411
|
+
};
|
|
1412
|
+
const evidenceDirectory = (target, retained) => scan(target, (name, path) => {
|
|
1413
|
+
if (!(retained ? /^abandon-[a-f0-9]{64}\.json$/u : /^\d{16}\.json$/u).test(name))
|
|
1414
|
+
fail("has an unclassified journal evidence entry");
|
|
1415
|
+
requireType(path, false);
|
|
1416
|
+
});
|
|
1417
|
+
const session = (target) => scan(target, (name, path) => {
|
|
1418
|
+
if (name === "recovery")
|
|
1419
|
+
return;
|
|
1420
|
+
const match = /^(?:journal\.[a-f0-9]{16}|inbox)(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
|
|
1421
|
+
if (!match || match[1] === "default")
|
|
1422
|
+
fail("has an unclassified session journal entry");
|
|
1423
|
+
requireType(path, !!match[2]);
|
|
1424
|
+
if (match[2])
|
|
1425
|
+
evidenceDirectory(path, match[2] === ".retained");
|
|
1426
|
+
const base = path.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match[1] ?? "default";
|
|
1427
|
+
profiles.add(familyProfile);
|
|
1428
|
+
if (familyProfile === profile && base !== pollingPath)
|
|
1429
|
+
families.set(base, "session");
|
|
1430
|
+
});
|
|
1431
|
+
scan(directory, (name, target) => {
|
|
1432
|
+
// Legacy quarantine folders are disposable housekeeping, never journal authority.
|
|
1433
|
+
if (name.toLowerCase() === "recovery")
|
|
1434
|
+
return;
|
|
1435
|
+
if (name.toLowerCase() === "sessions") {
|
|
1436
|
+
if (!includeSessions)
|
|
1437
|
+
fail("requires session-qualified namespace inspection");
|
|
1438
|
+
if (name !== "sessions")
|
|
1439
|
+
fail("has a noncanonical session root");
|
|
1440
|
+
requireType(target, true);
|
|
1441
|
+
scan(target, (sessionName, sessionPath) => {
|
|
1442
|
+
if (decodeTelegramSessionDirectoryName(sessionName) === undefined)
|
|
1443
|
+
fail("has a noncanonical session directory");
|
|
1444
|
+
requireType(sessionPath, true);
|
|
1445
|
+
session(sessionPath);
|
|
1446
|
+
});
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
if (includeSessions && /^journal\./iu.test(name))
|
|
1450
|
+
fail("has a misplaced session journal");
|
|
1451
|
+
if (!/inbox/iu.test(name))
|
|
1452
|
+
return;
|
|
1453
|
+
const match = /^(inbox|follower-inbox-[a-f0-9]{16})(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
|
|
1454
|
+
if (!match || match[2] === "default" || (!includeSessions && match[3] === ".retained"))
|
|
1455
|
+
fail("has a noncanonical journal-like entry");
|
|
1456
|
+
requireType(target, !!match[3]);
|
|
1457
|
+
if (includeSessions && match[3])
|
|
1458
|
+
evidenceDirectory(target, match[3] === ".retained");
|
|
1459
|
+
const base = target.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match[2] ?? "default";
|
|
1460
|
+
profiles.add(familyProfile);
|
|
1461
|
+
if (familyProfile === profile && base !== pollingPath)
|
|
1462
|
+
families.set(base, match[1] === "inbox" ? "session" : "follower");
|
|
1463
|
+
});
|
|
1464
|
+
return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0),
|
|
1465
|
+
families: [...families].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), profiles: [...profiles].sort() };
|
|
1156
1466
|
};
|
|
1157
1467
|
const before = census();
|
|
1468
|
+
const sourceInventory = [[pollingPath, "polling"], ...before.families];
|
|
1158
1469
|
const sources = [];
|
|
1470
|
+
const retainedInputs = [];
|
|
1159
1471
|
const accounting = { directoryEntries: before.entries.length, files: 0, bytes: 0, work: 0 };
|
|
1160
1472
|
let knownBotId = expected.botId;
|
|
1161
|
-
for (const
|
|
1473
|
+
for (const [path, role] of sourceInventory) {
|
|
1162
1474
|
const remaining = { maxFiles: limits.maxFiles - accounting.files, maxBytes: limits.maxBytes - accounting.bytes,
|
|
1163
1475
|
maxWork: limits.maxWork - accounting.work, maxEntries: limits.maxEntries };
|
|
1164
1476
|
if (remaining.maxFiles <= 0 || remaining.maxBytes <= 0 || remaining.maxWork <= 0)
|
|
1165
1477
|
capacity();
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1478
|
+
const acquire = () => acquireTelegramUpdateJournalFamily({ directory, path, profile,
|
|
1479
|
+
botIdentity: { ...expected, ...(knownBotId !== undefined ? { botId: knownBotId } : {}) }, limits: remaining }, undefined, includeSessions);
|
|
1480
|
+
const acquired = input.withSourceReference ? input.withSourceReference(path, acquire) : acquire();
|
|
1481
|
+
const evidence = acquired.evidence;
|
|
1482
|
+
retainedInputs.push(...(acquired.retainedInputs ?? []).map(original => ({ ...original, journalPath: path })));
|
|
1483
|
+
if (evidence.kind === "absent" && (role !== "polling" || before.entries.some(([entry]) => entry === polling || entry === `${polling}.segments`))) {
|
|
1170
1484
|
fail("lost a discovered journal family");
|
|
1171
1485
|
}
|
|
1172
1486
|
if (evidence.kind === "present") {
|
|
@@ -1175,11 +1489,20 @@ export function inspectTelegramProfileJournalNamespace(input) {
|
|
|
1175
1489
|
accounting.bytes += evidence.accounting.bytes;
|
|
1176
1490
|
}
|
|
1177
1491
|
accounting.work += evidence.kind === "present" ? Math.max(1, evidence.accounting.work) : 1;
|
|
1178
|
-
sources.push({ role
|
|
1492
|
+
sources.push({ role, path, evidence });
|
|
1179
1493
|
}
|
|
1180
|
-
|
|
1494
|
+
const namespaceCurrent = () => {
|
|
1495
|
+
try {
|
|
1496
|
+
return isDeepStrictEqual(beforeRoot, metadata(directory)) && isDeepStrictEqual(before, census()) &&
|
|
1497
|
+
isDeepStrictEqual(beforeRoot, metadata(directory));
|
|
1498
|
+
}
|
|
1499
|
+
catch {
|
|
1500
|
+
return false;
|
|
1501
|
+
}
|
|
1502
|
+
};
|
|
1503
|
+
if (!namespaceCurrent())
|
|
1181
1504
|
fail("changed observed root namespace or identity");
|
|
1182
|
-
return { sources, accounting, ...(knownBotId !== undefined ? { knownBotId } : {}) };
|
|
1505
|
+
return { sources, accounting, ...(includeSessions ? { retainedInputs } : {}), ...(knownBotId !== undefined ? { knownBotId } : {}) };
|
|
1183
1506
|
}
|
|
1184
1507
|
catch (error) {
|
|
1185
1508
|
if (error instanceof TelegramUpdateJournalError)
|
|
@@ -1187,6 +1510,28 @@ export function inspectTelegramProfileJournalNamespace(input) {
|
|
|
1187
1510
|
throw createJournalError("io", directory, "could not establish canonical namespace evidence", error);
|
|
1188
1511
|
}
|
|
1189
1512
|
}
|
|
1513
|
+
/** Complete-empty protection only; caller holds source serialization through consumption. Never deletion authority. */
|
|
1514
|
+
export function isTelegramThreadCleanupJournalNamespaceClear(input) {
|
|
1515
|
+
if (!input.requiredJournalBindingKeys.length || input.requiredJournalBindingKeys.length > input.limits.maxDirectoryEntries)
|
|
1516
|
+
throw new Error("Telegram temporary cleanup requires bounded exact journal references.");
|
|
1517
|
+
const requiredPaths = new Set();
|
|
1518
|
+
for (const key of input.requiredJournalBindingKeys) {
|
|
1519
|
+
const path = getTelegramUpdateJournalBindingPath(key);
|
|
1520
|
+
const identities = [input.botIdentity, { tokenSha256: input.botIdentity.tokenSha256 }];
|
|
1521
|
+
if (!path || !identities.some(botIdentity => key === createTelegramUpdateJournalBindingKey({
|
|
1522
|
+
path, profileName: input.profile, botIdentity,
|
|
1523
|
+
})))
|
|
1524
|
+
throw new Error("Telegram temporary cleanup has foreign or malformed journal references.");
|
|
1525
|
+
requiredPaths.add(path);
|
|
1526
|
+
}
|
|
1527
|
+
const namespace = inspectTelegramSessionJournalNamespace(input);
|
|
1528
|
+
if (namespace.sources.filter(source => source.role === "polling").length !== 1 ||
|
|
1529
|
+
namespace.retainedInputs?.some(original => original.state !== "committed") ||
|
|
1530
|
+
[...requiredPaths].some(path => !namespace.sources.some(source => source.path === path && source.evidence.kind === "present")))
|
|
1531
|
+
return false;
|
|
1532
|
+
// Original Telegram targets do not certify execution ownership. No nonempty family gets a source exemption.
|
|
1533
|
+
return namespace.sources.every(source => source.evidence.kind === "absent" || source.evidence.file.entries.length === 0);
|
|
1534
|
+
}
|
|
1190
1535
|
function cloneEntry(entry) {
|
|
1191
1536
|
return {
|
|
1192
1537
|
...entry,
|
|
@@ -1201,6 +1546,7 @@ function cloneEntry(entry) {
|
|
|
1201
1546
|
? { queueHandoff: cloneJournalQueueHandoff(entry.queueHandoff) }
|
|
1202
1547
|
: {}),
|
|
1203
1548
|
...(entry.failure ? { failure: { ...entry.failure } } : {}),
|
|
1549
|
+
...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
|
|
1204
1550
|
};
|
|
1205
1551
|
}
|
|
1206
1552
|
function cloneFile(file) {
|
|
@@ -1213,6 +1559,7 @@ function cloneFile(file) {
|
|
|
1213
1559
|
profile: file.profile,
|
|
1214
1560
|
botIdentity: { ...file.botIdentity },
|
|
1215
1561
|
entries: file.entries.map(cloneEntry),
|
|
1562
|
+
...(file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions.map(completion => ({ ...completion })) } : {}),
|
|
1216
1563
|
...(file.operatorDispositions?.length
|
|
1217
1564
|
? {
|
|
1218
1565
|
operatorDispositions: file.operatorDispositions.map((disposition) => ({ ...disposition })),
|
|
@@ -1312,6 +1659,53 @@ function mergeBotIdentity(stored, current) {
|
|
|
1312
1659
|
tokenSha256: current.tokenSha256,
|
|
1313
1660
|
};
|
|
1314
1661
|
}
|
|
1662
|
+
function createPendingRetentionReference(input) {
|
|
1663
|
+
const { path, journalBindingKey, entry, maxBytes } = input;
|
|
1664
|
+
const evidenceSha256 = createHash("sha256").update(JSON.stringify({ journalBindingKey, entry })).digest("hex");
|
|
1665
|
+
const failureId = `abandon-${evidenceSha256}`;
|
|
1666
|
+
const retainedDirectory = `${path}.retained`;
|
|
1667
|
+
const retainedPath = join(retainedDirectory, `${failureId}.json`);
|
|
1668
|
+
const read = () => {
|
|
1669
|
+
let directory;
|
|
1670
|
+
try {
|
|
1671
|
+
directory = lstatSync(retainedDirectory);
|
|
1672
|
+
}
|
|
1673
|
+
catch (error) {
|
|
1674
|
+
if (error.code === "ENOENT")
|
|
1675
|
+
return undefined;
|
|
1676
|
+
throw error;
|
|
1677
|
+
}
|
|
1678
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) {
|
|
1679
|
+
throw createJournalError("invalid", retainedDirectory, "retention directory is not a regular directory");
|
|
1680
|
+
}
|
|
1681
|
+
let stat;
|
|
1682
|
+
try {
|
|
1683
|
+
stat = lstatSync(retainedPath);
|
|
1684
|
+
}
|
|
1685
|
+
catch (error) {
|
|
1686
|
+
if (error.code === "ENOENT")
|
|
1687
|
+
return undefined;
|
|
1688
|
+
throw error;
|
|
1689
|
+
}
|
|
1690
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
|
|
1691
|
+
throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
|
|
1692
|
+
}
|
|
1693
|
+
const retained = JSON.parse(readFileSync(retainedPath, "utf8"));
|
|
1694
|
+
if (!isRecord(retained))
|
|
1695
|
+
throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
|
|
1696
|
+
const disposition = validateJournalOperatorDisposition(retained.requestedDisposition, retainedPath);
|
|
1697
|
+
if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
|
|
1698
|
+
disposition.failureId !== failureId || disposition.updateId !== entry.updateId ||
|
|
1699
|
+
disposition.action !== "discard" || disposition.evidenceSha256 !== evidenceSha256 ||
|
|
1700
|
+
!isDeepStrictEqual(retained, { version: 1, kind: "pending-input-retention", journalBindingKey,
|
|
1701
|
+
entry, requestedDisposition: disposition })) {
|
|
1702
|
+
throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
|
|
1703
|
+
}
|
|
1704
|
+
return { version: 1, kind: "pending-input-retention", journalBindingKey, entry,
|
|
1705
|
+
requestedDisposition: disposition };
|
|
1706
|
+
};
|
|
1707
|
+
return { evidenceSha256, failureId, retainedPath, read };
|
|
1708
|
+
}
|
|
1315
1709
|
function writeJournalFile(path, serialized, onPublicationBoundary, stagingPath = path) {
|
|
1316
1710
|
const tempPath = `${stagingPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1317
1711
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
@@ -1432,6 +1826,26 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(deps) {
|
|
|
1432
1826
|
});
|
|
1433
1827
|
const path = deps.getJournalPath(configuredProfileName);
|
|
1434
1828
|
const workspaceAdmission = deps.getWorkspaceAdmission?.();
|
|
1829
|
+
const sourceAccess = { directory: dirname(path), limits: {
|
|
1830
|
+
maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
|
|
1831
|
+
maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
|
|
1832
|
+
} };
|
|
1833
|
+
const options = { path, profileName, botIdentity,
|
|
1834
|
+
...(deps.getQueueRuntimeIdentity ? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() } : {}),
|
|
1835
|
+
...(workspaceAdmission ? { workspaceAdmission } : {}),
|
|
1836
|
+
...(deps.withWriterAdmission ? { withWriterAdmission: deps.withWriterAdmission } : {}),
|
|
1837
|
+
...(deps.withSourceSerialization ? { withSourceSerialization: deps.withSourceSerialization } : {}),
|
|
1838
|
+
...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}) };
|
|
1839
|
+
const journal = createTelegramUpdateJournalStore(options);
|
|
1840
|
+
// Scoped completion and receipt observation use strict private handles where the platform provides no-follow
|
|
1841
|
+
// evidence; elsewhere (Windows) the same capabilities read through the ordinary journal so queue receipts still work.
|
|
1842
|
+
const strict = deps.strictSourceAccess ?? Boolean(constants.O_NOFOLLOW && constants.O_NONBLOCK);
|
|
1843
|
+
const completionJournal = deps.withSourceSerialization
|
|
1844
|
+
? createTelegramUpdateJournalStore({ ...options, ...(strict ? { sourceAccess } : {}) }) : undefined;
|
|
1845
|
+
// Queue receipt readiness gates every ordinary prompt; without strict handles it uses the ordinary journal read.
|
|
1846
|
+
const observeQueuedReceipt = (expected) => strict
|
|
1847
|
+
? completionJournal.inspectQueuedReceipt(expected)
|
|
1848
|
+
: inspectJournalQueuedReceipt(journal.read(), { ...expected, queueOwner: validateQueuedReceiptObservation(expected, path) }, path);
|
|
1435
1849
|
return {
|
|
1436
1850
|
runtimeKey: JSON.stringify({
|
|
1437
1851
|
path,
|
|
@@ -1446,27 +1860,25 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(deps) {
|
|
|
1446
1860
|
readForProtection() {
|
|
1447
1861
|
// Protection must never turn corruption/recovery into empty-work evidence.
|
|
1448
1862
|
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
|
-
},
|
|
1863
|
+
...sourceAccess, path, profile: profileName, botIdentity,
|
|
1456
1864
|
});
|
|
1457
|
-
return { entries: evidence.kind === "present" ? evidence.file.entries : [] };
|
|
1865
|
+
return { entries: evidence.kind === "present" ? evidence.file.entries : [], exists: evidence.kind === "present" };
|
|
1458
1866
|
},
|
|
1459
|
-
journal:
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1867
|
+
journal: completionJournal ? { ...journal,
|
|
1868
|
+
removeCompletedExact(updateIds, expectedSources, completions) {
|
|
1869
|
+
return completions !== undefined ? completionJournal.removeCompletedExact(updateIds, expectedSources, completions)
|
|
1870
|
+
: journal.removeCompletedExact(updateIds, expectedSources);
|
|
1871
|
+
},
|
|
1872
|
+
completeQueuedExact(receipts, completions) { return completionJournal.completeQueuedExact(receipts, completions); },
|
|
1873
|
+
inspectSourceCompletion(expected) { return completionJournal.inspectSourceCompletion(expected); },
|
|
1874
|
+
inspectQueuedReceipt: observeQueuedReceipt,
|
|
1875
|
+
isQueueReceiptCurrent(receipt, owner) {
|
|
1876
|
+
if (receipt.journalBindingKey !== createTelegramUpdateJournalBindingKey({ path, profileName, botIdentity }))
|
|
1877
|
+
return false;
|
|
1878
|
+
return observeQueuedReceipt({ queueKind: receipt.queueKind, receiptId: receipt.receiptId,
|
|
1879
|
+
sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...owner } }) !== undefined;
|
|
1880
|
+
},
|
|
1881
|
+
} : journal,
|
|
1470
1882
|
};
|
|
1471
1883
|
};
|
|
1472
1884
|
}
|
|
@@ -1511,42 +1923,200 @@ export function withTelegramResolvedUpdateJournalReference(input) {
|
|
|
1511
1923
|
return input.registry.withReference({ referenceClass: input.referenceClass,
|
|
1512
1924
|
recoveryKey: binding.recoveryKey }, () => input.operation(binding));
|
|
1513
1925
|
}
|
|
1926
|
+
function validateQueuedReceiptObservation(expected, path) {
|
|
1927
|
+
if (!isRecord(expected) || !hasOnlyKeys(expected, ["queueKind", "receiptId", "sourceUpdateIds", "queueOwner"]) || (expected.queueKind !== "prompt" && expected.queueKind !== "control") ||
|
|
1928
|
+
!isNonEmptyString(expected.receiptId) ||
|
|
1929
|
+
!Array.isArray(expected.sourceUpdateIds) || expected.sourceUpdateIds.length === 0 ||
|
|
1930
|
+
expected.sourceUpdateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
|
|
1931
|
+
expected.sourceUpdateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= expected.sourceUpdateIds[index - 1]))) {
|
|
1932
|
+
throw createJournalError("invalid", path, "received invalid queue receipt observation authority");
|
|
1933
|
+
}
|
|
1934
|
+
return validateJournalQueueOwner(expected.queueOwner, path);
|
|
1935
|
+
}
|
|
1936
|
+
function inspectJournalQueuedReceipt(file, expected, path) {
|
|
1937
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version))
|
|
1938
|
+
throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
|
|
1939
|
+
const { queueOwner } = expected;
|
|
1940
|
+
const entries = file.entries.filter(entry => entry.queueReceiptId === expected.receiptId);
|
|
1941
|
+
if (entries.length !== expected.sourceUpdateIds.length || entries.some((entry, index) => entry.updateId !== expected.sourceUpdateIds[index] || entry.state !== "queued" || entry.queueKind !== expected.queueKind ||
|
|
1942
|
+
entry.queueHandoff !== undefined || !isDeepStrictEqual(entry.queueOwner, queueOwner)))
|
|
1943
|
+
return undefined;
|
|
1944
|
+
return { receipt: { queueKind: expected.queueKind, receiptId: expected.receiptId,
|
|
1945
|
+
sourceUpdateIds: [...expected.sourceUpdateIds], queueOwner: { ...queueOwner } },
|
|
1946
|
+
sources: entries.map(createTelegramUpdateJournalEntryDigest),
|
|
1947
|
+
queueOwnerSha256: createHash("sha256").update(JSON.stringify(queueOwner)).digest("hex") };
|
|
1948
|
+
}
|
|
1949
|
+
function inspectJournalSourceCompletion(file, completion, path) {
|
|
1950
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version))
|
|
1951
|
+
throw createJournalError("invalid", path, "source completion requires a legacy v1 source");
|
|
1952
|
+
const retained = file.sourceCompletions?.find(value => value.updateId === completion.updateId);
|
|
1953
|
+
if (!retained)
|
|
1954
|
+
return undefined;
|
|
1955
|
+
if (!isDeepStrictEqual(retained, completion))
|
|
1956
|
+
throw createJournalError("conflict", path, "source completion belongs to another exact scope");
|
|
1957
|
+
return { ...retained };
|
|
1958
|
+
}
|
|
1959
|
+
/** Operator-distinct authority: routing never treats it as an owner cancellation. */
|
|
1960
|
+
export const TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX = "session-successor:";
|
|
1961
|
+
function isAdoptablePendingEntry(entry) {
|
|
1962
|
+
return entry.state === "pending" && !entry.inputClaim && !entry.inputProvenance && !entry.routingInput &&
|
|
1963
|
+
!entry.queueOwner && !entry.queueHandoff && !entry.queueReceiptId && !entry.queueKind && !entry.failure &&
|
|
1964
|
+
entry.preApprovalExcluded !== true;
|
|
1965
|
+
}
|
|
1514
1966
|
export function createTelegramUpdateJournalBindingRuntime(deps) {
|
|
1515
1967
|
const resolveLeader = createTelegramUpdateJournalRuntimeBindingResolver({
|
|
1516
1968
|
...deps.base,
|
|
1517
1969
|
getJournalPath: deps.getLeaderJournalPath,
|
|
1518
1970
|
});
|
|
1519
|
-
const createFollowerResolver = (bindingKey, includeQueueRuntimeIdentity) =>
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
:
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1971
|
+
const createFollowerResolver = (bindingKey, includeQueueRuntimeIdentity, sessionId, legacy = false) => {
|
|
1972
|
+
// Session-aware composition must not fall back to a process journal before preparation supplies its ID.
|
|
1973
|
+
if (!legacy && deps.getActiveFollowerSessionId && !sessionId)
|
|
1974
|
+
return () => undefined;
|
|
1975
|
+
return createTelegramUpdateJournalRuntimeBindingResolver({
|
|
1976
|
+
getProfileName: deps.base.getProfileName,
|
|
1977
|
+
getBotToken: deps.base.getBotToken,
|
|
1978
|
+
getBotId: deps.base.getBotId,
|
|
1979
|
+
...(includeQueueRuntimeIdentity && deps.base.getQueueRuntimeIdentity
|
|
1980
|
+
? { getQueueRuntimeIdentity: deps.base.getQueueRuntimeIdentity }
|
|
1981
|
+
: {}),
|
|
1982
|
+
...(deps.base.withWriterAdmission
|
|
1983
|
+
? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
|
|
1984
|
+
// Follower journals need the same strict exact-receipt ports as the leader; without them queue publication cannot complete.
|
|
1985
|
+
...(deps.base.withSourceSerialization
|
|
1986
|
+
? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
|
|
1987
|
+
...(deps.base.getWorkspaceAdmission
|
|
1988
|
+
? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
|
|
1989
|
+
: {}),
|
|
1990
|
+
...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
|
|
1991
|
+
getJournalPath(profileName) {
|
|
1992
|
+
return deps.getFollowerJournalPath(bindingKey, profileName, sessionId);
|
|
1993
|
+
},
|
|
1994
|
+
});
|
|
1995
|
+
};
|
|
1996
|
+
const resolveFollower = () => createFollowerResolver(deps.getActiveFollowerBindingKey(), true, deps.getActiveFollowerSessionId?.())();
|
|
1537
1997
|
const resolveActive = () => deps.isFollowerRegistered() ? resolveFollower() : resolveLeader();
|
|
1538
|
-
|
|
1998
|
+
const inspectHistoricalSource = (journalBindingKey, operation) => {
|
|
1999
|
+
const path = getTelegramUpdateJournalBindingPath(journalBindingKey);
|
|
2000
|
+
const token = deps.base.getBotToken(), botId = deps.base.getBotId();
|
|
2001
|
+
const profile = deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME;
|
|
2002
|
+
if (!path || !token)
|
|
2003
|
+
return undefined;
|
|
2004
|
+
const botIdentity = createTelegramUpdateJournalBotIdentity({ botToken: token, botId });
|
|
2005
|
+
const keys = [botIdentity, { tokenSha256: botIdentity.tokenSha256 }].map(identity => createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: identity }));
|
|
2006
|
+
if (!keys.includes(journalBindingKey))
|
|
2007
|
+
return undefined;
|
|
2008
|
+
const getRoot = () => deps.getRuntimeDir?.() ?? dirname(deps.getLeaderJournalPath(profile));
|
|
2009
|
+
const root = getRoot();
|
|
2010
|
+
const parts = relative(root, path).split(sep);
|
|
2011
|
+
const suffix = escapeTelegramJournalPathPattern(getTelegramProfilePathSuffix(profile));
|
|
2012
|
+
const flat = new RegExp(`^(?:inbox|follower-inbox-[a-f0-9]{16})${suffix}\\.json$`, "u");
|
|
2013
|
+
const session = new RegExp(`^(?:journal\\.[a-f0-9]{16}|inbox)${suffix}\\.json$`, "u");
|
|
2014
|
+
if (!isAbsolute(root) || resolve(root) !== root || !isAbsolute(path) || resolve(path) !== path ||
|
|
2015
|
+
!(parts.length === 1 && flat.test(parts[0]) || parts.length === 3 && parts[0] === "sessions" &&
|
|
2016
|
+
decodeTelegramSessionDirectoryName(parts[1]) !== undefined && session.test(parts[2])))
|
|
2017
|
+
return undefined;
|
|
2018
|
+
const current = () => deps.base.getBotToken() === token && deps.base.getBotId() === botId &&
|
|
2019
|
+
(deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME) === profile && getRoot() === root;
|
|
2020
|
+
const inspect = () => {
|
|
2021
|
+
if (!current())
|
|
2022
|
+
return undefined;
|
|
2023
|
+
const observed = inspectTelegramUpdateJournalRetention({ directory: root, path, profile, botIdentity,
|
|
2024
|
+
limits: { maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
|
|
2025
|
+
maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024 } });
|
|
2026
|
+
if (observed.evidence.kind !== "present" || !current())
|
|
2027
|
+
return undefined;
|
|
2028
|
+
return operation(observed.evidence.file, observed.retainedInputs, path);
|
|
2029
|
+
};
|
|
2030
|
+
const result = deps.base.withSourceSerialization ? deps.base.withSourceSerialization(inspect) : inspect();
|
|
2031
|
+
return current() ? result : undefined;
|
|
2032
|
+
};
|
|
2033
|
+
let preparedFollower;
|
|
2034
|
+
const runtime = {
|
|
1539
2035
|
resolveLeader,
|
|
1540
2036
|
resolveFollower,
|
|
1541
2037
|
resolveActive,
|
|
1542
2038
|
getActiveRecoveryKey: () => resolveActive()?.recoveryKey,
|
|
1543
|
-
|
|
2039
|
+
prepareActiveFollowerSuccession(isCurrent) {
|
|
2040
|
+
const recipientBindingKey = deps.getActiveFollowerBindingKey();
|
|
2041
|
+
const sessionId = deps.getActiveFollowerSessionId?.();
|
|
2042
|
+
if (!sessionId)
|
|
2043
|
+
return undefined;
|
|
2044
|
+
const previous = preparedFollower;
|
|
2045
|
+
const result = previous && previous.recipientBindingKey === recipientBindingKey && previous.sessionId !== sessionId
|
|
2046
|
+
? runtime.adoptPredecessorPending({ recipientBindingKey, predecessorSessionId: previous.sessionId,
|
|
2047
|
+
successorSessionId: sessionId, isCurrent })
|
|
2048
|
+
: undefined;
|
|
2049
|
+
// Only a completed adoption advances the predecessor; failures retry idempotently on the next preparation.
|
|
2050
|
+
preparedFollower = { recipientBindingKey, sessionId };
|
|
2051
|
+
return result;
|
|
2052
|
+
},
|
|
2053
|
+
inspectSourceAbandonment(journalBindingKey, updateId) {
|
|
2054
|
+
if (!isSafeNonNegativeInteger(updateId))
|
|
2055
|
+
throw new Error("Telegram abandonment proof requires an exact update ID.");
|
|
2056
|
+
return inspectHistoricalSource(journalBindingKey, (file, originals) => {
|
|
2057
|
+
const original = originals.find(value => value.updateId === updateId && value.state === "committed" &&
|
|
2058
|
+
value.journalBindingKey === journalBindingKey);
|
|
2059
|
+
if (!original)
|
|
2060
|
+
return undefined;
|
|
2061
|
+
const disposition = file.operatorDispositions?.find(value => value.failureId === original.failureId);
|
|
2062
|
+
if (!disposition || !("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody")
|
|
2063
|
+
return undefined;
|
|
2064
|
+
return { journalBindingKey, updateId, retainedPath: original.path, operatorAuthorityId: disposition.operatorAuthorityId };
|
|
2065
|
+
});
|
|
2066
|
+
},
|
|
2067
|
+
inspectSourceCompletion(journalBindingKey, expected) {
|
|
2068
|
+
const completion = validateJournalSourceCompletion(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
|
|
2069
|
+
return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalSourceCompletion(file, completion, path));
|
|
2070
|
+
},
|
|
2071
|
+
inspectQueuedReceipt(journalBindingKey, expected) {
|
|
2072
|
+
const queueOwner = validateQueuedReceiptObservation(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
|
|
2073
|
+
return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalQueuedReceipt(file, { ...expected, queueOwner }, path));
|
|
2074
|
+
},
|
|
2075
|
+
adoptPredecessorPending(input) {
|
|
2076
|
+
const { recipientBindingKey, predecessorSessionId, successorSessionId, isCurrent } = input;
|
|
2077
|
+
if (!recipientBindingKey || !predecessorSessionId || !successorSessionId ||
|
|
2078
|
+
predecessorSessionId === successorSessionId || typeof isCurrent !== "function") {
|
|
2079
|
+
throw new Error("Telegram session adoption requires distinct exact session identities.");
|
|
2080
|
+
}
|
|
2081
|
+
const assertCurrent = () => {
|
|
2082
|
+
if (!isCurrent())
|
|
2083
|
+
throw new Error("Telegram session adoption lost successor authority.");
|
|
2084
|
+
};
|
|
2085
|
+
assertCurrent();
|
|
2086
|
+
const predecessor = createFollowerResolver(recipientBindingKey, false, predecessorSessionId)();
|
|
2087
|
+
const successor = createFollowerResolver(recipientBindingKey, false, successorSessionId)();
|
|
2088
|
+
if (!predecessor?.readForProtection || !successor || predecessor.recoveryKey === successor.recoveryKey) {
|
|
2089
|
+
throw new Error("Telegram session adoption requires exact predecessor and successor journals.");
|
|
2090
|
+
}
|
|
2091
|
+
const result = { adoptedUpdateIds: [], retainedUpdateIds: [] };
|
|
2092
|
+
// Non-repairing selection; the abandonment CAS rechecks each exact entry under the source lock.
|
|
2093
|
+
const candidates = predecessor.readForProtection().entries.filter(isAdoptablePendingEntry)
|
|
2094
|
+
.sort((left, right) => left.updateId - right.updateId);
|
|
2095
|
+
for (const entry of candidates) {
|
|
2096
|
+
assertCurrent();
|
|
2097
|
+
// Commit away first: a crash afterwards preserves the private original instead of duplicating execution.
|
|
2098
|
+
const committed = predecessor.journal.abandonPending({ journalBindingKey: predecessor.recoveryKey, entry,
|
|
2099
|
+
operatorAuthorityId: `${TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX}${successorSessionId}`, isCurrent });
|
|
2100
|
+
if (committed.duplicate) {
|
|
2101
|
+
result.retainedUpdateIds.push(entry.updateId);
|
|
2102
|
+
continue;
|
|
2103
|
+
}
|
|
2104
|
+
assertCurrent();
|
|
2105
|
+
successor.journal.appendBatch([entry.update]);
|
|
2106
|
+
result.adoptedUpdateIds.push(entry.updateId);
|
|
2107
|
+
}
|
|
2108
|
+
return result;
|
|
2109
|
+
},
|
|
2110
|
+
createRecipientResolver: (bindingKey, sessionId) => createFollowerResolver(bindingKey, false, sessionId),
|
|
2111
|
+
createLegacyRecipientResolver: (bindingKey) => createFollowerResolver(bindingKey, false, undefined, true),
|
|
1544
2112
|
createPathResolver: (path) => createTelegramUpdateJournalRuntimeBindingResolver({
|
|
1545
2113
|
getProfileName: deps.base.getProfileName,
|
|
1546
2114
|
getBotToken: deps.base.getBotToken,
|
|
1547
2115
|
getBotId: deps.base.getBotId,
|
|
1548
2116
|
...(deps.base.withWriterAdmission
|
|
1549
2117
|
? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
|
|
2118
|
+
...(deps.base.withSourceSerialization
|
|
2119
|
+
? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
|
|
1550
2120
|
...(deps.base.getWorkspaceAdmission
|
|
1551
2121
|
? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
|
|
1552
2122
|
: {}),
|
|
@@ -1554,6 +2124,7 @@ export function createTelegramUpdateJournalBindingRuntime(deps) {
|
|
|
1554
2124
|
getJournalPath: () => path,
|
|
1555
2125
|
}),
|
|
1556
2126
|
};
|
|
2127
|
+
return runtime;
|
|
1557
2128
|
}
|
|
1558
2129
|
export function createTelegramUpdateJournalStore(options) {
|
|
1559
2130
|
return createJournalStoreCore(options).journal;
|
|
@@ -1673,6 +2244,9 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
1673
2244
|
if ((file.operatorDispositions?.length ?? 0) > maxEntries) {
|
|
1674
2245
|
throw createJournalError("capacity", path, `exceeds its ${maxEntries}-operator-disposition limit`);
|
|
1675
2246
|
}
|
|
2247
|
+
if ((file.sourceCompletions?.length ?? 0) > maxEntries) {
|
|
2248
|
+
throw createJournalError("capacity", path, `exceeds its ${maxEntries}-source-completion limit`);
|
|
2249
|
+
}
|
|
1676
2250
|
const serializedBytes = Buffer.byteLength(serialized);
|
|
1677
2251
|
if (serializedBytes > maxBytes) {
|
|
1678
2252
|
throw createJournalError("capacity", path, `exceeds its ${maxBytes}-byte limit`);
|
|
@@ -1685,7 +2259,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
1685
2259
|
botIdentity: { ...expectedIdentity },
|
|
1686
2260
|
entries: [],
|
|
1687
2261
|
});
|
|
1688
|
-
const readCurrentStrict = () => {
|
|
2262
|
+
const readCurrentStrict = (allowReconciliation = true) => {
|
|
1689
2263
|
let source;
|
|
1690
2264
|
let recoveringMissingSnapshot = false;
|
|
1691
2265
|
try {
|
|
@@ -1711,6 +2285,8 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
1711
2285
|
throw createJournalError("io", segmentDirectory, "could not be read while the journal snapshot is missing", segmentError);
|
|
1712
2286
|
}
|
|
1713
2287
|
if (orphanedSegmentNames.length > 0) {
|
|
2288
|
+
if (!allowReconciliation)
|
|
2289
|
+
throw createJournalError("invalid", path, "requires explicit missing-snapshot reconciliation");
|
|
1714
2290
|
if (withPairingAdmission)
|
|
1715
2291
|
throw createJournalError("pairing-evidence", path, "requires explicit missing-snapshot reconciliation");
|
|
1716
2292
|
recoveringMissingSnapshot = true;
|
|
@@ -1788,6 +2364,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
1788
2364
|
(withPairingAdmission && segment.botIdentity.tokenSha256 !== storedIdentity.tokenSha256)) {
|
|
1789
2365
|
throw createJournalError("identity-mismatch", segmentPath, "belongs to another journal identity");
|
|
1790
2366
|
}
|
|
2367
|
+
assertJournalSourceCompletionContinuity(file, segment, segmentPath);
|
|
1791
2368
|
const entriesById = new Map(file.entries.map((entry) => [entry.updateId, entry]));
|
|
1792
2369
|
for (const updateId of segment.removedUpdateIds) {
|
|
1793
2370
|
if (recoveringMissingSnapshot && !entriesById.has(updateId)) {
|
|
@@ -1824,14 +2401,16 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
1824
2401
|
: file.operatorDispositions?.length
|
|
1825
2402
|
? { operatorDispositions: file.operatorDispositions }
|
|
1826
2403
|
: {}),
|
|
2404
|
+
...(segment.sourceCompletions !== undefined ? { sourceCompletions: segment.sourceCompletions }
|
|
2405
|
+
: file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions } : {}),
|
|
1827
2406
|
}, segmentPath, version);
|
|
1828
2407
|
revision = segment.revision;
|
|
1829
2408
|
}
|
|
1830
2409
|
const identityChanged = storedProfile !== profile ||
|
|
1831
2410
|
!identitiesMatch(file.botIdentity, expectedIdentity) ||
|
|
1832
|
-
(withPairingAdmission && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
|
|
2411
|
+
((withPairingAdmission || !!file.sourceCompletions?.length) && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
|
|
1833
2412
|
if (identityChanged) {
|
|
1834
|
-
if (file.entries.length > 0 || withPairingAdmission) {
|
|
2413
|
+
if (file.entries.length > 0 || file.sourceCompletions?.length || withPairingAdmission || !allowReconciliation) {
|
|
1835
2414
|
throw createJournalError("identity-mismatch", path, storedProfile !== profile
|
|
1836
2415
|
? `belongs to profile ${storedProfile}, not ${profile}`
|
|
1837
2416
|
: "belongs to another Telegram bot identity");
|
|
@@ -1970,39 +2549,40 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
1970
2549
|
// Only known schema/JSON corruption may fall through to reset.
|
|
1971
2550
|
}
|
|
1972
2551
|
}
|
|
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);
|
|
2552
|
+
// Approved corruption policy: delete damaged evidence instead of quarantining it under recovery/.
|
|
2553
|
+
// Segments go first so a fresh revisionless snapshot can never replay them; a crash repeats the reset.
|
|
2554
|
+
const deletedPaths = [];
|
|
2555
|
+
const deleteDamaged = (target, label) => {
|
|
2556
|
+
try {
|
|
2557
|
+
lstatSync(target);
|
|
1984
2558
|
}
|
|
1985
|
-
|
|
1986
|
-
|
|
2559
|
+
catch (statError) {
|
|
2560
|
+
if (statError.code === "ENOENT")
|
|
2561
|
+
return;
|
|
2562
|
+
throw createJournalError("io", target, `could not inspect an unrecoverable ${label}`, statError);
|
|
2563
|
+
}
|
|
2564
|
+
try {
|
|
2565
|
+
rmSync(target, { recursive: true, force: true });
|
|
2566
|
+
}
|
|
2567
|
+
catch (removeError) {
|
|
2568
|
+
throw createJournalError("io", target, `could not delete an unrecoverable ${label}`, removeError);
|
|
2569
|
+
}
|
|
2570
|
+
deletedPaths.push(target);
|
|
2571
|
+
};
|
|
2572
|
+
deleteDamaged(segmentDirectory, "journal segment history");
|
|
2573
|
+
// A regular snapshot is replaced atomically below; anything else cannot be a rename target.
|
|
2574
|
+
if (snapshotExists && !lstatSync(path).isFile())
|
|
2575
|
+
deleteDamaged(path, "journal snapshot");
|
|
1987
2576
|
const reset = emptyFile();
|
|
1988
2577
|
const serialized = serializeJournalFile(reset);
|
|
1989
2578
|
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
|
-
}
|
|
2579
|
+
writeJournalFile(path, serialized, onPublicationBoundary);
|
|
2580
|
+
if (snapshotExists && !deletedPaths.includes(path))
|
|
2581
|
+
deletedPaths.push(path);
|
|
2002
2582
|
notifyRecovery({
|
|
2003
2583
|
kind: "reset",
|
|
2004
2584
|
path,
|
|
2005
|
-
|
|
2585
|
+
deletedPaths,
|
|
2006
2586
|
reason: error.message,
|
|
2007
2587
|
});
|
|
2008
2588
|
return { file: reset, exists: true, serializedBytes };
|
|
@@ -2048,7 +2628,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2048
2628
|
const inputHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH);
|
|
2049
2629
|
const inputBindingHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH);
|
|
2050
2630
|
const inputHandoffHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH);
|
|
2051
|
-
const getFileWork = (file) => file.entries.length + (file.operatorDispositions?.length ?? 0);
|
|
2631
|
+
const getFileWork = (file) => file.entries.length + (file.operatorDispositions?.length ?? 0) + (file.sourceCompletions?.length ?? 0);
|
|
2052
2632
|
const advanceInputHeadroomState = (state, entries, projectionSlack) => {
|
|
2053
2633
|
const revision = (state.file.revision ?? 0) + 1;
|
|
2054
2634
|
if (!isSafePositiveInteger(revision))
|
|
@@ -2190,7 +2770,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2190
2770
|
const createInputHeadroomSegment = (previous, next) => {
|
|
2191
2771
|
const previousEntries = new Map(previous.entries.map(entry => [entry.updateId, entry]));
|
|
2192
2772
|
const nextEntries = new Map(next.entries.map(entry => [entry.updateId, entry]));
|
|
2193
|
-
return { version
|
|
2773
|
+
return { version,
|
|
2194
2774
|
revision: next.revision, previousRevision: next.revision - 1,
|
|
2195
2775
|
profile: next.profile, botIdentity: next.botIdentity,
|
|
2196
2776
|
upsertedEntries: next.entries.filter(entry => !previousEntries.has(entry.updateId) || !isDeepStrictEqual(previousEntries.get(entry.updateId), entry)),
|
|
@@ -2218,7 +2798,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2218
2798
|
: { files: 1, bytes: publishedBytes, work: getFileWork(publishedFile) };
|
|
2219
2799
|
if (publishedSegment) {
|
|
2220
2800
|
const segmentWork = publishedSegment.upsertedEntries.length + publishedSegment.removedUpdateIds.length +
|
|
2221
|
-
(publishedSegment.operatorDispositions?.length ?? 0);
|
|
2801
|
+
(publishedSegment.operatorDispositions?.length ?? 0) + (publishedSegment.sourceCompletions?.length ?? 0);
|
|
2222
2802
|
const segmentBytes = Buffer.byteLength(`${JSON.stringify(publishedSegment, null, 2)}\n`);
|
|
2223
2803
|
baseRetainedCount += 1;
|
|
2224
2804
|
baseRetainedBytes += segmentBytes;
|
|
@@ -2271,9 +2851,10 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2271
2851
|
const segmentText = `${JSON.stringify(segment, null, 2)}\n`;
|
|
2272
2852
|
const segmentBytes = Buffer.byteLength(segmentText);
|
|
2273
2853
|
const collections = [segment.upsertedEntries.length, segment.removedUpdateIds.length,
|
|
2274
|
-
segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0
|
|
2275
|
-
|
|
2276
|
-
const
|
|
2854
|
+
segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0,
|
|
2855
|
+
segment.sourceCompletions?.length ?? 0, file.sourceCompletions?.length ?? 0];
|
|
2856
|
+
const segmentWork = collections[0] + collections[1] + collections[2] + collections[5];
|
|
2857
|
+
const fileWork = collections[3] + collections[4] + collections[6];
|
|
2277
2858
|
const accounting = source.evidence.accounting;
|
|
2278
2859
|
// Both the segment-first state and snapshot-first cleanup residue must fit.
|
|
2279
2860
|
assertSourceResources(accounting.files + 1, accounting.bytes + segmentBytes, accounting.work + segmentWork + fileWork, collections);
|
|
@@ -2311,12 +2892,13 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2311
2892
|
}
|
|
2312
2893
|
return { file: revisedFile, serializedBytes };
|
|
2313
2894
|
};
|
|
2314
|
-
const publishMutation = (current, entries, contentChanged, operatorDispositions = current.file.operatorDispositions, acceptedThroughUpdateId = current.file.acceptedThroughUpdateId, publicationBoundary = onPublicationBoundary, inputHeadroom = version
|
|
2895
|
+
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
2896
|
const botIdentity = current.source ? current.file.botIdentity : mergeBotIdentity(current.file.botIdentity, expectedIdentity);
|
|
2316
2897
|
if (!contentChanged &&
|
|
2317
2898
|
isDeepStrictEqual(current.file.botIdentity, botIdentity) &&
|
|
2318
2899
|
isDeepStrictEqual(current.file.operatorDispositions ?? [], operatorDispositions ?? []) &&
|
|
2319
|
-
current.file.acceptedThroughUpdateId === acceptedThroughUpdateId
|
|
2900
|
+
current.file.acceptedThroughUpdateId === acceptedThroughUpdateId &&
|
|
2901
|
+
isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? [])) {
|
|
2320
2902
|
return { file: current.file, serializedBytes: current.serializedBytes };
|
|
2321
2903
|
}
|
|
2322
2904
|
const file = {
|
|
@@ -2330,21 +2912,24 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2330
2912
|
...(operatorDispositions?.length
|
|
2331
2913
|
? { operatorDispositions }
|
|
2332
2914
|
: {}),
|
|
2915
|
+
...(sourceCompletions?.length ? { sourceCompletions } : {}),
|
|
2333
2916
|
};
|
|
2917
|
+
assertJournalRoutingInputContinuity(current.file, entries, path);
|
|
2334
2918
|
if (withPairingAdmission || current.source)
|
|
2335
2919
|
parseJournalFile(file, path, version);
|
|
2336
2920
|
const serialized = serializeJournalFile(file);
|
|
2337
2921
|
const serializedBytes = assertCapacity(file, serialized);
|
|
2338
2922
|
const changed = contentChanged ||
|
|
2339
2923
|
current.file.acceptedThroughUpdateId !== acceptedThroughUpdateId ||
|
|
2340
|
-
!isDeepStrictEqual(current.file.botIdentity, file.botIdentity)
|
|
2924
|
+
!isDeepStrictEqual(current.file.botIdentity, file.botIdentity) ||
|
|
2925
|
+
!isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []);
|
|
2341
2926
|
if (!changed) {
|
|
2342
2927
|
return { file, serializedBytes: current.serializedBytes };
|
|
2343
2928
|
}
|
|
2344
2929
|
if (!current.exists) {
|
|
2345
2930
|
if (current.source) {
|
|
2346
|
-
const collections = [file.entries.length, file.operatorDispositions?.length ?? 0];
|
|
2347
|
-
assertSourceResources(1, serializedBytes, collections[0] + collections[1], collections);
|
|
2931
|
+
const collections = [file.entries.length, file.operatorDispositions?.length ?? 0, file.sourceCompletions?.length ?? 0];
|
|
2932
|
+
assertSourceResources(1, serializedBytes, collections[0] + collections[1] + collections[2], collections);
|
|
2348
2933
|
}
|
|
2349
2934
|
if (inputHeadroom !== false)
|
|
2350
2935
|
assertInputProgressHeadroom(current, file, inputHeadroom);
|
|
@@ -2379,6 +2964,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2379
2964
|
...(!isDeepStrictEqual(current.file.operatorDispositions ?? [], operatorDispositions ?? [])
|
|
2380
2965
|
? { operatorDispositions: operatorDispositions ?? [] }
|
|
2381
2966
|
: {}),
|
|
2967
|
+
...(!isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []) ? { sourceCompletions: sourceCompletions ?? [] } : {}),
|
|
2382
2968
|
};
|
|
2383
2969
|
if (current.source) {
|
|
2384
2970
|
if (!isSafePositiveInteger(revision))
|
|
@@ -2386,7 +2972,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2386
2972
|
const revisedFile = { ...file, revision };
|
|
2387
2973
|
if (inputHeadroom !== false)
|
|
2388
2974
|
assertInputProgressHeadroom(current, revisedFile, inputHeadroom, segment);
|
|
2389
|
-
return publishSourceSegment(current, file, segment, publicationBoundary, version
|
|
2975
|
+
return publishSourceSegment(current, file, segment, publicationBoundary, journalHasCustodyFields(version));
|
|
2390
2976
|
}
|
|
2391
2977
|
publishTelegramUpdateJournalSegmentUnlocked(path, segment, publicationBoundary);
|
|
2392
2978
|
const revisedFile = { ...file, revision };
|
|
@@ -2429,6 +3015,175 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2429
3015
|
}
|
|
2430
3016
|
return { file: revisedFile, serializedBytes };
|
|
2431
3017
|
};
|
|
3018
|
+
const removeCompleted = (updateIds, expectedSources, exact = false, completionInput) => runMutation((readCurrent) => {
|
|
3019
|
+
const current = exact && !sourceAccess ? readCurrentStrict(false) : readCurrent();
|
|
3020
|
+
if (completionInput !== undefined && !sourceAccess)
|
|
3021
|
+
throw createJournalError("invalid", path, "source completion requires an exact source handle");
|
|
3022
|
+
const completions = completionInput === undefined ? undefined : validateJournalSourceCompletions(completionInput, path, version);
|
|
3023
|
+
if (exact && expectedSources === undefined)
|
|
3024
|
+
throw createJournalError("invalid", path, "received no exact completion sources");
|
|
3025
|
+
const requestedIds = new Set();
|
|
3026
|
+
for (const updateId of updateIds) {
|
|
3027
|
+
if (!isSafeNonNegativeInteger(updateId))
|
|
3028
|
+
throw createJournalError("invalid", path, "received an invalid removal update id");
|
|
3029
|
+
requestedIds.add(updateId);
|
|
3030
|
+
}
|
|
3031
|
+
if (expectedSources !== undefined) {
|
|
3032
|
+
if (!Array.isArray(expectedSources) || !expectedSources.length)
|
|
3033
|
+
throw createJournalError("invalid", path, "received no exact completion sources");
|
|
3034
|
+
const expectedIds = new Set();
|
|
3035
|
+
for (const expected of expectedSources) {
|
|
3036
|
+
if (!isRecord(expected) || Object.keys(expected).some(key => !["updateId", "sourceSha256"].includes(key)) ||
|
|
3037
|
+
!isSafeNonNegativeInteger(expected.updateId) || !requestedIds.has(expected.updateId) || expectedIds.has(expected.updateId) ||
|
|
3038
|
+
typeof expected.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(expected.sourceSha256)) {
|
|
3039
|
+
throw createJournalError("invalid", path, "received an invalid exact completion source");
|
|
3040
|
+
}
|
|
3041
|
+
expectedIds.add(expected.updateId);
|
|
3042
|
+
const entry = current.file.entries.find(value => value.updateId === expected.updateId);
|
|
3043
|
+
if (!entry || createTelegramUpdateJournalEntryDigest(entry).sourceSha256 !== expected.sourceSha256) {
|
|
3044
|
+
throw createJournalError("conflict", path, "exact completion source changed or disappeared");
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
if (completions) {
|
|
3049
|
+
const guards = new Map((expectedSources ?? []).map(source => [source.updateId, source.sourceSha256]));
|
|
3050
|
+
if (completions.some(completion => guards.get(completion.updateId) !== completion.sourceSha256)) {
|
|
3051
|
+
throw createJournalError("invalid", path, "source completion has no matching exact guard");
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
const requestedEntries = current.file.entries.filter(entry => requestedIds.has(entry.updateId));
|
|
3055
|
+
const protectedEntry = requestedEntries.find(entry => entry.state === "failed" || entry.state === "queued");
|
|
3056
|
+
if (protectedEntry)
|
|
3057
|
+
throw createJournalError("conflict", path, protectedEntry.state === "failed"
|
|
3058
|
+
? `cannot complete terminal update ${protectedEntry.updateId} without an operator disposition`
|
|
3059
|
+
: `cannot complete queued update ${protectedEntry.updateId} without its exact owner receipt`);
|
|
3060
|
+
const removedUpdateIds = requestedEntries.map(entry => entry.updateId);
|
|
3061
|
+
const sourceCompletions = completions
|
|
3062
|
+
? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId) : current.file.sourceCompletions;
|
|
3063
|
+
if (sourceCompletions?.length)
|
|
3064
|
+
validateJournalSourceCompletions(sourceCompletions, path, version);
|
|
3065
|
+
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);
|
|
3066
|
+
return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
|
|
3067
|
+
...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
|
|
3068
|
+
});
|
|
3069
|
+
const completeQueued = (receipts, completionInput, exact = false) => runMutation((readCurrent) => {
|
|
3070
|
+
if (exact && !sourceAccess)
|
|
3071
|
+
throw createJournalError("invalid", path, "source completion requires an exact source handle");
|
|
3072
|
+
const completions = exact ? validateJournalSourceCompletions(completionInput, path, version) : undefined;
|
|
3073
|
+
if (!Array.isArray(receipts) || receipts.length === 0)
|
|
3074
|
+
throw createJournalError("invalid", path, "received no queued receipts to complete");
|
|
3075
|
+
const current = readCurrent();
|
|
3076
|
+
const receiptIds = new Set(), requestedUpdateIds = new Set();
|
|
3077
|
+
for (const receipt of receipts) {
|
|
3078
|
+
if ((receipt.queueKind !== "prompt" && receipt.queueKind !== "control") || !isNonEmptyString(receipt.receiptId) ||
|
|
3079
|
+
receiptIds.has(receipt.receiptId) || !Array.isArray(receipt.sourceUpdateIds) || receipt.sourceUpdateIds.length === 0) {
|
|
3080
|
+
throw createJournalError("invalid", path, "received an invalid queued completion receipt");
|
|
3081
|
+
}
|
|
3082
|
+
receiptIds.add(receipt.receiptId);
|
|
3083
|
+
const queueOwner = validateJournalQueueOwner(receipt.queueOwner, path);
|
|
3084
|
+
if (queueRuntimeIdentity && (queueOwner.instanceId !== queueRuntimeIdentity.instanceId || queueOwner.processId !== queueRuntimeIdentity.processId ||
|
|
3085
|
+
queueOwner.processBirthId !== queueRuntimeIdentity.processBirthId)) {
|
|
3086
|
+
throw createJournalError("conflict", path, `cannot complete foreign queue receipt ${receipt.receiptId}`);
|
|
3087
|
+
}
|
|
3088
|
+
const sourceUpdateIds = new Set();
|
|
3089
|
+
for (const updateId of receipt.sourceUpdateIds) {
|
|
3090
|
+
if (!isSafeNonNegativeInteger(updateId) || sourceUpdateIds.has(updateId) || requestedUpdateIds.has(updateId)) {
|
|
3091
|
+
throw createJournalError("invalid", path, "received overlapping queued completion update ids");
|
|
3092
|
+
}
|
|
3093
|
+
sourceUpdateIds.add(updateId);
|
|
3094
|
+
requestedUpdateIds.add(updateId);
|
|
3095
|
+
}
|
|
3096
|
+
const persistedReceiptEntries = current.file.entries.filter(entry => entry.queueReceiptId === receipt.receiptId);
|
|
3097
|
+
if (persistedReceiptEntries.length !== sourceUpdateIds.size || persistedReceiptEntries.some(entry => !sourceUpdateIds.has(entry.updateId) || entry.state !== "queued" || entry.queueKind !== receipt.queueKind || !entry.queueOwner ||
|
|
3098
|
+
entry.queueHandoff !== undefined || !areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, queueOwner))) {
|
|
3099
|
+
throw createJournalError("conflict", path, `cannot complete stale or foreign queue receipt ${receipt.receiptId}`);
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
const removedEntries = current.file.entries.filter(entry => requestedUpdateIds.has(entry.updateId));
|
|
3103
|
+
const removedUpdateIds = removedEntries.map(entry => entry.updateId);
|
|
3104
|
+
if (removedUpdateIds.length !== requestedUpdateIds.size)
|
|
3105
|
+
throw createJournalError("conflict", path, "queued completion did not resolve every source update");
|
|
3106
|
+
if (completions) {
|
|
3107
|
+
const sources = new Map(removedEntries.map(entry => [entry.updateId, createTelegramUpdateJournalEntryDigest(entry).sourceSha256]));
|
|
3108
|
+
if (completions.some(completion => sources.get(completion.updateId) !== completion.sourceSha256)) {
|
|
3109
|
+
throw createJournalError("conflict", path, "queued completion source changed or belongs to another receipt");
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
const sourceCompletions = completions ? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId)
|
|
3113
|
+
: current.file.sourceCompletions;
|
|
3114
|
+
if (sourceCompletions?.length)
|
|
3115
|
+
validateJournalSourceCompletions(sourceCompletions, path, version);
|
|
3116
|
+
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);
|
|
3117
|
+
return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
|
|
3118
|
+
...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
|
|
3119
|
+
});
|
|
3120
|
+
const mutateRoutingInputs = (input, select, publishedAtMs) => runMutation(readCurrent => {
|
|
3121
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version))
|
|
3122
|
+
throw createJournalError("unsupported-version", path, "routing input lifetime requires a private v1 source");
|
|
3123
|
+
const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
3124
|
+
if (input.journalBindingKey !== binding || !isSafePositiveInteger(input.operatorUserId) ||
|
|
3125
|
+
typeof input.isCurrent !== "function" || !Array.isArray(input.entries) || !input.entries.length || input.entries.length > maxEntries) {
|
|
3126
|
+
throw createJournalError("invalid", path, "invalid routing input lifetime authority");
|
|
3127
|
+
}
|
|
3128
|
+
const assertCurrent = () => { if (!input.isCurrent())
|
|
3129
|
+
throw createJournalError("conflict", path, "routing input lifetime authority ended"); };
|
|
3130
|
+
assertCurrent();
|
|
3131
|
+
const expected = input.entries.map(entry => validateJournalEntry(entry, path, version));
|
|
3132
|
+
if (new Set(expected.map(entry => entry.updateId)).size !== expected.length)
|
|
3133
|
+
throw createJournalError("invalid", path, "duplicate routing input sources");
|
|
3134
|
+
const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
|
|
3135
|
+
const nowMs = select ? getNowMs() : publishedAtMs;
|
|
3136
|
+
if (!isSafeNonNegativeInteger(nowMs) || !Number.isSafeInteger(nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS) || (!select && nowMs > getNowMs()))
|
|
3137
|
+
throw createJournalError("invalid", path, "invalid routing input lifetime clock");
|
|
3138
|
+
let issued = false;
|
|
3139
|
+
const changed = new Map();
|
|
3140
|
+
const result = expected.map(original => {
|
|
3141
|
+
const entry = current.file.entries.find(value => value.updateId === original.updateId);
|
|
3142
|
+
if (!entry || entry.state !== "pending" || original.state !== "pending" || entry.inputClaim || entry.inputProvenance || entry.queueOwner) {
|
|
3143
|
+
throw createJournalError("conflict", path, "routing input source is not unclaimed pending work");
|
|
3144
|
+
}
|
|
3145
|
+
const withoutLifetime = (value) => { const copy = cloneEntry(value); delete copy.routingInput; return copy; };
|
|
3146
|
+
if (!isDeepStrictEqual(entry, original) && (!isDeepStrictEqual(withoutLifetime(entry), withoutLifetime(original)) ||
|
|
3147
|
+
(original.routingInput && !isDeepStrictEqual(original.routingInput, entry.routingInput)))) {
|
|
3148
|
+
throw createJournalError("conflict", path, "routing input source changed");
|
|
3149
|
+
}
|
|
3150
|
+
const lifetime = entry.routingInput;
|
|
3151
|
+
if (lifetime && lifetime.operatorUserId !== input.operatorUserId)
|
|
3152
|
+
throw createJournalError("conflict", path, "routing input belongs to another operator");
|
|
3153
|
+
if (createPendingRetentionReference({ path, journalBindingKey: binding, entry, maxBytes }).read()) {
|
|
3154
|
+
throw createJournalError("conflict", path, "routing input has protected retention");
|
|
3155
|
+
}
|
|
3156
|
+
if (select) {
|
|
3157
|
+
if (!lifetime || (lifetime.phase === "waiting" && nowMs >= lifetime.expiresAtMs))
|
|
3158
|
+
throw createJournalError("conflict", path, "routing input choice expired or was not armed");
|
|
3159
|
+
if (lifetime.phase === "selected")
|
|
3160
|
+
return cloneEntry(entry);
|
|
3161
|
+
issued = true;
|
|
3162
|
+
const selected = { ...cloneEntry(entry), routingInput: { ...lifetime, phase: "selected" } };
|
|
3163
|
+
changed.set(entry.updateId, selected);
|
|
3164
|
+
return cloneEntry(selected);
|
|
3165
|
+
}
|
|
3166
|
+
if (lifetime) {
|
|
3167
|
+
if (lifetime.phase !== "waiting")
|
|
3168
|
+
throw createJournalError("conflict", path, "selected routing input cannot renew its choice");
|
|
3169
|
+
return cloneEntry(entry);
|
|
3170
|
+
}
|
|
3171
|
+
if (nowMs < entry.admittedAtMs)
|
|
3172
|
+
throw createJournalError("conflict", path, "routing input publication predates admission");
|
|
3173
|
+
issued = true;
|
|
3174
|
+
const armed = { ...cloneEntry(entry), routingInput: { operatorUserId: input.operatorUserId,
|
|
3175
|
+
publishedAtMs: nowMs, expiresAtMs: nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS, phase: "waiting" } };
|
|
3176
|
+
changed.set(entry.updateId, armed);
|
|
3177
|
+
return cloneEntry(armed);
|
|
3178
|
+
});
|
|
3179
|
+
assertCurrent();
|
|
3180
|
+
if (select && issued && result.some(entry => !changed.has(entry.updateId)))
|
|
3181
|
+
throw createJournalError("conflict", path, "routing input selection has mixed prior phases");
|
|
3182
|
+
if (changed.size)
|
|
3183
|
+
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(); });
|
|
3184
|
+
assertCurrent();
|
|
3185
|
+
return { issued, entries: result };
|
|
3186
|
+
});
|
|
2432
3187
|
const journal = {
|
|
2433
3188
|
read() {
|
|
2434
3189
|
return runMutation((readCurrent) => {
|
|
@@ -2467,9 +3222,12 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2467
3222
|
const current = readCurrent();
|
|
2468
3223
|
const entriesById = new Map(current.file.entries.map((entry) => [entry.updateId, entry]));
|
|
2469
3224
|
const previousAcceptedThroughUpdateId = current.file.acceptedThroughUpdateId;
|
|
2470
|
-
const discardedUpdateIds = new Set(
|
|
2471
|
-
|
|
2472
|
-
|
|
3225
|
+
const discardedUpdateIds = new Set([
|
|
3226
|
+
...(current.file.operatorDispositions ?? [])
|
|
3227
|
+
.filter((disposition) => disposition.action === "discard")
|
|
3228
|
+
.map((disposition) => disposition.updateId),
|
|
3229
|
+
...(current.file.sourceCompletions ?? []).map(completion => completion.updateId),
|
|
3230
|
+
]);
|
|
2473
3231
|
let admittedAtMs;
|
|
2474
3232
|
const addedUpdateIds = [];
|
|
2475
3233
|
const duplicateUpdateIds = [];
|
|
@@ -2719,6 +3477,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2719
3477
|
updateId: entry.updateId,
|
|
2720
3478
|
update: entry.update,
|
|
2721
3479
|
admittedAtMs: entry.admittedAtMs,
|
|
3480
|
+
...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
|
|
2722
3481
|
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
2723
3482
|
state: input.disposition,
|
|
2724
3483
|
failure,
|
|
@@ -2808,8 +3567,8 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
2808
3567
|
applyLegacyCustodyDisposition(authority) {
|
|
2809
3568
|
return runMutation((readCurrent) => {
|
|
2810
3569
|
const current = readCurrent();
|
|
2811
|
-
if (current.file.version
|
|
2812
|
-
throw createJournalError("conflict", path, "legacy custody disposition requires a
|
|
3570
|
+
if (!journalHasCustodyFields(current.file.version))
|
|
3571
|
+
throw createJournalError("conflict", path, "legacy custody disposition requires a custody journal");
|
|
2813
3572
|
const existing = current.file.operatorDispositions?.find(candidate => candidate.failureId === authority.dispositionId);
|
|
2814
3573
|
if (existing) {
|
|
2815
3574
|
if (!("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody")
|
|
@@ -3018,67 +3777,8 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
3018
3777
|
};
|
|
3019
3778
|
});
|
|
3020
3779
|
},
|
|
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
|
-
},
|
|
3780
|
+
completeQueued(receipts) { return completeQueued(receipts); },
|
|
3781
|
+
completeQueuedExact(receipts, completions) { return completeQueued(receipts, completions, true); },
|
|
3082
3782
|
discardQueued(input) {
|
|
3083
3783
|
return runMutation((readCurrent) => {
|
|
3084
3784
|
if ((input.queueKind !== "prompt" &&
|
|
@@ -3194,33 +3894,199 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
3194
3894
|
};
|
|
3195
3895
|
});
|
|
3196
3896
|
},
|
|
3197
|
-
|
|
3897
|
+
inspectAbandonedPending(updateId) {
|
|
3198
3898
|
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);
|
|
3899
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
3900
|
+
throw createJournalError("unsupported-version", path, "abandonment inspection requires the legacy v1 source");
|
|
3206
3901
|
}
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3902
|
+
if (!isSafeNonNegativeInteger(updateId))
|
|
3903
|
+
throw createJournalError("invalid", path, "invalid abandonment update id");
|
|
3904
|
+
// Inspection never repairs or quarantines source evidence.
|
|
3905
|
+
const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
|
|
3906
|
+
const matches = (current.file.operatorDispositions ?? []).filter(item => item.updateId === updateId);
|
|
3907
|
+
const disposition = matches[0];
|
|
3908
|
+
if (matches.length !== 1 || !disposition || !("dispositionKind" in disposition) ||
|
|
3909
|
+
disposition.dispositionKind !== "legacy-custody" || disposition.action !== "discard" ||
|
|
3910
|
+
disposition.failureId !== `abandon-${disposition.evidenceSha256}` ||
|
|
3911
|
+
current.file.entries.some(entry => entry.updateId === updateId))
|
|
3912
|
+
return undefined;
|
|
3913
|
+
const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
3914
|
+
const retainedPath = join(`${path}.retained`, `${disposition.failureId}.json`);
|
|
3915
|
+
let stat;
|
|
3916
|
+
try {
|
|
3917
|
+
stat = lstatSync(retainedPath);
|
|
3213
3918
|
}
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3919
|
+
catch (error) {
|
|
3920
|
+
if (error.code === "ENOENT")
|
|
3921
|
+
throw createJournalError("conflict", retainedPath, "committed abandonment lost its retained input");
|
|
3922
|
+
throw error;
|
|
3923
|
+
}
|
|
3924
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
|
|
3925
|
+
throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
|
|
3926
|
+
}
|
|
3927
|
+
const parsed = JSON.parse(readFileSync(retainedPath, "utf8"));
|
|
3928
|
+
if (!isRecord(parsed))
|
|
3929
|
+
throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
|
|
3930
|
+
const entry = validateJournalEntry(parsed.entry, retainedPath, version);
|
|
3931
|
+
const reference = createPendingRetentionReference({ path, journalBindingKey, entry, maxBytes });
|
|
3932
|
+
const retained = reference.read();
|
|
3933
|
+
if (entry.updateId !== updateId || reference.retainedPath !== retainedPath ||
|
|
3934
|
+
!isDeepStrictEqual(retained?.requestedDisposition, disposition)) {
|
|
3935
|
+
throw createJournalError("conflict", retainedPath, "retained input does not match committed abandonment");
|
|
3936
|
+
}
|
|
3937
|
+
return { journalBindingKey, updateId, retainedPath, operatorAuthorityId: disposition.operatorAuthorityId };
|
|
3938
|
+
});
|
|
3939
|
+
},
|
|
3940
|
+
inspectPendingRetention(entry) {
|
|
3941
|
+
return runMutation(() => {
|
|
3942
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
3943
|
+
throw createJournalError("unsupported-version", path, "pending retention requires the legacy v1 source");
|
|
3944
|
+
}
|
|
3945
|
+
const expected = validateJournalEntry(entry, path, version);
|
|
3946
|
+
if (expected.state !== "pending")
|
|
3947
|
+
throw createJournalError("conflict", path, "retention inspection requires a pending source");
|
|
3948
|
+
const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
3949
|
+
const reference = createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
|
|
3950
|
+
const retained = reference.read();
|
|
3951
|
+
return retained ? { journalBindingKey, retainedPath: reference.retainedPath,
|
|
3952
|
+
requestedDisposition: retained.requestedDisposition } : undefined;
|
|
3953
|
+
});
|
|
3954
|
+
},
|
|
3955
|
+
abandonPending(input) {
|
|
3956
|
+
return runMutation((readCurrent) => {
|
|
3957
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
3958
|
+
throw createJournalError("unsupported-version", path, "pending abandonment requires the legacy v1 source");
|
|
3959
|
+
}
|
|
3960
|
+
const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
3961
|
+
if (input.journalBindingKey !== journalBindingKey) {
|
|
3962
|
+
throw createJournalError("identity-mismatch", path, "pending abandonment belongs to another source");
|
|
3963
|
+
}
|
|
3964
|
+
if (!isBoundedString(input.operatorAuthorityId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
3965
|
+
!input.operatorAuthorityId.trim() || typeof input.isCurrent !== "function") {
|
|
3966
|
+
throw createJournalError("invalid", path, "pending abandonment requires explicit operator authority");
|
|
3967
|
+
}
|
|
3968
|
+
const assertCurrent = () => {
|
|
3969
|
+
if (!input.isCurrent())
|
|
3970
|
+
throw createJournalError("conflict", path, "pending abandonment lost authority");
|
|
3971
|
+
};
|
|
3972
|
+
assertCurrent();
|
|
3973
|
+
const expected = validateJournalEntry(input.entry, path, version);
|
|
3974
|
+
if (expected.state !== "pending" || expected.inputClaim || expected.inputProvenance ||
|
|
3975
|
+
expected.queueOwner || expected.queueHandoff || expected.queueReceiptId || expected.failure) {
|
|
3976
|
+
throw createJournalError("conflict", path, "only an exact unclaimed pending source may be abandoned");
|
|
3977
|
+
}
|
|
3978
|
+
// Never repair/quarantine source evidence while deciding an operator cancellation.
|
|
3979
|
+
const readStrict = sourceAccess ? readCurrent : () => readCurrentStrict(false);
|
|
3980
|
+
const current = readStrict();
|
|
3981
|
+
const { evidenceSha256, failureId, retainedPath, read: readRetention } = createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
|
|
3982
|
+
const existing = current.file.operatorDispositions?.find(item => item.failureId === failureId || item.updateId === expected.updateId);
|
|
3983
|
+
const currentEntry = current.file.entries.find(entry => entry.updateId === expected.updateId);
|
|
3984
|
+
const assertRetention = (retained, disposition) => {
|
|
3985
|
+
if (!retained || !isDeepStrictEqual(retained.requestedDisposition, disposition)) {
|
|
3986
|
+
throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
|
|
3987
|
+
}
|
|
3220
3988
|
};
|
|
3989
|
+
if (existing) {
|
|
3990
|
+
if (currentEntry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" ||
|
|
3991
|
+
existing.failureId !== failureId || existing.action !== "discard" || existing.evidenceSha256 !== evidenceSha256) {
|
|
3992
|
+
throw createJournalError("conflict", path, "pending source already has another disposition");
|
|
3993
|
+
}
|
|
3994
|
+
assertRetention(readRetention(), existing);
|
|
3995
|
+
assertCurrent();
|
|
3996
|
+
return { disposition: existing, retainedPath, duplicate: true,
|
|
3997
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
3998
|
+
}
|
|
3999
|
+
if (!currentEntry || !isDeepStrictEqual(currentEntry, expected)) {
|
|
4000
|
+
throw createJournalError("conflict", path, "pending source changed before abandonment");
|
|
4001
|
+
}
|
|
4002
|
+
const nowMs = getNowMs();
|
|
4003
|
+
if (!isSafeNonNegativeInteger(nowMs))
|
|
4004
|
+
throw createJournalError("invalid", path, "invalid abandonment timestamp");
|
|
4005
|
+
const retained = readRetention();
|
|
4006
|
+
let disposition;
|
|
4007
|
+
if (retained) {
|
|
4008
|
+
disposition = retained.requestedDisposition;
|
|
4009
|
+
}
|
|
4010
|
+
else {
|
|
4011
|
+
// Reuse the existing legacy discard tombstone so older v1 readers also refuse replay.
|
|
4012
|
+
// This is an operator disposition, not a fabricated execution failure or completion.
|
|
4013
|
+
disposition = { dispositionKind: "legacy-custody", failureId, updateId: expected.updateId,
|
|
4014
|
+
action: "discard", committedAtMs: nowMs, evidenceSha256,
|
|
4015
|
+
operatorAuthorityId: input.operatorAuthorityId, authorizedAtMs: nowMs };
|
|
4016
|
+
const copy = { version: 1, kind: "pending-input-retention",
|
|
4017
|
+
journalBindingKey, entry: expected, requestedDisposition: disposition };
|
|
4018
|
+
writeJournalFile(retainedPath, `${JSON.stringify(copy)}\n`, (boundary, target) => {
|
|
4019
|
+
onPublicationBoundary?.(boundary, target);
|
|
4020
|
+
assertCurrent();
|
|
4021
|
+
});
|
|
4022
|
+
}
|
|
4023
|
+
assertCurrent();
|
|
4024
|
+
assertRetention(readRetention(), disposition);
|
|
4025
|
+
const committed = (snapshot) => !snapshot.file.entries.some(entry => entry.updateId === expected.updateId) &&
|
|
4026
|
+
snapshot.file.operatorDispositions?.some(item => isDeepStrictEqual(item, disposition));
|
|
4027
|
+
try {
|
|
4028
|
+
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(); });
|
|
4029
|
+
return { disposition, retainedPath, duplicate: false,
|
|
4030
|
+
entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
4031
|
+
}
|
|
4032
|
+
catch (error) {
|
|
4033
|
+
// A segment may be durable even if later snapshot compaction failed.
|
|
4034
|
+
// Only exact read-back can turn that unknown acknowledgement into success.
|
|
4035
|
+
let observed;
|
|
4036
|
+
try {
|
|
4037
|
+
observed = readStrict();
|
|
4038
|
+
}
|
|
4039
|
+
catch {
|
|
4040
|
+
throw error;
|
|
4041
|
+
}
|
|
4042
|
+
if (!committed(observed))
|
|
4043
|
+
throw error;
|
|
4044
|
+
assertRetention(readRetention(), disposition);
|
|
4045
|
+
return { disposition, retainedPath, duplicate: false,
|
|
4046
|
+
entryCount: observed.file.entries.length, serializedBytes: observed.serializedBytes };
|
|
4047
|
+
}
|
|
4048
|
+
});
|
|
4049
|
+
},
|
|
4050
|
+
removeCompleted(updateIds) { return removeCompleted(updateIds); },
|
|
4051
|
+
removeCompletedExact(updateIds, expectedSources, completions) { return removeCompleted(updateIds, expectedSources, true, completions); },
|
|
4052
|
+
inspectQueuedReceipt(expected) {
|
|
4053
|
+
return runMutationCore(readCurrent => {
|
|
4054
|
+
if (!sourceAccess || !isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
4055
|
+
throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
|
|
4056
|
+
}
|
|
4057
|
+
const queueOwner = validateQueuedReceiptObservation(expected, path);
|
|
4058
|
+
const current = readCurrent();
|
|
4059
|
+
return inspectJournalQueuedReceipt(current.file, { ...expected, queueOwner }, path);
|
|
4060
|
+
});
|
|
4061
|
+
},
|
|
4062
|
+
inspectSourceCompletion(expected) {
|
|
4063
|
+
return runMutation(readCurrent => {
|
|
4064
|
+
if (!sourceAccess)
|
|
4065
|
+
throw createJournalError("invalid", path, "source completion requires an exact source handle");
|
|
4066
|
+
const completion = validateJournalSourceCompletion(expected, path);
|
|
4067
|
+
const current = readCurrent();
|
|
4068
|
+
return inspectJournalSourceCompletion(current.file, completion, path);
|
|
3221
4069
|
});
|
|
3222
4070
|
},
|
|
3223
4071
|
};
|
|
4072
|
+
const abandonRoutingPending = journal.abandonPending.bind(journal);
|
|
4073
|
+
if (isTelegramUpdateJournalLegacyFamilyVersion(version))
|
|
4074
|
+
journal.routingInputs = {
|
|
4075
|
+
arm(input) { return mutateRoutingInputs(input, false, input.publishedAtMs).entries; },
|
|
4076
|
+
select(input) { return mutateRoutingInputs(input, true); },
|
|
4077
|
+
expire(input) {
|
|
4078
|
+
const original = validateJournalEntry(input.entry, path, version), lifetime = original.routingInput;
|
|
4079
|
+
if (!lifetime || lifetime.phase !== "waiting" || lifetime.operatorUserId !== input.operatorUserId ||
|
|
4080
|
+
!isSafePositiveInteger(input.operatorUserId) || typeof input.isCurrent !== "function") {
|
|
4081
|
+
throw createJournalError("conflict", path, "routing input expiry lacks exact waiting authority");
|
|
4082
|
+
}
|
|
4083
|
+
const current = () => { const now = getNowMs(); return isSafeNonNegativeInteger(now) && now >= lifetime.expiresAtMs && input.isCurrent(); };
|
|
4084
|
+
if (!current())
|
|
4085
|
+
throw createJournalError("conflict", path, "routing input has not expired under current authority");
|
|
4086
|
+
return abandonRoutingPending({ journalBindingKey: input.journalBindingKey, entry: original,
|
|
4087
|
+
operatorAuthorityId: `telegram-owner:${input.operatorUserId}`, isCurrent: current });
|
|
4088
|
+
},
|
|
4089
|
+
};
|
|
3224
4090
|
if (!getInputContext)
|
|
3225
4091
|
return { journal };
|
|
3226
4092
|
// Strict source acquisition already requires this exact stored receipt scope.
|
|
@@ -3376,7 +4242,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
3376
4242
|
const evidence = readTelegramUpdateJournalSource({
|
|
3377
4243
|
directory: sourceAccess.directory, path, profile,
|
|
3378
4244
|
botIdentity: expectedIdentity, limits: sourceAccess.limits,
|
|
3379
|
-
version
|
|
4245
|
+
version,
|
|
3380
4246
|
});
|
|
3381
4247
|
return evidence.kind === "present"
|
|
3382
4248
|
? listTelegramUpdateJournalLegacyCustodyCandidates(evidence.file) : [];
|