@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
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
readFileSync,
|
|
23
23
|
readdirSync,
|
|
24
24
|
rmdirSync,
|
|
25
|
+
rmSync,
|
|
25
26
|
statSync,
|
|
26
27
|
unlinkSync,
|
|
27
28
|
writeFileSync,
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
type TelegramProcessLiveness,
|
|
40
41
|
} from "./bus.ts";
|
|
41
42
|
import {
|
|
43
|
+
decodeTelegramSessionDirectoryName,
|
|
42
44
|
getTelegramProfilePathSuffix,
|
|
43
45
|
TELEGRAM_DEFAULT_PROFILE_NAME,
|
|
44
46
|
} from "./paths.ts";
|
|
@@ -47,22 +49,28 @@ import {
|
|
|
47
49
|
type TelegramWorkspaceAdmissionLedger,
|
|
48
50
|
type TelegramWorkspaceAdmissionScope,
|
|
49
51
|
} from "./workspace-admission.ts";
|
|
52
|
+
import {
|
|
53
|
+
isWireRecord as isRecord,
|
|
54
|
+
hasOnlyWireKeys as hasOnlyKeys,
|
|
55
|
+
isNonNegativeWireInteger as isSafeNonNegativeInteger,
|
|
56
|
+
isNonEmptyWireString as isNonEmptyString,
|
|
57
|
+
} from "./wire.ts";
|
|
50
58
|
|
|
51
59
|
export const TELEGRAM_UPDATE_JOURNAL_VERSION = 1 as const;
|
|
52
60
|
export const TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION = 2 as const;
|
|
53
61
|
export const TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION = 3 as const;
|
|
54
62
|
// Receipt and binding identities survive storage-schema upgrades.
|
|
55
63
|
const TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION = 1 as const;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
const TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES = 10_000;
|
|
65
|
+
const TELEGRAM_UPDATE_JOURNAL_MAX_BYTES = 32 * 1024 * 1024;
|
|
66
|
+
const TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH = 128;
|
|
59
67
|
export const TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH = 256;
|
|
60
|
-
|
|
68
|
+
const TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH = 256;
|
|
61
69
|
const TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES = 4 * 1024;
|
|
62
70
|
const TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES = 64;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH = 128;
|
|
72
|
+
const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH = 32;
|
|
73
|
+
const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH = 256;
|
|
66
74
|
export const TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH = 128;
|
|
67
75
|
export const TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH = 512;
|
|
68
76
|
|
|
@@ -115,7 +123,78 @@ export function discoverTelegramFollowerJournalPaths(input: {
|
|
|
115
123
|
}
|
|
116
124
|
return { paths: Array.from(paths).sort(), complete };
|
|
117
125
|
}
|
|
118
|
-
|
|
126
|
+
|
|
127
|
+
/** Bounded read-only session-family discovery, not writer closure or deletion authority. */
|
|
128
|
+
export function discoverTelegramSessionJournalPaths(input: {
|
|
129
|
+
directory: string;
|
|
130
|
+
profileName?: string;
|
|
131
|
+
maxDirectoryEntries?: number;
|
|
132
|
+
}): TelegramFollowerJournalDiscovery {
|
|
133
|
+
const maxEntries = input.maxDirectoryEntries ?? 10_000;
|
|
134
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) return { paths: [], complete: false };
|
|
135
|
+
const root = join(input.directory, "sessions");
|
|
136
|
+
const suffix = getTelegramProfilePathSuffix(input.profileName);
|
|
137
|
+
const paths = new Set<string>();
|
|
138
|
+
let remaining = maxEntries;
|
|
139
|
+
let complete = true;
|
|
140
|
+
const scan = (directory: string): Dirent[] => {
|
|
141
|
+
const entries: Dirent[] = [];
|
|
142
|
+
const handle = opendirSync(directory);
|
|
143
|
+
try {
|
|
144
|
+
for (;;) {
|
|
145
|
+
const entry = handle.readSync();
|
|
146
|
+
if (!entry) break;
|
|
147
|
+
if (--remaining < 0) { complete = false; break; }
|
|
148
|
+
entries.push(entry);
|
|
149
|
+
}
|
|
150
|
+
} finally { handle.closeSync(); }
|
|
151
|
+
return entries;
|
|
152
|
+
};
|
|
153
|
+
try {
|
|
154
|
+
const stat = lstatSync(root);
|
|
155
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return { paths: [], complete: false };
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return { paths: [], complete: (error as NodeJS.ErrnoException).code === "ENOENT" };
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
for (const session of scan(root)) {
|
|
161
|
+
if (!complete) break;
|
|
162
|
+
if (!session.isDirectory() || decodeTelegramSessionDirectoryName(session.name) === undefined) {
|
|
163
|
+
complete = false; break;
|
|
164
|
+
}
|
|
165
|
+
const directory = join(root, session.name);
|
|
166
|
+
const sessionStat = lstatSync(directory);
|
|
167
|
+
if (!sessionStat.isDirectory() || sessionStat.isSymbolicLink()) { complete = false; break; }
|
|
168
|
+
for (const entry of scan(directory)) {
|
|
169
|
+
if (!complete) break;
|
|
170
|
+
// The leader polling journal and legacy recovery folders are not recipient custody.
|
|
171
|
+
if (entry.name === "recovery" || /^inbox(?:\.[a-zA-Z0-9._-]+)?\.json(?:\.segments|\.retained)?$/u.test(entry.name)) continue;
|
|
172
|
+
const match = /^journal\.([a-f0-9]{16})(\.[a-zA-Z0-9._-]+)?\.json(\.segments|\.retained)?$/u.exec(entry.name);
|
|
173
|
+
if (!match) { complete = false; break; }
|
|
174
|
+
const stat = lstatSync(join(directory, entry.name));
|
|
175
|
+
if (stat.isSymbolicLink() || (match[3] ? !stat.isDirectory() : !stat.isFile())) {
|
|
176
|
+
complete = false; break;
|
|
177
|
+
}
|
|
178
|
+
if ((match[2] ?? "") !== suffix) continue;
|
|
179
|
+
paths.add(join(directory, entry.name.replace(/\.(?:segments|retained)$/u, "")));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch { complete = false; }
|
|
183
|
+
return { paths: Array.from(paths).sort(), complete };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Transitional discovery covers both retained flat recipients and session-owned families. */
|
|
187
|
+
export function discoverTelegramRecipientJournalPaths(input: {
|
|
188
|
+
directory: string;
|
|
189
|
+
profileName?: string;
|
|
190
|
+
}): TelegramFollowerJournalDiscovery {
|
|
191
|
+
const legacy = discoverTelegramFollowerJournalPaths(input);
|
|
192
|
+
const sessions = discoverTelegramSessionJournalPaths(input);
|
|
193
|
+
return { paths: Array.from(new Set([...legacy.paths, ...sessions.paths])).sort(),
|
|
194
|
+
complete: legacy.complete && sessions.complete };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH = 256;
|
|
119
198
|
export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_COUNT = 256;
|
|
120
199
|
export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_BYTES = 4 * 1024 * 1024;
|
|
121
200
|
|
|
@@ -329,7 +408,7 @@ export interface TelegramUpdateJournalLegacyCustodyCandidate {
|
|
|
329
408
|
evidenceSha256: string;
|
|
330
409
|
}
|
|
331
410
|
|
|
332
|
-
|
|
411
|
+
function listTelegramUpdateJournalLegacyCustodyCandidates(
|
|
333
412
|
snapshot: Pick<TelegramUpdateJournalSnapshot, "entries">,
|
|
334
413
|
): TelegramUpdateJournalLegacyCustodyCandidate[] {
|
|
335
414
|
return snapshot.entries.flatMap(entry => {
|
|
@@ -409,12 +488,23 @@ export type TelegramUpdateJournalOperatorDisposition =
|
|
|
409
488
|
| TelegramUpdateJournalTerminalOperatorDisposition
|
|
410
489
|
| TelegramUpdateJournalLegacyCustodyDisposition;
|
|
411
490
|
|
|
491
|
+
export const TELEGRAM_ROUTING_INPUT_TTL_MS = 60 * 60 * 1000;
|
|
492
|
+
|
|
493
|
+
/** A source-only choice deadline, never a queue lease or a Thread deletion grant. */
|
|
494
|
+
export interface TelegramUpdateJournalRoutingInput {
|
|
495
|
+
operatorUserId: number;
|
|
496
|
+
publishedAtMs: number;
|
|
497
|
+
expiresAtMs: number;
|
|
498
|
+
phase: "waiting" | "selected";
|
|
499
|
+
}
|
|
500
|
+
|
|
412
501
|
export interface TelegramUpdateJournalEntry {
|
|
413
502
|
updateId: number;
|
|
414
503
|
update: TelegramJournaledUpdate;
|
|
415
504
|
/** Mandatory in v2/v3; immutable veto, never sender authorization. Absent only in legacy v1. */
|
|
416
505
|
preApprovalExcluded?: boolean;
|
|
417
506
|
admittedAtMs: number;
|
|
507
|
+
routingInput?: TelegramUpdateJournalRoutingInput;
|
|
418
508
|
state: TelegramUpdateJournalEntryState;
|
|
419
509
|
queueKind?: TelegramUpdateJournalQueueKind;
|
|
420
510
|
queueReceiptId?: string;
|
|
@@ -429,6 +519,19 @@ export interface TelegramUpdateJournalEntry {
|
|
|
429
519
|
terminalFailureId?: string;
|
|
430
520
|
}
|
|
431
521
|
|
|
522
|
+
/** Exact parsed journal entry identity, checked inside the source owner's mutation transaction. */
|
|
523
|
+
export interface TelegramUpdateJournalEntryDigest {
|
|
524
|
+
updateId: number;
|
|
525
|
+
sourceSha256: string;
|
|
526
|
+
}
|
|
527
|
+
/** Journal-owned removal ACK bound to an opaque, immutable caller acceptance scope. */
|
|
528
|
+
export interface TelegramUpdateJournalSourceCompletion extends TelegramUpdateJournalEntryDigest {
|
|
529
|
+
completionSha256: string;
|
|
530
|
+
}
|
|
531
|
+
export function createTelegramUpdateJournalEntryDigest(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEntryDigest {
|
|
532
|
+
return { updateId: entry.updateId, sourceSha256: createHash("sha256").update(JSON.stringify(entry)).digest("hex") };
|
|
533
|
+
}
|
|
534
|
+
|
|
432
535
|
export interface TelegramUpdateJournalFile {
|
|
433
536
|
version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION | typeof TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION | typeof TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
|
|
434
537
|
revision?: number;
|
|
@@ -437,6 +540,7 @@ export interface TelegramUpdateJournalFile {
|
|
|
437
540
|
botIdentity: TelegramUpdateJournalBotIdentity;
|
|
438
541
|
entries: TelegramUpdateJournalEntry[];
|
|
439
542
|
operatorDispositions?: TelegramUpdateJournalOperatorDisposition[];
|
|
543
|
+
sourceCompletions?: TelegramUpdateJournalSourceCompletion[];
|
|
440
544
|
}
|
|
441
545
|
|
|
442
546
|
export interface TelegramUpdateJournalSnapshot
|
|
@@ -455,6 +559,7 @@ export interface TelegramUpdateJournalAppendResult {
|
|
|
455
559
|
}
|
|
456
560
|
|
|
457
561
|
export interface TelegramUpdateJournalRemoveResult {
|
|
562
|
+
sourceCompletions?: TelegramUpdateJournalSourceCompletion[];
|
|
458
563
|
removedUpdateIds: number[];
|
|
459
564
|
entryCount: number;
|
|
460
565
|
serializedBytes: number;
|
|
@@ -475,6 +580,12 @@ export interface TelegramUpdateJournalQueueResult {
|
|
|
475
580
|
serializedBytes: number;
|
|
476
581
|
}
|
|
477
582
|
|
|
583
|
+
export interface TelegramUpdateJournalQueuedReceiptEvidence {
|
|
584
|
+
receipt: TelegramUpdateJournalQueuedCompletion;
|
|
585
|
+
sources: TelegramUpdateJournalEntryDigest[];
|
|
586
|
+
queueOwnerSha256: string;
|
|
587
|
+
}
|
|
588
|
+
|
|
478
589
|
export interface TelegramUpdateJournalQueuedCompletion {
|
|
479
590
|
queueKind: TelegramUpdateJournalQueueKind;
|
|
480
591
|
receiptId: string;
|
|
@@ -593,8 +704,69 @@ export interface TelegramUpdateJournalOperatorDispositionResult {
|
|
|
593
704
|
serializedBytes: number;
|
|
594
705
|
}
|
|
595
706
|
|
|
707
|
+
export interface TelegramUpdateJournalPendingAbandonmentInput {
|
|
708
|
+
journalBindingKey: string;
|
|
709
|
+
entry: TelegramUpdateJournalEntry;
|
|
710
|
+
operatorAuthorityId: string;
|
|
711
|
+
/** Caller proves owner authorization and excludes live chooser/worker dispatch through publication. */
|
|
712
|
+
isCurrent: () => boolean;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export interface TelegramUpdateJournalPendingAbandonmentResult {
|
|
716
|
+
disposition: TelegramUpdateJournalLegacyCustodyDisposition;
|
|
717
|
+
retainedPath: string;
|
|
718
|
+
duplicate: boolean;
|
|
719
|
+
entryCount: number;
|
|
720
|
+
serializedBytes: number;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Private evidence copy, not execution authority or proof that abandonment committed. */
|
|
724
|
+
export interface TelegramUpdateJournalRetainedInput {
|
|
725
|
+
version: 1;
|
|
726
|
+
kind: "pending-input-retention";
|
|
727
|
+
journalBindingKey: string;
|
|
728
|
+
entry: TelegramUpdateJournalEntry;
|
|
729
|
+
requestedDisposition: TelegramUpdateJournalLegacyCustodyDisposition;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export interface TelegramUpdateJournalPendingRetentionEvidence {
|
|
733
|
+
journalBindingKey: string;
|
|
734
|
+
retainedPath: string;
|
|
735
|
+
requestedDisposition: TelegramUpdateJournalLegacyCustodyDisposition;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Durable proof that an owner abandoned this exact pending source; never delivery or completion proof. */
|
|
739
|
+
export interface TelegramUpdateJournalAbandonedPendingEvidence {
|
|
740
|
+
journalBindingKey: string;
|
|
741
|
+
updateId: number;
|
|
742
|
+
retainedPath: string;
|
|
743
|
+
operatorAuthorityId: string;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export interface TelegramRoutingInputAuthority {
|
|
747
|
+
journalBindingKey: string;
|
|
748
|
+
entries: readonly TelegramUpdateJournalEntry[];
|
|
749
|
+
operatorUserId: number;
|
|
750
|
+
isCurrent(): boolean;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Package-private v1 capability; raw input custody does not expose it. */
|
|
754
|
+
export interface TelegramRoutingInputJournal {
|
|
755
|
+
arm(input: TelegramRoutingInputAuthority & { publishedAtMs: number }): TelegramUpdateJournalEntry[];
|
|
756
|
+
select(input: TelegramRoutingInputAuthority): { issued: boolean; entries: TelegramUpdateJournalEntry[] };
|
|
757
|
+
expire(input: Omit<TelegramRoutingInputAuthority, "entries"> & { entry: TelegramUpdateJournalEntry }): TelegramUpdateJournalPendingAbandonmentResult;
|
|
758
|
+
}
|
|
759
|
+
|
|
596
760
|
export interface TelegramUpdateJournalStore {
|
|
761
|
+
routingInputs?: TelegramRoutingInputJournal;
|
|
597
762
|
read(): TelegramUpdateJournalSnapshot;
|
|
763
|
+
/** Read-only: the committed abandonment tombstone plus its matching private retention, or undefined. */
|
|
764
|
+
inspectAbandonedPending(updateId: number): TelegramUpdateJournalAbandonedPendingEvidence | undefined;
|
|
765
|
+
/** Read-only protection evidence for this snapshot entry, never commit/execution authority. */
|
|
766
|
+
inspectPendingRetention(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalPendingRetentionEvidence | undefined;
|
|
767
|
+
abandonPending(
|
|
768
|
+
input: TelegramUpdateJournalPendingAbandonmentInput,
|
|
769
|
+
): TelegramUpdateJournalPendingAbandonmentResult;
|
|
598
770
|
appendBatch<TUpdate extends TelegramUpdateJournalInput>(
|
|
599
771
|
updates: readonly TUpdate[],
|
|
600
772
|
acceptedThroughUpdateId?: number,
|
|
@@ -623,6 +795,11 @@ export interface TelegramUpdateJournalStore {
|
|
|
623
795
|
completeQueued(
|
|
624
796
|
receipts: readonly TelegramUpdateJournalQueuedCompletion[],
|
|
625
797
|
): TelegramUpdateJournalRemoveResult;
|
|
798
|
+
/** Strict v1 whole-receipt disposal co-publishes caller-scoped ACKs; never a readiness grant. */
|
|
799
|
+
completeQueuedExact(
|
|
800
|
+
receipts: readonly TelegramUpdateJournalQueuedCompletion[],
|
|
801
|
+
completions: readonly TelegramUpdateJournalSourceCompletion[],
|
|
802
|
+
): TelegramUpdateJournalRemoveResult;
|
|
626
803
|
discardQueued(
|
|
627
804
|
input: TelegramUpdateJournalQueueDiscardInput,
|
|
628
805
|
): TelegramUpdateJournalQueueDiscardResult;
|
|
@@ -632,6 +809,15 @@ export interface TelegramUpdateJournalStore {
|
|
|
632
809
|
removeCompleted(
|
|
633
810
|
updateIds: readonly number[],
|
|
634
811
|
): TelegramUpdateJournalRemoveResult;
|
|
812
|
+
/** Guarded completion; every supplied source must still match, and absence is a conflict, not an ACK. */
|
|
813
|
+
removeCompletedExact(updateIds: readonly number[], expectedSources: readonly TelegramUpdateJournalEntryDigest[],
|
|
814
|
+
completions?: readonly TelegramUpdateJournalSourceCompletion[]): TelegramUpdateJournalRemoveResult;
|
|
815
|
+
/** Strict read-only observation; neither source absence nor a different scope is completion evidence. */
|
|
816
|
+
inspectSourceCompletion(expected: TelegramUpdateJournalSourceCompletion): TelegramUpdateJournalSourceCompletion | undefined;
|
|
817
|
+
/** Complete strict observation of a retained, unoffered v1 queue receipt; never acquires readiness. */
|
|
818
|
+
inspectQueuedReceipt(expected: TelegramUpdateJournalQueuedCompletion): TelegramUpdateJournalQueuedReceiptEvidence | undefined;
|
|
819
|
+
isQueueReceiptCurrent?: (receipt: Pick<TelegramUpdateJournalQueuedCompletion, "queueKind" | "receiptId" | "sourceUpdateIds"> &
|
|
820
|
+
{ journalBindingKey?: string }, owner: TelegramUpdateJournalQueueOwner) => boolean;
|
|
635
821
|
}
|
|
636
822
|
|
|
637
823
|
export type TelegramUpdateJournalPublicationBoundary =
|
|
@@ -642,7 +828,8 @@ export interface TelegramUpdateJournalRecoveryEvent {
|
|
|
642
828
|
kind: "repaired" | "reset";
|
|
643
829
|
path: string;
|
|
644
830
|
revision?: number;
|
|
645
|
-
|
|
831
|
+
/** Damaged snapshot/segment paths deleted or atomically replaced; nothing is retained for recovery. */
|
|
832
|
+
deletedPaths?: string[];
|
|
646
833
|
reason: string;
|
|
647
834
|
}
|
|
648
835
|
|
|
@@ -831,6 +1018,7 @@ interface JournalSourceAcquisition {
|
|
|
831
1018
|
evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily>;
|
|
832
1019
|
snapshotRevision: number;
|
|
833
1020
|
segments: { name: string; bytes: number; work: number; botId?: number }[];
|
|
1021
|
+
retainedInputs?: TelegramUpdateJournalRetentionInspection["retainedInputs"];
|
|
834
1022
|
}
|
|
835
1023
|
|
|
836
1024
|
export interface TelegramUpdateJournalSegment {
|
|
@@ -843,6 +1031,7 @@ export interface TelegramUpdateJournalSegment {
|
|
|
843
1031
|
upsertedEntries: TelegramUpdateJournalEntry[];
|
|
844
1032
|
removedUpdateIds: number[];
|
|
845
1033
|
operatorDispositions?: TelegramUpdateJournalOperatorDisposition[];
|
|
1034
|
+
sourceCompletions?: TelegramUpdateJournalSourceCompletion[];
|
|
846
1035
|
}
|
|
847
1036
|
|
|
848
1037
|
export interface TelegramUpdateJournalSegmentPublicationResult {
|
|
@@ -865,30 +1054,10 @@ function createJournalError(
|
|
|
865
1054
|
);
|
|
866
1055
|
}
|
|
867
1056
|
|
|
868
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
869
|
-
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
function hasOnlyKeys(
|
|
873
|
-
value: Record<string, unknown>,
|
|
874
|
-
allowedKeys: readonly string[],
|
|
875
|
-
): boolean {
|
|
876
|
-
const allowed = new Set(allowedKeys);
|
|
877
|
-
return Object.keys(value).every((key) => allowed.has(key));
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
function isSafeNonNegativeInteger(value: unknown): value is number {
|
|
881
|
-
return Number.isSafeInteger(value) && (value as number) >= 0;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
1057
|
function isSafePositiveInteger(value: unknown): value is number {
|
|
885
1058
|
return Number.isSafeInteger(value) && (value as number) > 0;
|
|
886
1059
|
}
|
|
887
1060
|
|
|
888
|
-
function isNonEmptyString(value: unknown): value is string {
|
|
889
|
-
return typeof value === "string" && value.length > 0;
|
|
890
|
-
}
|
|
891
|
-
|
|
892
1061
|
function isBoundedString(
|
|
893
1062
|
value: unknown,
|
|
894
1063
|
maxLength: number,
|
|
@@ -1255,8 +1424,16 @@ function validateJournalOperatorDisposition(
|
|
|
1255
1424
|
}
|
|
1256
1425
|
|
|
1257
1426
|
function journalRequiresExclusion(version: TelegramUpdateJournalFile["version"]): boolean {
|
|
1258
|
-
return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION ||
|
|
1259
|
-
|
|
1427
|
+
return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION || journalHasCustodyFields(version);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function journalHasCustodyFields(version: TelegramUpdateJournalFile["version"]): boolean {
|
|
1431
|
+
return version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/** Only legacy v1 carries routing-lifetime, scoped-ACK and pairing-free queue-receipt semantics. */
|
|
1435
|
+
export function isTelegramUpdateJournalLegacyFamilyVersion(version: unknown): boolean {
|
|
1436
|
+
return version === TELEGRAM_UPDATE_JOURNAL_VERSION;
|
|
1260
1437
|
}
|
|
1261
1438
|
|
|
1262
1439
|
function validateJournalInputClaim(
|
|
@@ -1328,6 +1505,7 @@ function validateJournalEntry(
|
|
|
1328
1505
|
"updateId",
|
|
1329
1506
|
"update",
|
|
1330
1507
|
"admittedAtMs",
|
|
1508
|
+
...(isTelegramUpdateJournalLegacyFamilyVersion(version) ? ["routingInput"] : []),
|
|
1331
1509
|
"state",
|
|
1332
1510
|
"queueKind",
|
|
1333
1511
|
"queueReceiptId",
|
|
@@ -1339,7 +1517,7 @@ function validateJournalEntry(
|
|
|
1339
1517
|
"terminalReason",
|
|
1340
1518
|
"terminalFailureId",
|
|
1341
1519
|
...(journalRequiresExclusion(version) ? ["preApprovalExcluded"] : []),
|
|
1342
|
-
...(version
|
|
1520
|
+
...(journalHasCustodyFields(version)
|
|
1343
1521
|
? ["inputClaim", "inputProvenance"] : []),
|
|
1344
1522
|
]) ||
|
|
1345
1523
|
!isSafeNonNegativeInteger(value.updateId) ||
|
|
@@ -1359,6 +1537,20 @@ function validateJournalEntry(
|
|
|
1359
1537
|
"contains an entry/update id mismatch",
|
|
1360
1538
|
);
|
|
1361
1539
|
}
|
|
1540
|
+
let routingInput: TelegramUpdateJournalRoutingInput | undefined;
|
|
1541
|
+
if (value.routingInput !== undefined) {
|
|
1542
|
+
const routing = value.routingInput;
|
|
1543
|
+
if (!isRecord(routing) || !hasOnlyKeys(routing, ["operatorUserId", "publishedAtMs", "expiresAtMs", "phase"]) ||
|
|
1544
|
+
!isSafePositiveInteger(routing.operatorUserId) || !isSafeNonNegativeInteger(routing.publishedAtMs) ||
|
|
1545
|
+
routing.publishedAtMs < value.admittedAtMs || !isSafeNonNegativeInteger(routing.expiresAtMs) ||
|
|
1546
|
+
routing.expiresAtMs !== routing.publishedAtMs + TELEGRAM_ROUTING_INPUT_TTL_MS ||
|
|
1547
|
+
(routing.phase !== "waiting" && routing.phase !== "selected") ||
|
|
1548
|
+
(routing.phase === "waiting" && value.state === "queued")) {
|
|
1549
|
+
throw createJournalError("invalid", path, "contains invalid routing input lifetime");
|
|
1550
|
+
}
|
|
1551
|
+
routingInput = { operatorUserId: routing.operatorUserId, publishedAtMs: routing.publishedAtMs,
|
|
1552
|
+
expiresAtMs: routing.expiresAtMs, phase: routing.phase };
|
|
1553
|
+
}
|
|
1362
1554
|
const queueKind = value.queueKind;
|
|
1363
1555
|
const queueReceiptId = value.queueReceiptId;
|
|
1364
1556
|
const hasQueueMetadata =
|
|
@@ -1474,6 +1666,7 @@ function validateJournalEntry(
|
|
|
1474
1666
|
...(inputClaim ? { inputClaim } : {}),
|
|
1475
1667
|
...(inputProvenance ? { inputProvenance } : {}),
|
|
1476
1668
|
admittedAtMs: value.admittedAtMs,
|
|
1669
|
+
...(routingInput ? { routingInput } : {}),
|
|
1477
1670
|
state: value.state,
|
|
1478
1671
|
...(queueKind === "prompt" || queueKind === "control"
|
|
1479
1672
|
? { queueKind }
|
|
@@ -1522,6 +1715,32 @@ function assertSupportedJournalVersion(
|
|
|
1522
1715
|
}
|
|
1523
1716
|
}
|
|
1524
1717
|
|
|
1718
|
+
function validateJournalSourceCompletion(value: unknown, path: string): TelegramUpdateJournalSourceCompletion {
|
|
1719
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["updateId", "sourceSha256", "completionSha256"]) ||
|
|
1720
|
+
!isSafeNonNegativeInteger(value.updateId) || typeof value.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.sourceSha256) ||
|
|
1721
|
+
typeof value.completionSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.completionSha256)) {
|
|
1722
|
+
throw createJournalError("invalid", path, "has invalid source completion evidence");
|
|
1723
|
+
}
|
|
1724
|
+
return { updateId: value.updateId, sourceSha256: value.sourceSha256, completionSha256: value.completionSha256 };
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function validateJournalSourceCompletions(value: unknown, path: string, version: TelegramUpdateJournalFile["version"],
|
|
1728
|
+
allowEmpty = false): TelegramUpdateJournalSourceCompletion[] {
|
|
1729
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version) || !Array.isArray(value) || !allowEmpty && !value.length) {
|
|
1730
|
+
throw createJournalError("invalid", path, "has invalid source completion collection");
|
|
1731
|
+
}
|
|
1732
|
+
const completions = value.map(item => validateJournalSourceCompletion(item, path));
|
|
1733
|
+
const scopes = new Set<string>();
|
|
1734
|
+
for (let index = 0; index < completions.length; index++) {
|
|
1735
|
+
const completion = completions[index]!;
|
|
1736
|
+
if ((index > 0 && completions[index - 1]!.updateId >= completion.updateId) || scopes.has(completion.completionSha256)) {
|
|
1737
|
+
throw createJournalError("invalid", path, "has duplicate or unordered source completion evidence");
|
|
1738
|
+
}
|
|
1739
|
+
scopes.add(completion.completionSha256);
|
|
1740
|
+
}
|
|
1741
|
+
return completions;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1525
1744
|
function parseJournalFile(
|
|
1526
1745
|
value: unknown,
|
|
1527
1746
|
path: string,
|
|
@@ -1542,6 +1761,7 @@ function parseJournalFile(
|
|
|
1542
1761
|
"botIdentity",
|
|
1543
1762
|
"entries",
|
|
1544
1763
|
"operatorDispositions",
|
|
1764
|
+
"sourceCompletions",
|
|
1545
1765
|
]) ||
|
|
1546
1766
|
!Number.isSafeInteger(value.version) ||
|
|
1547
1767
|
(value.revision !== undefined &&
|
|
@@ -1635,6 +1855,14 @@ function parseJournalFile(
|
|
|
1635
1855
|
).map((disposition) =>
|
|
1636
1856
|
validateJournalOperatorDisposition(disposition, path),
|
|
1637
1857
|
);
|
|
1858
|
+
const sourceCompletions = value.sourceCompletions === undefined ? [] : validateJournalSourceCompletions(value.sourceCompletions, path, version);
|
|
1859
|
+
const botIdentity = validateBotIdentity(value.botIdentity, path);
|
|
1860
|
+
const activeIds = new Set(entries.map(entry => entry.updateId));
|
|
1861
|
+
const discardedIds = new Set(operatorDispositions.filter(disposition => disposition.action === "discard").map(disposition => disposition.updateId));
|
|
1862
|
+
if (sourceCompletions.some(completion => activeIds.has(completion.updateId) || discardedIds.has(completion.updateId) ||
|
|
1863
|
+
value.acceptedThroughUpdateId !== undefined && completion.updateId > (value.acceptedThroughUpdateId as number))) {
|
|
1864
|
+
throw createJournalError("invalid", path, "has contradictory source completion evidence");
|
|
1865
|
+
}
|
|
1638
1866
|
const dispositionFailureIds = new Set<string>();
|
|
1639
1867
|
const entriesByUpdateId = new Map(
|
|
1640
1868
|
entries.map((entry) => [entry.updateId, entry]),
|
|
@@ -1669,12 +1897,62 @@ function parseJournalFile(
|
|
|
1669
1897
|
? { acceptedThroughUpdateId: value.acceptedThroughUpdateId as number }
|
|
1670
1898
|
: {}),
|
|
1671
1899
|
profile: value.profile,
|
|
1672
|
-
botIdentity
|
|
1900
|
+
botIdentity,
|
|
1673
1901
|
entries,
|
|
1674
1902
|
...(operatorDispositions.length > 0 ? { operatorDispositions } : {}),
|
|
1903
|
+
...(sourceCompletions.length > 0 ? { sourceCompletions } : {}),
|
|
1675
1904
|
};
|
|
1676
1905
|
}
|
|
1677
1906
|
|
|
1907
|
+
function assertJournalRoutingInputContinuity(file: TelegramUpdateJournalFile, entries: readonly TelegramUpdateJournalEntry[], path: string): void {
|
|
1908
|
+
const previous = new Map(file.entries.map(entry => [entry.updateId, entry.routingInput]));
|
|
1909
|
+
for (const entry of entries) {
|
|
1910
|
+
const retained = previous.get(entry.updateId), next = entry.routingInput;
|
|
1911
|
+
if (retained && (!next || next.operatorUserId !== retained.operatorUserId || next.publishedAtMs !== retained.publishedAtMs ||
|
|
1912
|
+
next.expiresAtMs !== retained.expiresAtMs || (retained.phase === "selected" && next.phase !== "selected"))) {
|
|
1913
|
+
throw createJournalError("invalid", path, "regresses retained routing input lifetime");
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
function assertJournalSourceCompletionContinuity(file: TelegramUpdateJournalFile, segment: TelegramUpdateJournalSegment, path: string): void {
|
|
1919
|
+
assertJournalRoutingInputContinuity(file, segment.upsertedEntries, path);
|
|
1920
|
+
if (segment.sourceCompletions === undefined) return;
|
|
1921
|
+
const previous = new Map((file.sourceCompletions ?? []).map(completion => [completion.updateId, completion]));
|
|
1922
|
+
const next = new Map(segment.sourceCompletions.map(completion => [completion.updateId, completion]));
|
|
1923
|
+
if ([...previous].some(([id, completion]) => !isDeepStrictEqual(completion, next.get(id)))) {
|
|
1924
|
+
throw createJournalError("invalid", path, "regresses retained source completion evidence");
|
|
1925
|
+
}
|
|
1926
|
+
const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
|
|
1927
|
+
const removed = new Set(segment.removedUpdateIds), upserted = new Set(segment.upsertedEntries.map(entry => entry.updateId));
|
|
1928
|
+
const upsertedReceipts = new Set(segment.upsertedEntries.map(entry => entry.queueReceiptId));
|
|
1929
|
+
// Grouped markers share one whole-receipt check rather than rescanning the group per source.
|
|
1930
|
+
const queueGroups = new Map<string, TelegramUpdateJournalEntry[]>(), queueRemovals = new Map<string, boolean>();
|
|
1931
|
+
for (const entry of file.entries) {
|
|
1932
|
+
if (!entry.queueReceiptId) continue;
|
|
1933
|
+
const group = queueGroups.get(entry.queueReceiptId) ?? [];
|
|
1934
|
+
group.push(entry); queueGroups.set(entry.queueReceiptId, group);
|
|
1935
|
+
}
|
|
1936
|
+
const queueWasRemoved = (source: TelegramUpdateJournalEntry): boolean => {
|
|
1937
|
+
const receiptId = source.queueReceiptId;
|
|
1938
|
+
if (!receiptId || !source.queueOwner) return false;
|
|
1939
|
+
if (queueRemovals.has(receiptId)) return queueRemovals.get(receiptId)!;
|
|
1940
|
+
const group = queueGroups.get(receiptId);
|
|
1941
|
+
const valid = !!group?.length && !upsertedReceipts.has(receiptId) && group.every(entry =>
|
|
1942
|
+
removed.has(entry.updateId) && !upserted.has(entry.updateId) && entry.state === "queued" && entry.queueKind === source.queueKind &&
|
|
1943
|
+
entry.queueHandoff === undefined && !!entry.queueOwner && areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, source.queueOwner!));
|
|
1944
|
+
queueRemovals.set(receiptId, valid); return valid;
|
|
1945
|
+
};
|
|
1946
|
+
for (const completion of segment.sourceCompletions) {
|
|
1947
|
+
if (previous.has(completion.updateId)) continue;
|
|
1948
|
+
const source = entries.get(completion.updateId);
|
|
1949
|
+
if (!removed.has(completion.updateId) || !source || (source.state === "queued" && !queueWasRemoved(source)) || source.state === "failed" ||
|
|
1950
|
+
createTelegramUpdateJournalEntryDigest(source).sourceSha256 !== completion.sourceSha256) {
|
|
1951
|
+
throw createJournalError("invalid", path, "source completion does not match its removal revision");
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1678
1956
|
function parseJournalSegment(
|
|
1679
1957
|
value: unknown,
|
|
1680
1958
|
path: string,
|
|
@@ -1697,6 +1975,7 @@ function parseJournalSegment(
|
|
|
1697
1975
|
"upsertedEntries",
|
|
1698
1976
|
"removedUpdateIds",
|
|
1699
1977
|
"operatorDispositions",
|
|
1978
|
+
"sourceCompletions",
|
|
1700
1979
|
]) ||
|
|
1701
1980
|
value.version !== version ||
|
|
1702
1981
|
!isSafePositiveInteger(value.revision) ||
|
|
@@ -1751,6 +2030,7 @@ function parseJournalSegment(
|
|
|
1751
2030
|
).map((disposition) =>
|
|
1752
2031
|
validateJournalOperatorDisposition(disposition, path),
|
|
1753
2032
|
);
|
|
2033
|
+
const segmentBotIdentity = validateBotIdentity(value.botIdentity, path);
|
|
1754
2034
|
return {
|
|
1755
2035
|
version,
|
|
1756
2036
|
revision: value.revision,
|
|
@@ -1759,12 +2039,13 @@ function parseJournalSegment(
|
|
|
1759
2039
|
? { acceptedThroughUpdateId: value.acceptedThroughUpdateId as number }
|
|
1760
2040
|
: {}),
|
|
1761
2041
|
profile: value.profile,
|
|
1762
|
-
botIdentity:
|
|
2042
|
+
botIdentity: segmentBotIdentity,
|
|
1763
2043
|
upsertedEntries,
|
|
1764
2044
|
removedUpdateIds,
|
|
1765
2045
|
...(value.operatorDispositions !== undefined
|
|
1766
2046
|
? { operatorDispositions }
|
|
1767
2047
|
: {}),
|
|
2048
|
+
...(value.sourceCompletions !== undefined ? { sourceCompletions: validateJournalSourceCompletions(value.sourceCompletions, path, version, true) } : {}),
|
|
1768
2049
|
};
|
|
1769
2050
|
}
|
|
1770
2051
|
|
|
@@ -1807,6 +2088,19 @@ export function inspectTelegramUpdateJournalFamily(input: {
|
|
|
1807
2088
|
return acquireTelegramUpdateJournalFamily(input).evidence;
|
|
1808
2089
|
}
|
|
1809
2090
|
|
|
2091
|
+
export interface TelegramUpdateJournalRetentionInspection {
|
|
2092
|
+
evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily>;
|
|
2093
|
+
retainedInputs: { path: string; journalBindingKey: string; failureId: string; updateId: number; state: "committed" | "uncommitted" }[];
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
/** Classifies private originals against exact discard tombstones; never replay or deletion authority. */
|
|
2097
|
+
export function inspectTelegramUpdateJournalRetention(
|
|
2098
|
+
input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0],
|
|
2099
|
+
): TelegramUpdateJournalRetentionInspection {
|
|
2100
|
+
const acquired = acquireTelegramUpdateJournalFamily(input, undefined, true);
|
|
2101
|
+
return { evidence: acquired.evidence, retainedInputs: acquired.retainedInputs ?? [] };
|
|
2102
|
+
}
|
|
2103
|
+
|
|
1810
2104
|
/**
|
|
1811
2105
|
* Read-only source evidence for cooperating writers serialized by the caller through
|
|
1812
2106
|
* consumption; never readiness, recovery, or permission to publish. Ancestors retain
|
|
@@ -1821,7 +2115,7 @@ export function readTelegramUpdateJournalSource(
|
|
|
1821
2115
|
input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0] & { version: TelegramUpdateJournalFile["version"] },
|
|
1822
2116
|
): ReturnType<typeof inspectTelegramUpdateJournalFamily> {
|
|
1823
2117
|
const version = input.version;
|
|
1824
|
-
if (version !== 1 && version !== 2 && version !== 3) {
|
|
2118
|
+
if (version !== 1 && version !== 2 && version !== 3 && version !== 4) {
|
|
1825
2119
|
throw createJournalError("unsupported-version", input.path, "requires a selected source version");
|
|
1826
2120
|
}
|
|
1827
2121
|
return acquireTelegramUpdateJournalFamily({
|
|
@@ -1834,6 +2128,7 @@ export function readTelegramUpdateJournalSource(
|
|
|
1834
2128
|
function acquireTelegramUpdateJournalFamily(
|
|
1835
2129
|
input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0],
|
|
1836
2130
|
selectedVersion?: TelegramUpdateJournalFile["version"],
|
|
2131
|
+
inspectRetention = false,
|
|
1837
2132
|
): JournalSourceAcquisition {
|
|
1838
2133
|
const { path, limits } = input;
|
|
1839
2134
|
const acquiredSegments: JournalSourceAcquisition["segments"] = [];
|
|
@@ -1878,7 +2173,7 @@ function acquireTelegramUpdateJournalFamily(
|
|
|
1878
2173
|
};
|
|
1879
2174
|
const charge = (value: unknown) => {
|
|
1880
2175
|
if (!isRecord(value)) return;
|
|
1881
|
-
for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions"]) {
|
|
2176
|
+
for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions", "sourceCompletions"]) {
|
|
1882
2177
|
const collection = value[key];
|
|
1883
2178
|
if (!Array.isArray(collection)) continue;
|
|
1884
2179
|
if (collection.length > limits.maxEntries || collection.length > limits.maxWork - work) capacity();
|
|
@@ -1910,15 +2205,15 @@ function acquireTelegramUpdateJournalFamily(
|
|
|
1910
2205
|
} finally { closeSync(fd); }
|
|
1911
2206
|
};
|
|
1912
2207
|
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
1913
|
-
const enumerate = (): string[] => {
|
|
2208
|
+
const enumerate = (target = segmentDirectory, pattern = /^\d{16}\.json$/u, usedFiles = files): string[] => {
|
|
1914
2209
|
const names: string[] = [];
|
|
1915
|
-
const directory = opendirSync(
|
|
2210
|
+
const directory = opendirSync(target, { bufferSize: 1 });
|
|
1916
2211
|
try {
|
|
1917
2212
|
for (;;) {
|
|
1918
2213
|
const entry = directory.readSync();
|
|
1919
2214
|
if (!entry) break;
|
|
1920
|
-
if (names.length >= limits.maxFiles -
|
|
1921
|
-
if (
|
|
2215
|
+
if (names.length >= limits.maxFiles - usedFiles) capacity();
|
|
2216
|
+
if (!pattern.test(entry.name) || !entry.isFile() || entry.isSymbolicLink()) fail("has an unexpected journal evidence entry");
|
|
1922
2217
|
names.push(entry.name);
|
|
1923
2218
|
}
|
|
1924
2219
|
} finally { directory.closeSync(); }
|
|
@@ -1976,6 +2271,7 @@ function acquireTelegramUpdateJournalFamily(
|
|
|
1976
2271
|
if (segment.revision !== revision + 1 || segment.previousRevision !== revision) fail("has a newer revision gap");
|
|
1977
2272
|
if (segment.acceptedThroughUpdateId !== undefined && file.acceptedThroughUpdateId !== undefined &&
|
|
1978
2273
|
segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId) fail("regresses the admission cursor");
|
|
2274
|
+
assertJournalSourceCompletionContinuity(file, segment, target);
|
|
1979
2275
|
const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
|
|
1980
2276
|
for (const id of segment.removedUpdateIds) entries.delete(id);
|
|
1981
2277
|
for (const entry of segment.upsertedEntries) {
|
|
@@ -1993,15 +2289,63 @@ function acquireTelegramUpdateJournalFamily(
|
|
|
1993
2289
|
...(segment.acceptedThroughUpdateId !== undefined ? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId } : {}),
|
|
1994
2290
|
entries: [...entries.values()].sort((a, b) => a.updateId - b.updateId),
|
|
1995
2291
|
operatorDispositions: segment.operatorDispositions ?? file.operatorDispositions,
|
|
2292
|
+
sourceCompletions: segment.sourceCompletions ?? file.sourceCompletions,
|
|
1996
2293
|
};
|
|
1997
2294
|
charge(next);
|
|
1998
2295
|
file = parseJournalFile(next, target, file.version);
|
|
1999
2296
|
}
|
|
2000
2297
|
}
|
|
2298
|
+
const retainedInputs: TelegramUpdateJournalRetentionInspection["retainedInputs"] = [];
|
|
2299
|
+
const retentionDirectory = `${path}.retained`;
|
|
2300
|
+
const retention = inspectRetention ? observe(retentionDirectory, true, true) : undefined;
|
|
2301
|
+
const retentionPattern = /^abandon-[a-f0-9]{64}\.json$/u;
|
|
2302
|
+
const retentionNames = retention ? enumerate(retentionDirectory, retentionPattern) : [];
|
|
2303
|
+
if (retentionNames.length > limits.maxEntries) capacity();
|
|
2304
|
+
if (retention) {
|
|
2305
|
+
if (!file || file.version !== 1) fail("private retention requires its exact v1 journal snapshot");
|
|
2306
|
+
const current = file!;
|
|
2307
|
+
const bindingKeys = new Set([expected, current.botIdentity].map(botIdentity =>
|
|
2308
|
+
createTelegramUpdateJournalBindingKey({ path, profileName: input.profile, botIdentity })));
|
|
2309
|
+
const updateIds = new Set<number>();
|
|
2310
|
+
for (const name of retentionNames) {
|
|
2311
|
+
const target = join(retentionDirectory, name);
|
|
2312
|
+
const before = observe(target, false)!;
|
|
2313
|
+
if (before.nlink !== 1n) fail("has linked private retention evidence");
|
|
2314
|
+
const raw = read(target, before);
|
|
2315
|
+
files += 1;
|
|
2316
|
+
if (limits.maxWork - work < 2) capacity();
|
|
2317
|
+
work += 2;
|
|
2318
|
+
if (!isRecord(raw)) throw createJournalError("invalid", target, "private retention is not an evidence record");
|
|
2319
|
+
if (typeof raw.journalBindingKey !== "string" || !bindingKeys.has(raw.journalBindingKey))
|
|
2320
|
+
throw createJournalError("invalid", target, "has foreign private retention evidence");
|
|
2321
|
+
const entry = validateJournalEntry(raw.entry, target, 1);
|
|
2322
|
+
const disposition = validateJournalOperatorDisposition(raw.requestedDisposition, target);
|
|
2323
|
+
const digest = createHash("sha256").update(JSON.stringify({ journalBindingKey: raw.journalBindingKey, entry })).digest("hex");
|
|
2324
|
+
if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
|
|
2325
|
+
disposition.action !== "discard" || entry.state !== "pending" || disposition.updateId !== entry.updateId ||
|
|
2326
|
+
disposition.evidenceSha256 !== digest || disposition.failureId !== `abandon-${digest}` ||
|
|
2327
|
+
name !== `${disposition.failureId}.json` || !isDeepStrictEqual(raw, { version: 1, kind: "pending-input-retention",
|
|
2328
|
+
journalBindingKey: raw.journalBindingKey, entry, requestedDisposition: disposition })) fail("has inconsistent private retention evidence");
|
|
2329
|
+
if (updateIds.has(entry.updateId)) fail("has duplicate private retention sources");
|
|
2330
|
+
updateIds.add(entry.updateId);
|
|
2331
|
+
const committed = current.operatorDispositions?.find(value => value.failureId === disposition.failureId || value.updateId === entry.updateId);
|
|
2332
|
+
if (committed && (!isDeepStrictEqual(committed, disposition) || current.entries.some(value => value.updateId === entry.updateId)))
|
|
2333
|
+
fail("private retention contradicts its journal disposition");
|
|
2334
|
+
retainedInputs.push({ path: target, journalBindingKey: raw.journalBindingKey,
|
|
2335
|
+
failureId: disposition.failureId, updateId: entry.updateId, state: committed ? "committed" : "uncommitted" });
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
if (inspectRetention && file?.operatorDispositions?.some(disposition =>
|
|
2339
|
+
"dispositionKind" in disposition && disposition.dispositionKind === "legacy-custody" &&
|
|
2340
|
+
disposition.action === "discard" && /^abandon-[a-f0-9]{64}$/u.test(disposition.failureId) &&
|
|
2341
|
+
!retainedInputs.some(original => original.updateId === disposition.updateId && original.state === "committed")))
|
|
2342
|
+
fail("committed abandonment lost its private original");
|
|
2001
2343
|
// Re-enumeration has the same bound, rather than allocating an unbounded census.
|
|
2002
2344
|
files = snapshot ? 1 : 0;
|
|
2003
2345
|
if (segments && !isDeepStrictEqual(names, enumerate())) fail("changed segment namespace");
|
|
2004
2346
|
files += names.length;
|
|
2347
|
+
if (retention && !isDeepStrictEqual(retentionNames, enumerate(retentionDirectory, retentionPattern))) fail("changed private retention namespace");
|
|
2348
|
+
files += retentionNames.length;
|
|
2005
2349
|
for (const [target, before] of observed) {
|
|
2006
2350
|
const after = status(target);
|
|
2007
2351
|
const compare = selectedVersion !== undefined && ancestors.has(target) ? sameAncestor : same;
|
|
@@ -2023,6 +2367,7 @@ function acquireTelegramUpdateJournalFamily(
|
|
|
2023
2367
|
evidence: file ? { kind: "present", file, ...(knownBotId !== undefined ? { knownBotId } : {}), accounting: { files, bytes, work } } : { kind: "absent" },
|
|
2024
2368
|
snapshotRevision,
|
|
2025
2369
|
segments: acquiredSegments,
|
|
2370
|
+
...(inspectRetention ? { retainedInputs } : {}),
|
|
2026
2371
|
};
|
|
2027
2372
|
} catch (error) {
|
|
2028
2373
|
if (error instanceof TelegramUpdateJournalError) throw error;
|
|
@@ -2036,16 +2381,35 @@ function acquireTelegramUpdateJournalFamily(
|
|
|
2036
2381
|
* Recensus detects observable changes, not hostile same-user path swaps; arbitrary
|
|
2037
2382
|
* consumer references and archive consumption require separate audits.
|
|
2038
2383
|
*/
|
|
2039
|
-
export
|
|
2384
|
+
export interface TelegramJournalNamespaceInspectionInput {
|
|
2040
2385
|
directory: string;
|
|
2041
2386
|
profile: string;
|
|
2042
2387
|
botIdentity: TelegramUpdateJournalBotIdentity;
|
|
2043
2388
|
limits: { maxDirectoryEntries: number; maxFiles: number; maxBytes: number; maxEntries: number; maxWork: number };
|
|
2044
|
-
|
|
2045
|
-
|
|
2389
|
+
/** Leader polling journal named by owners.json; defaults to the flat root `inbox`. */
|
|
2390
|
+
pollingPath?: string;
|
|
2391
|
+
/** Hold a participating consumer reference during each physical family read. */
|
|
2392
|
+
withSourceReference?: <T>(path: string, operation: () => T) => T;
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
export interface TelegramJournalNamespaceInspection {
|
|
2396
|
+
/** Original preservation facts only, never replay/deletion grants. */
|
|
2397
|
+
retainedInputs?: (TelegramUpdateJournalRetentionInspection["retainedInputs"][number] & { journalPath: string })[];
|
|
2398
|
+
sources: { role: "polling" | "follower" | "session"; path: string; evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily> }[];
|
|
2046
2399
|
accounting: { directoryEntries: number; files: number; bytes: number; work: number };
|
|
2047
2400
|
knownBotId?: number;
|
|
2048
|
-
}
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
export function inspectTelegramProfileJournalNamespace(input: TelegramJournalNamespaceInspectionInput): TelegramJournalNamespaceInspection {
|
|
2404
|
+
return inspectTelegramJournalNamespace(input, false);
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
/** Read-only evidence over polling, retained flat recipients and role-neutral session journals. */
|
|
2408
|
+
export function inspectTelegramSessionJournalNamespace(input: TelegramJournalNamespaceInspectionInput): TelegramJournalNamespaceInspection {
|
|
2409
|
+
return inspectTelegramJournalNamespace(input, true);
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
function inspectTelegramJournalNamespace(input: TelegramJournalNamespaceInspectionInput, includeSessions: boolean): TelegramJournalNamespaceInspection {
|
|
2049
2413
|
const { directory, profile, limits } = input;
|
|
2050
2414
|
const fail = (message: string): never => { throw createJournalError("invalid", directory, message); };
|
|
2051
2415
|
const capacity = (): never => { throw createJournalError("capacity", directory, "exceeds namespace inspection resource limits"); };
|
|
@@ -2062,46 +2426,99 @@ export function inspectTelegramProfileJournalNamespace(input: {
|
|
|
2062
2426
|
size: value.size, mtimeNs: value.mtimeNs, ctimeNs: value.ctimeNs };
|
|
2063
2427
|
};
|
|
2064
2428
|
const suffix = profile === "default" ? "" : `.${profile}`;
|
|
2065
|
-
const
|
|
2429
|
+
const pollingPath = input.pollingPath ?? join(directory, `inbox${suffix}.json`);
|
|
2430
|
+
const polling = relative(directory, pollingPath);
|
|
2431
|
+
{
|
|
2432
|
+
const parts = polling.split(sep);
|
|
2433
|
+
if (!isAbsolute(pollingPath) || resolve(pollingPath) !== pollingPath || parts.at(-1) !== `inbox${suffix}.json` ||
|
|
2434
|
+
!(parts.length === 1 || (parts.length === 3 && parts[0] === "sessions" &&
|
|
2435
|
+
decodeTelegramSessionDirectoryName(parts[1]!) !== undefined))) fail("requires a canonical polling journal path");
|
|
2436
|
+
}
|
|
2066
2437
|
try {
|
|
2067
2438
|
const root = lstatSync(directory);
|
|
2068
2439
|
if (!root.isDirectory() || root.isSymbolicLink() || realpathSync(directory) !== directory) fail("requires a canonical directory anchor");
|
|
2069
2440
|
const beforeRoot = metadata(directory);
|
|
2070
2441
|
const census = () => {
|
|
2071
2442
|
const entries = new Map<string, ReturnType<typeof metadata>>();
|
|
2072
|
-
const
|
|
2073
|
-
const
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2443
|
+
const families = new Map<string, "follower" | "session">(), profiles = new Set<string>();
|
|
2444
|
+
const scan = (parent: string, visit: (name: string, target: string) => void): void => {
|
|
2445
|
+
const handle = opendirSync(parent, { bufferSize: 1 });
|
|
2446
|
+
try {
|
|
2447
|
+
for (;;) {
|
|
2448
|
+
const entry = handle.readSync();
|
|
2449
|
+
if (!entry) break;
|
|
2450
|
+
if (entries.size >= limits.maxDirectoryEntries) capacity();
|
|
2451
|
+
const target = join(parent, entry.name);
|
|
2452
|
+
const key = relative(directory, target);
|
|
2453
|
+
if (entries.has(key)) fail("repeated an observed namespace entry");
|
|
2454
|
+
entries.set(key, metadata(target));
|
|
2455
|
+
visit(entry.name, target);
|
|
2456
|
+
}
|
|
2457
|
+
} finally { handle.closeSync(); }
|
|
2458
|
+
};
|
|
2459
|
+
const requireType = (target: string, isDirectory: boolean): void => {
|
|
2460
|
+
const value = lstatSync(target);
|
|
2461
|
+
if (value.isSymbolicLink() || !(isDirectory ? value.isDirectory() : value.isFile()) ||
|
|
2462
|
+
(!isDirectory && value.nlink !== 1)) fail("has a linked or unexpected journal type");
|
|
2463
|
+
};
|
|
2464
|
+
const evidenceDirectory = (target: string, retained: boolean): void => scan(target, (name, path) => {
|
|
2465
|
+
if (!(retained ? /^abandon-[a-f0-9]{64}\.json$/u : /^\d{16}\.json$/u).test(name)) fail("has an unclassified journal evidence entry");
|
|
2466
|
+
requireType(path, false);
|
|
2467
|
+
});
|
|
2468
|
+
const session = (target: string): void => scan(target, (name, path) => {
|
|
2469
|
+
if (name === "recovery") return;
|
|
2470
|
+
const match = /^(?:journal\.[a-f0-9]{16}|inbox)(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
|
|
2471
|
+
if (!match || match[1] === "default") fail("has an unclassified session journal entry");
|
|
2472
|
+
requireType(path, !!match![2]);
|
|
2473
|
+
if (match![2]) evidenceDirectory(path, match![2] === ".retained");
|
|
2474
|
+
const base = path.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match![1] ?? "default";
|
|
2475
|
+
profiles.add(familyProfile);
|
|
2476
|
+
if (familyProfile === profile && base !== pollingPath) families.set(base, "session");
|
|
2477
|
+
});
|
|
2478
|
+
scan(directory, (name, target) => {
|
|
2479
|
+
// Legacy quarantine folders are disposable housekeeping, never journal authority.
|
|
2480
|
+
if (name.toLowerCase() === "recovery") return;
|
|
2481
|
+
if (name.toLowerCase() === "sessions") {
|
|
2482
|
+
if (!includeSessions) fail("requires session-qualified namespace inspection");
|
|
2483
|
+
if (name !== "sessions") fail("has a noncanonical session root");
|
|
2484
|
+
requireType(target, true);
|
|
2485
|
+
scan(target, (sessionName, sessionPath) => {
|
|
2486
|
+
if (decodeTelegramSessionDirectoryName(sessionName) === undefined) fail("has a noncanonical session directory");
|
|
2487
|
+
requireType(sessionPath, true);
|
|
2488
|
+
session(sessionPath);
|
|
2489
|
+
});
|
|
2490
|
+
return;
|
|
2089
2491
|
}
|
|
2090
|
-
|
|
2091
|
-
|
|
2492
|
+
if (includeSessions && /^journal\./iu.test(name)) fail("has a misplaced session journal");
|
|
2493
|
+
if (!/inbox/iu.test(name)) return;
|
|
2494
|
+
const match = /^(inbox|follower-inbox-[a-f0-9]{16})(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
|
|
2495
|
+
if (!match || match[2] === "default" || (!includeSessions && match[3] === ".retained")) fail("has a noncanonical journal-like entry");
|
|
2496
|
+
requireType(target, !!match![3]);
|
|
2497
|
+
if (includeSessions && match![3]) evidenceDirectory(target, match![3] === ".retained");
|
|
2498
|
+
const base = target.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match![2] ?? "default";
|
|
2499
|
+
profiles.add(familyProfile);
|
|
2500
|
+
if (familyProfile === profile && base !== pollingPath)
|
|
2501
|
+
families.set(base, match![1] === "inbox" ? "session" : "follower");
|
|
2502
|
+
});
|
|
2503
|
+
return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0),
|
|
2504
|
+
families: [...families].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), profiles: [...profiles].sort() };
|
|
2092
2505
|
};
|
|
2093
2506
|
const before = census();
|
|
2094
|
-
const
|
|
2507
|
+
const sourceInventory = [[pollingPath, "polling"], ...before.families] as const;
|
|
2508
|
+
const sources: TelegramJournalNamespaceInspection["sources"] = [];
|
|
2509
|
+
const retainedInputs: NonNullable<TelegramJournalNamespaceInspection["retainedInputs"]> = [];
|
|
2095
2510
|
const accounting = { directoryEntries: before.entries.length, files: 0, bytes: 0, work: 0 };
|
|
2096
2511
|
let knownBotId = expected.botId;
|
|
2097
|
-
for (const
|
|
2512
|
+
for (const [path, role] of sourceInventory) {
|
|
2098
2513
|
const remaining = { maxFiles: limits.maxFiles - accounting.files, maxBytes: limits.maxBytes - accounting.bytes,
|
|
2099
2514
|
maxWork: limits.maxWork - accounting.work, maxEntries: limits.maxEntries };
|
|
2100
2515
|
if (remaining.maxFiles <= 0 || remaining.maxBytes <= 0 || remaining.maxWork <= 0) capacity();
|
|
2101
|
-
const
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2516
|
+
const acquire = () => acquireTelegramUpdateJournalFamily({ directory, path, profile,
|
|
2517
|
+
botIdentity: { ...expected, ...(knownBotId !== undefined ? { botId: knownBotId } : {}) }, limits: remaining }, undefined, includeSessions);
|
|
2518
|
+
const acquired = input.withSourceReference ? input.withSourceReference(path, acquire) : acquire();
|
|
2519
|
+
const evidence = acquired.evidence;
|
|
2520
|
+
retainedInputs.push(...(acquired.retainedInputs ?? []).map(original => ({ ...original, journalPath: path })));
|
|
2521
|
+
if (evidence.kind === "absent" && (role !== "polling" || before.entries.some(([entry]) => entry === polling || entry === `${polling}.segments`))) {
|
|
2105
2522
|
fail("lost a discovered journal family");
|
|
2106
2523
|
}
|
|
2107
2524
|
if (evidence.kind === "present") {
|
|
@@ -2110,16 +2527,46 @@ export function inspectTelegramProfileJournalNamespace(input: {
|
|
|
2110
2527
|
accounting.bytes += evidence.accounting.bytes;
|
|
2111
2528
|
}
|
|
2112
2529
|
accounting.work += evidence.kind === "present" ? Math.max(1, evidence.accounting.work) : 1;
|
|
2113
|
-
sources.push({ role
|
|
2530
|
+
sources.push({ role, path, evidence });
|
|
2114
2531
|
}
|
|
2115
|
-
|
|
2116
|
-
|
|
2532
|
+
const namespaceCurrent = (): boolean => {
|
|
2533
|
+
try { return isDeepStrictEqual(beforeRoot, metadata(directory)) && isDeepStrictEqual(before, census()) &&
|
|
2534
|
+
isDeepStrictEqual(beforeRoot, metadata(directory)); }
|
|
2535
|
+
catch { return false; }
|
|
2536
|
+
};
|
|
2537
|
+
if (!namespaceCurrent()) fail("changed observed root namespace or identity");
|
|
2538
|
+
return { sources, accounting, ...(includeSessions ? { retainedInputs } : {}), ...(knownBotId !== undefined ? { knownBotId } : {}) };
|
|
2117
2539
|
} catch (error) {
|
|
2118
2540
|
if (error instanceof TelegramUpdateJournalError) throw error;
|
|
2119
2541
|
throw createJournalError("io", directory, "could not establish canonical namespace evidence", error);
|
|
2120
2542
|
}
|
|
2121
2543
|
}
|
|
2122
2544
|
|
|
2545
|
+
/** Complete-empty protection only; caller holds source serialization through consumption. Never deletion authority. */
|
|
2546
|
+
export function isTelegramThreadCleanupJournalNamespaceClear(input: TelegramJournalNamespaceInspectionInput & {
|
|
2547
|
+
requiredJournalBindingKeys: readonly string[];
|
|
2548
|
+
withSourceReference: NonNullable<TelegramJournalNamespaceInspectionInput["withSourceReference"]>;
|
|
2549
|
+
}): boolean {
|
|
2550
|
+
if (!input.requiredJournalBindingKeys.length || input.requiredJournalBindingKeys.length > input.limits.maxDirectoryEntries)
|
|
2551
|
+
throw new Error("Telegram temporary cleanup requires bounded exact journal references.");
|
|
2552
|
+
const requiredPaths = new Set<string>();
|
|
2553
|
+
for (const key of input.requiredJournalBindingKeys) {
|
|
2554
|
+
const path = getTelegramUpdateJournalBindingPath(key);
|
|
2555
|
+
const identities = [input.botIdentity, { tokenSha256: input.botIdentity.tokenSha256 }];
|
|
2556
|
+
if (!path || !identities.some(botIdentity => key === createTelegramUpdateJournalBindingKey({
|
|
2557
|
+
path, profileName: input.profile, botIdentity,
|
|
2558
|
+
}))) throw new Error("Telegram temporary cleanup has foreign or malformed journal references.");
|
|
2559
|
+
requiredPaths.add(path);
|
|
2560
|
+
}
|
|
2561
|
+
const namespace = inspectTelegramSessionJournalNamespace(input);
|
|
2562
|
+
if (namespace.sources.filter(source => source.role === "polling").length !== 1 ||
|
|
2563
|
+
namespace.retainedInputs?.some(original => original.state !== "committed") ||
|
|
2564
|
+
[...requiredPaths].some(path => !namespace.sources.some(source => source.path === path && source.evidence.kind === "present")))
|
|
2565
|
+
return false;
|
|
2566
|
+
// Original Telegram targets do not certify execution ownership. No nonempty family gets a source exemption.
|
|
2567
|
+
return namespace.sources.every(source => source.evidence.kind === "absent" || source.evidence.file.entries.length === 0);
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2123
2570
|
function cloneEntry(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEntry {
|
|
2124
2571
|
return {
|
|
2125
2572
|
...entry,
|
|
@@ -2134,6 +2581,7 @@ function cloneEntry(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEnt
|
|
|
2134
2581
|
? { queueHandoff: cloneJournalQueueHandoff(entry.queueHandoff) }
|
|
2135
2582
|
: {}),
|
|
2136
2583
|
...(entry.failure ? { failure: { ...entry.failure } } : {}),
|
|
2584
|
+
...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
|
|
2137
2585
|
};
|
|
2138
2586
|
}
|
|
2139
2587
|
|
|
@@ -2147,6 +2595,7 @@ function cloneFile(file: TelegramUpdateJournalFile): TelegramUpdateJournalFile {
|
|
|
2147
2595
|
profile: file.profile,
|
|
2148
2596
|
botIdentity: { ...file.botIdentity },
|
|
2149
2597
|
entries: file.entries.map(cloneEntry),
|
|
2598
|
+
...(file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions.map(completion => ({ ...completion })) } : {}),
|
|
2150
2599
|
...(file.operatorDispositions?.length
|
|
2151
2600
|
? {
|
|
2152
2601
|
operatorDispositions: file.operatorDispositions.map(
|
|
@@ -2288,6 +2737,46 @@ function mergeBotIdentity(
|
|
|
2288
2737
|
};
|
|
2289
2738
|
}
|
|
2290
2739
|
|
|
2740
|
+
function createPendingRetentionReference(input: {
|
|
2741
|
+
path: string;
|
|
2742
|
+
journalBindingKey: string;
|
|
2743
|
+
entry: TelegramUpdateJournalEntry;
|
|
2744
|
+
maxBytes: number;
|
|
2745
|
+
}) {
|
|
2746
|
+
const { path, journalBindingKey, entry, maxBytes } = input;
|
|
2747
|
+
const evidenceSha256 = createHash("sha256").update(JSON.stringify({ journalBindingKey, entry })).digest("hex");
|
|
2748
|
+
const failureId = `abandon-${evidenceSha256}`;
|
|
2749
|
+
const retainedDirectory = `${path}.retained`;
|
|
2750
|
+
const retainedPath = join(retainedDirectory, `${failureId}.json`);
|
|
2751
|
+
const read = (): TelegramUpdateJournalRetainedInput | undefined => {
|
|
2752
|
+
let directory;
|
|
2753
|
+
try { directory = lstatSync(retainedDirectory); }
|
|
2754
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
|
|
2755
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) {
|
|
2756
|
+
throw createJournalError("invalid", retainedDirectory, "retention directory is not a regular directory");
|
|
2757
|
+
}
|
|
2758
|
+
let stat;
|
|
2759
|
+
try { stat = lstatSync(retainedPath); }
|
|
2760
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
|
|
2761
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
|
|
2762
|
+
throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
|
|
2763
|
+
}
|
|
2764
|
+
const retained: unknown = JSON.parse(readFileSync(retainedPath, "utf8"));
|
|
2765
|
+
if (!isRecord(retained)) throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
|
|
2766
|
+
const disposition = validateJournalOperatorDisposition(retained.requestedDisposition, retainedPath);
|
|
2767
|
+
if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
|
|
2768
|
+
disposition.failureId !== failureId || disposition.updateId !== entry.updateId ||
|
|
2769
|
+
disposition.action !== "discard" || disposition.evidenceSha256 !== evidenceSha256 ||
|
|
2770
|
+
!isDeepStrictEqual(retained, { version: 1, kind: "pending-input-retention", journalBindingKey,
|
|
2771
|
+
entry, requestedDisposition: disposition })) {
|
|
2772
|
+
throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
|
|
2773
|
+
}
|
|
2774
|
+
return { version: 1, kind: "pending-input-retention", journalBindingKey, entry,
|
|
2775
|
+
requestedDisposition: disposition };
|
|
2776
|
+
};
|
|
2777
|
+
return { evidenceSha256, failureId, retainedPath, read };
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2291
2780
|
function writeJournalFile(
|
|
2292
2781
|
path: string,
|
|
2293
2782
|
serialized: string,
|
|
@@ -2428,7 +2917,7 @@ export interface TelegramUpdateJournalRuntimeBinding {
|
|
|
2428
2917
|
runtimeKey: string;
|
|
2429
2918
|
recoveryKey: string;
|
|
2430
2919
|
journal: TelegramUpdateJournalStore;
|
|
2431
|
-
readForProtection?: () => { entries: readonly TelegramUpdateJournalEntry[] };
|
|
2920
|
+
readForProtection?: () => { entries: readonly TelegramUpdateJournalEntry[]; exists: boolean };
|
|
2432
2921
|
}
|
|
2433
2922
|
|
|
2434
2923
|
export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
|
|
@@ -2438,11 +2927,14 @@ export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
|
|
|
2438
2927
|
getJournalPath: (profileName?: string) => string;
|
|
2439
2928
|
getQueueRuntimeIdentity?: () => TelegramUpdateJournalQueueRuntimeIdentity;
|
|
2440
2929
|
withWriterAdmission?: <T>(operation: () => T) => T;
|
|
2930
|
+
withSourceSerialization?: TelegramUpdateJournalStoreOptions["withSourceSerialization"];
|
|
2441
2931
|
getWorkspaceAdmission?: () => Pick<
|
|
2442
2932
|
TelegramWorkspaceAdmissionLedger,
|
|
2443
2933
|
"acquireAdmission" | "releaseAdmission"
|
|
2444
2934
|
> | undefined;
|
|
2445
2935
|
onRecovery?: (event: TelegramUpdateJournalRecoveryEvent) => void;
|
|
2936
|
+
/** Strict no-follow source handles; defaults to platform support (absent on Windows). */
|
|
2937
|
+
strictSourceAccess?: boolean;
|
|
2446
2938
|
}
|
|
2447
2939
|
|
|
2448
2940
|
export function createTelegramUpdateJournalRuntimeBindingResolver(
|
|
@@ -2460,6 +2952,26 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
|
|
|
2460
2952
|
});
|
|
2461
2953
|
const path = deps.getJournalPath(configuredProfileName);
|
|
2462
2954
|
const workspaceAdmission = deps.getWorkspaceAdmission?.();
|
|
2955
|
+
const sourceAccess = { directory: dirname(path), limits: {
|
|
2956
|
+
maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
|
|
2957
|
+
maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
|
|
2958
|
+
} };
|
|
2959
|
+
const options: TelegramUpdateJournalStoreOptions = { path, profileName, botIdentity,
|
|
2960
|
+
...(deps.getQueueRuntimeIdentity ? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() } : {}),
|
|
2961
|
+
...(workspaceAdmission ? { workspaceAdmission } : {}),
|
|
2962
|
+
...(deps.withWriterAdmission ? { withWriterAdmission: deps.withWriterAdmission } : {}),
|
|
2963
|
+
...(deps.withSourceSerialization ? { withSourceSerialization: deps.withSourceSerialization } : {}),
|
|
2964
|
+
...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}) };
|
|
2965
|
+
const journal = createTelegramUpdateJournalStore(options);
|
|
2966
|
+
// Scoped completion and receipt observation use strict private handles where the platform provides no-follow
|
|
2967
|
+
// evidence; elsewhere (Windows) the same capabilities read through the ordinary journal so queue receipts still work.
|
|
2968
|
+
const strict = deps.strictSourceAccess ?? Boolean(constants.O_NOFOLLOW && constants.O_NONBLOCK);
|
|
2969
|
+
const completionJournal = deps.withSourceSerialization
|
|
2970
|
+
? createTelegramUpdateJournalStore({ ...options, ...(strict ? { sourceAccess } : {}) }) : undefined;
|
|
2971
|
+
// Queue receipt readiness gates every ordinary prompt; without strict handles it uses the ordinary journal read.
|
|
2972
|
+
const observeQueuedReceipt = (expected: TelegramUpdateJournalQueuedCompletion) => strict
|
|
2973
|
+
? completionJournal!.inspectQueuedReceipt(expected)
|
|
2974
|
+
: inspectJournalQueuedReceipt(journal.read(), { ...expected, queueOwner: validateQueuedReceiptObservation(expected, path) }, path);
|
|
2463
2975
|
return {
|
|
2464
2976
|
runtimeKey: JSON.stringify({
|
|
2465
2977
|
path,
|
|
@@ -2474,27 +2986,24 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
|
|
|
2474
2986
|
readForProtection() {
|
|
2475
2987
|
// Protection must never turn corruption/recovery into empty-work evidence.
|
|
2476
2988
|
const evidence = inspectTelegramUpdateJournalFamily({
|
|
2477
|
-
|
|
2478
|
-
limits: {
|
|
2479
|
-
maxFiles: 1024,
|
|
2480
|
-
maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
|
|
2481
|
-
maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES,
|
|
2482
|
-
maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
|
|
2483
|
-
},
|
|
2989
|
+
...sourceAccess, path, profile: profileName, botIdentity,
|
|
2484
2990
|
});
|
|
2485
|
-
return { entries: evidence.kind === "present" ? evidence.file.entries : [] };
|
|
2991
|
+
return { entries: evidence.kind === "present" ? evidence.file.entries : [], exists: evidence.kind === "present" };
|
|
2486
2992
|
},
|
|
2487
|
-
journal:
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2993
|
+
journal: completionJournal ? { ...journal,
|
|
2994
|
+
removeCompletedExact(updateIds, expectedSources, completions) {
|
|
2995
|
+
return completions !== undefined ? completionJournal.removeCompletedExact(updateIds, expectedSources, completions)
|
|
2996
|
+
: journal.removeCompletedExact(updateIds, expectedSources);
|
|
2997
|
+
},
|
|
2998
|
+
completeQueuedExact(receipts, completions) { return completionJournal.completeQueuedExact(receipts, completions); },
|
|
2999
|
+
inspectSourceCompletion(expected) { return completionJournal.inspectSourceCompletion(expected); },
|
|
3000
|
+
inspectQueuedReceipt: observeQueuedReceipt,
|
|
3001
|
+
isQueueReceiptCurrent(receipt, owner) {
|
|
3002
|
+
if (receipt.journalBindingKey !== createTelegramUpdateJournalBindingKey({ path, profileName, botIdentity })) return false;
|
|
3003
|
+
return observeQueuedReceipt({ queueKind: receipt.queueKind, receiptId: receipt.receiptId,
|
|
3004
|
+
sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...owner } }) !== undefined;
|
|
3005
|
+
},
|
|
3006
|
+
} : journal,
|
|
2498
3007
|
};
|
|
2499
3008
|
};
|
|
2500
3009
|
}
|
|
@@ -2560,13 +3069,82 @@ export function withTelegramResolvedUpdateJournalReference<T>(input: {
|
|
|
2560
3069
|
recoveryKey: binding.recoveryKey }, () => input.operation(binding));
|
|
2561
3070
|
}
|
|
2562
3071
|
|
|
3072
|
+
function validateQueuedReceiptObservation(expected: TelegramUpdateJournalQueuedCompletion, path: string): TelegramUpdateJournalQueueOwner {
|
|
3073
|
+
if (!isRecord(expected) || !hasOnlyKeys(expected, ["queueKind", "receiptId", "sourceUpdateIds", "queueOwner"]) || (expected.queueKind !== "prompt" && expected.queueKind !== "control") ||
|
|
3074
|
+
!isNonEmptyString(expected.receiptId) ||
|
|
3075
|
+
!Array.isArray(expected.sourceUpdateIds) || expected.sourceUpdateIds.length === 0 ||
|
|
3076
|
+
expected.sourceUpdateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
|
|
3077
|
+
expected.sourceUpdateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= expected.sourceUpdateIds[index - 1]!))) {
|
|
3078
|
+
throw createJournalError("invalid", path, "received invalid queue receipt observation authority");
|
|
3079
|
+
}
|
|
3080
|
+
return validateJournalQueueOwner(expected.queueOwner, path);
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
function inspectJournalQueuedReceipt(file: TelegramUpdateJournalFile, expected: TelegramUpdateJournalQueuedCompletion,
|
|
3084
|
+
path: string): TelegramUpdateJournalQueuedReceiptEvidence | undefined {
|
|
3085
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version)) throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
|
|
3086
|
+
const { queueOwner } = expected;
|
|
3087
|
+
const entries = file.entries.filter(entry => entry.queueReceiptId === expected.receiptId);
|
|
3088
|
+
if (entries.length !== expected.sourceUpdateIds.length || entries.some((entry, index) =>
|
|
3089
|
+
entry.updateId !== expected.sourceUpdateIds[index] || entry.state !== "queued" || entry.queueKind !== expected.queueKind ||
|
|
3090
|
+
entry.queueHandoff !== undefined || !isDeepStrictEqual(entry.queueOwner, queueOwner))) return undefined;
|
|
3091
|
+
return { receipt: { queueKind: expected.queueKind, receiptId: expected.receiptId,
|
|
3092
|
+
sourceUpdateIds: [...expected.sourceUpdateIds], queueOwner: { ...queueOwner } },
|
|
3093
|
+
sources: entries.map(createTelegramUpdateJournalEntryDigest),
|
|
3094
|
+
queueOwnerSha256: createHash("sha256").update(JSON.stringify(queueOwner)).digest("hex") };
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
function inspectJournalSourceCompletion(file: TelegramUpdateJournalFile, completion: TelegramUpdateJournalSourceCompletion,
|
|
3098
|
+
path: string): TelegramUpdateJournalSourceCompletion | undefined {
|
|
3099
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version)) throw createJournalError("invalid", path, "source completion requires a legacy v1 source");
|
|
3100
|
+
const retained = file.sourceCompletions?.find(value => value.updateId === completion.updateId);
|
|
3101
|
+
if (!retained) return undefined;
|
|
3102
|
+
if (!isDeepStrictEqual(retained, completion)) throw createJournalError("conflict", path, "source completion belongs to another exact scope");
|
|
3103
|
+
return { ...retained };
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
/** Operator-distinct authority: routing never treats it as an owner cancellation. */
|
|
3107
|
+
export const TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX = "session-successor:";
|
|
3108
|
+
|
|
3109
|
+
export interface TelegramSessionPendingAdoptionInput {
|
|
3110
|
+
recipientBindingKey: string;
|
|
3111
|
+
predecessorSessionId: string;
|
|
3112
|
+
successorSessionId: string;
|
|
3113
|
+
/** Caller proves the successor registration is current and no predecessor worker runs. */
|
|
3114
|
+
isCurrent: () => boolean;
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
export interface TelegramSessionPendingAdoptionResult {
|
|
3118
|
+
adoptedUpdateIds: number[];
|
|
3119
|
+
/** Already committed away by an earlier attempt; never re-appended, the private original remains. */
|
|
3120
|
+
retainedUpdateIds: number[];
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
function isAdoptablePendingEntry(entry: TelegramUpdateJournalEntry): boolean {
|
|
3124
|
+
return entry.state === "pending" && !entry.inputClaim && !entry.inputProvenance && !entry.routingInput &&
|
|
3125
|
+
!entry.queueOwner && !entry.queueHandoff && !entry.queueReceiptId && !entry.queueKind && !entry.failure &&
|
|
3126
|
+
entry.preApprovalExcluded !== true;
|
|
3127
|
+
}
|
|
3128
|
+
|
|
2563
3129
|
export interface TelegramUpdateJournalBindingRuntime {
|
|
2564
3130
|
resolveLeader: () => TelegramUpdateJournalRuntimeBinding | undefined;
|
|
2565
3131
|
resolveFollower: () => TelegramUpdateJournalRuntimeBinding | undefined;
|
|
2566
3132
|
resolveActive: () => TelegramUpdateJournalRuntimeBinding | undefined;
|
|
2567
3133
|
getActiveRecoveryKey: () => string | undefined;
|
|
3134
|
+
/** Exact historical proof lookup; no store, admission, execution, recovery or mutation port. */
|
|
3135
|
+
inspectSourceAbandonment: (journalBindingKey: string, updateId: number) => TelegramUpdateJournalAbandonedPendingEvidence | undefined;
|
|
3136
|
+
inspectSourceCompletion: (journalBindingKey: string, expected: TelegramUpdateJournalSourceCompletion) => TelegramUpdateJournalSourceCompletion | undefined;
|
|
3137
|
+
inspectQueuedReceipt: (journalBindingKey: string, expected: TelegramUpdateJournalQueuedCompletion) => TelegramUpdateJournalQueuedReceiptEvidence | undefined;
|
|
3138
|
+
/** In-process `/new` succession: move unclaimed predecessor pending inputs into the successor session journal. */
|
|
3139
|
+
adoptPredecessorPending: (input: TelegramSessionPendingAdoptionInput) => TelegramSessionPendingAdoptionResult;
|
|
3140
|
+
/** Process-local: adopts from the previously prepared session of the same recipient key, then records the active one. */
|
|
3141
|
+
prepareActiveFollowerSuccession: (isCurrent: () => boolean) => TelegramSessionPendingAdoptionResult | undefined;
|
|
2568
3142
|
createRecipientResolver: (
|
|
2569
3143
|
recipientBindingKey: string,
|
|
3144
|
+
sessionId?: string,
|
|
3145
|
+
) => () => TelegramUpdateJournalRuntimeBinding | undefined;
|
|
3146
|
+
createLegacyRecipientResolver: (
|
|
3147
|
+
recipientBindingKey: string,
|
|
2570
3148
|
) => () => TelegramUpdateJournalRuntimeBinding | undefined;
|
|
2571
3149
|
createPathResolver: (
|
|
2572
3150
|
path: string,
|
|
@@ -2576,11 +3154,15 @@ export interface TelegramUpdateJournalBindingRuntime {
|
|
|
2576
3154
|
export function createTelegramUpdateJournalBindingRuntime(deps: {
|
|
2577
3155
|
base: Omit<TelegramUpdateJournalRuntimeBindingResolverDeps, "getJournalPath">;
|
|
2578
3156
|
getLeaderJournalPath: (profileName?: string) => string;
|
|
3157
|
+
/** Runtime root; defaults to the leader journal's directory for flat layouts. */
|
|
3158
|
+
getRuntimeDir?: () => string;
|
|
2579
3159
|
getFollowerJournalPath: (
|
|
2580
3160
|
bindingKey: string,
|
|
2581
3161
|
profileName?: string,
|
|
3162
|
+
sessionId?: string,
|
|
2582
3163
|
) => string;
|
|
2583
3164
|
getActiveFollowerBindingKey: () => string;
|
|
3165
|
+
getActiveFollowerSessionId?: () => string | undefined;
|
|
2584
3166
|
isFollowerRegistered: () => boolean;
|
|
2585
3167
|
}): TelegramUpdateJournalBindingRuntime {
|
|
2586
3168
|
const resolveLeader = createTelegramUpdateJournalRuntimeBindingResolver({
|
|
@@ -2590,8 +3172,12 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
|
|
|
2590
3172
|
const createFollowerResolver = (
|
|
2591
3173
|
bindingKey: string,
|
|
2592
3174
|
includeQueueRuntimeIdentity: boolean,
|
|
2593
|
-
|
|
2594
|
-
|
|
3175
|
+
sessionId?: string,
|
|
3176
|
+
legacy = false,
|
|
3177
|
+
) => {
|
|
3178
|
+
// Session-aware composition must not fall back to a process journal before preparation supplies its ID.
|
|
3179
|
+
if (!legacy && deps.getActiveFollowerSessionId && !sessionId) return () => undefined;
|
|
3180
|
+
return createTelegramUpdateJournalRuntimeBindingResolver({
|
|
2595
3181
|
getProfileName: deps.base.getProfileName,
|
|
2596
3182
|
getBotToken: deps.base.getBotToken,
|
|
2597
3183
|
getBotId: deps.base.getBotId,
|
|
@@ -2600,25 +3186,129 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
|
|
|
2600
3186
|
: {}),
|
|
2601
3187
|
...(deps.base.withWriterAdmission
|
|
2602
3188
|
? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
|
|
3189
|
+
// Follower journals need the same strict exact-receipt ports as the leader; without them queue publication cannot complete.
|
|
3190
|
+
...(deps.base.withSourceSerialization
|
|
3191
|
+
? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
|
|
2603
3192
|
...(deps.base.getWorkspaceAdmission
|
|
2604
3193
|
? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
|
|
2605
3194
|
: {}),
|
|
2606
3195
|
...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
|
|
2607
3196
|
getJournalPath(profileName) {
|
|
2608
|
-
return deps.getFollowerJournalPath(bindingKey, profileName);
|
|
3197
|
+
return deps.getFollowerJournalPath(bindingKey, profileName, sessionId);
|
|
2609
3198
|
},
|
|
2610
3199
|
});
|
|
3200
|
+
};
|
|
2611
3201
|
const resolveFollower = () =>
|
|
2612
|
-
createFollowerResolver(deps.getActiveFollowerBindingKey(), true)();
|
|
3202
|
+
createFollowerResolver(deps.getActiveFollowerBindingKey(), true, deps.getActiveFollowerSessionId?.())();
|
|
2613
3203
|
const resolveActive = () =>
|
|
2614
3204
|
deps.isFollowerRegistered() ? resolveFollower() : resolveLeader();
|
|
2615
|
-
|
|
3205
|
+
const inspectHistoricalSource = <T>(journalBindingKey: string,
|
|
3206
|
+
operation: (file: TelegramUpdateJournalFile, originals: TelegramUpdateJournalRetentionInspection["retainedInputs"], path: string) => T | undefined): T | undefined => {
|
|
3207
|
+
const path = getTelegramUpdateJournalBindingPath(journalBindingKey);
|
|
3208
|
+
const token = deps.base.getBotToken(), botId = deps.base.getBotId();
|
|
3209
|
+
const profile = deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME;
|
|
3210
|
+
if (!path || !token) return undefined;
|
|
3211
|
+
const botIdentity = createTelegramUpdateJournalBotIdentity({ botToken: token, botId });
|
|
3212
|
+
const keys = [botIdentity, { tokenSha256: botIdentity.tokenSha256 }].map(identity =>
|
|
3213
|
+
createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: identity }));
|
|
3214
|
+
if (!keys.includes(journalBindingKey)) return undefined;
|
|
3215
|
+
const getRoot = () => deps.getRuntimeDir?.() ?? dirname(deps.getLeaderJournalPath(profile));
|
|
3216
|
+
const root = getRoot();
|
|
3217
|
+
const parts = relative(root, path).split(sep);
|
|
3218
|
+
const suffix = escapeTelegramJournalPathPattern(getTelegramProfilePathSuffix(profile));
|
|
3219
|
+
const flat = new RegExp(`^(?:inbox|follower-inbox-[a-f0-9]{16})${suffix}\\.json$`, "u");
|
|
3220
|
+
const session = new RegExp(`^(?:journal\\.[a-f0-9]{16}|inbox)${suffix}\\.json$`, "u");
|
|
3221
|
+
if (!isAbsolute(root) || resolve(root) !== root || !isAbsolute(path) || resolve(path) !== path ||
|
|
3222
|
+
!(parts.length === 1 && flat.test(parts[0]!) || parts.length === 3 && parts[0] === "sessions" &&
|
|
3223
|
+
decodeTelegramSessionDirectoryName(parts[1]!) !== undefined && session.test(parts[2]!))) return undefined;
|
|
3224
|
+
const current = () => deps.base.getBotToken() === token && deps.base.getBotId() === botId &&
|
|
3225
|
+
(deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME) === profile && getRoot() === root;
|
|
3226
|
+
const inspect = () => {
|
|
3227
|
+
if (!current()) return undefined;
|
|
3228
|
+
const observed = inspectTelegramUpdateJournalRetention({ directory: root, path, profile, botIdentity,
|
|
3229
|
+
limits: { maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
|
|
3230
|
+
maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024 } });
|
|
3231
|
+
if (observed.evidence.kind !== "present" || !current()) return undefined;
|
|
3232
|
+
return operation(observed.evidence.file, observed.retainedInputs, path);
|
|
3233
|
+
};
|
|
3234
|
+
const result = deps.base.withSourceSerialization ? deps.base.withSourceSerialization(inspect) : inspect();
|
|
3235
|
+
return current() ? result : undefined;
|
|
3236
|
+
};
|
|
3237
|
+
let preparedFollower: { recipientBindingKey: string; sessionId: string } | undefined;
|
|
3238
|
+
const runtime: TelegramUpdateJournalBindingRuntime = {
|
|
2616
3239
|
resolveLeader,
|
|
2617
3240
|
resolveFollower,
|
|
2618
3241
|
resolveActive,
|
|
2619
3242
|
getActiveRecoveryKey: () => resolveActive()?.recoveryKey,
|
|
2620
|
-
|
|
2621
|
-
|
|
3243
|
+
prepareActiveFollowerSuccession(isCurrent) {
|
|
3244
|
+
const recipientBindingKey = deps.getActiveFollowerBindingKey();
|
|
3245
|
+
const sessionId = deps.getActiveFollowerSessionId?.();
|
|
3246
|
+
if (!sessionId) return undefined;
|
|
3247
|
+
const previous = preparedFollower;
|
|
3248
|
+
const result = previous && previous.recipientBindingKey === recipientBindingKey && previous.sessionId !== sessionId
|
|
3249
|
+
? runtime.adoptPredecessorPending({ recipientBindingKey, predecessorSessionId: previous.sessionId,
|
|
3250
|
+
successorSessionId: sessionId, isCurrent })
|
|
3251
|
+
: undefined;
|
|
3252
|
+
// Only a completed adoption advances the predecessor; failures retry idempotently on the next preparation.
|
|
3253
|
+
preparedFollower = { recipientBindingKey, sessionId };
|
|
3254
|
+
return result;
|
|
3255
|
+
},
|
|
3256
|
+
inspectSourceAbandonment(journalBindingKey, updateId) {
|
|
3257
|
+
if (!isSafeNonNegativeInteger(updateId)) throw new Error("Telegram abandonment proof requires an exact update ID.");
|
|
3258
|
+
return inspectHistoricalSource(journalBindingKey, (file, originals) => {
|
|
3259
|
+
const original = originals.find(value => value.updateId === updateId && value.state === "committed" &&
|
|
3260
|
+
value.journalBindingKey === journalBindingKey);
|
|
3261
|
+
if (!original) return undefined;
|
|
3262
|
+
const disposition = file.operatorDispositions?.find(value => value.failureId === original.failureId);
|
|
3263
|
+
if (!disposition || !("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody") return undefined;
|
|
3264
|
+
return { journalBindingKey, updateId, retainedPath: original.path, operatorAuthorityId: disposition.operatorAuthorityId };
|
|
3265
|
+
});
|
|
3266
|
+
},
|
|
3267
|
+
inspectSourceCompletion(journalBindingKey, expected) {
|
|
3268
|
+
const completion = validateJournalSourceCompletion(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
|
|
3269
|
+
return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalSourceCompletion(file, completion, path));
|
|
3270
|
+
},
|
|
3271
|
+
inspectQueuedReceipt(journalBindingKey, expected) {
|
|
3272
|
+
const queueOwner = validateQueuedReceiptObservation(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
|
|
3273
|
+
return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalQueuedReceipt(file, { ...expected, queueOwner }, path));
|
|
3274
|
+
},
|
|
3275
|
+
adoptPredecessorPending(input) {
|
|
3276
|
+
const { recipientBindingKey, predecessorSessionId, successorSessionId, isCurrent } = input;
|
|
3277
|
+
if (!recipientBindingKey || !predecessorSessionId || !successorSessionId ||
|
|
3278
|
+
predecessorSessionId === successorSessionId || typeof isCurrent !== "function") {
|
|
3279
|
+
throw new Error("Telegram session adoption requires distinct exact session identities.");
|
|
3280
|
+
}
|
|
3281
|
+
const assertCurrent = () => {
|
|
3282
|
+
if (!isCurrent()) throw new Error("Telegram session adoption lost successor authority.");
|
|
3283
|
+
};
|
|
3284
|
+
assertCurrent();
|
|
3285
|
+
const predecessor = createFollowerResolver(recipientBindingKey, false, predecessorSessionId)();
|
|
3286
|
+
const successor = createFollowerResolver(recipientBindingKey, false, successorSessionId)();
|
|
3287
|
+
if (!predecessor?.readForProtection || !successor || predecessor.recoveryKey === successor.recoveryKey) {
|
|
3288
|
+
throw new Error("Telegram session adoption requires exact predecessor and successor journals.");
|
|
3289
|
+
}
|
|
3290
|
+
const result: TelegramSessionPendingAdoptionResult = { adoptedUpdateIds: [], retainedUpdateIds: [] };
|
|
3291
|
+
// Non-repairing selection; the abandonment CAS rechecks each exact entry under the source lock.
|
|
3292
|
+
const candidates = predecessor.readForProtection().entries.filter(isAdoptablePendingEntry)
|
|
3293
|
+
.sort((left, right) => left.updateId - right.updateId);
|
|
3294
|
+
for (const entry of candidates) {
|
|
3295
|
+
assertCurrent();
|
|
3296
|
+
// Commit away first: a crash afterwards preserves the private original instead of duplicating execution.
|
|
3297
|
+
const committed = predecessor.journal.abandonPending({ journalBindingKey: predecessor.recoveryKey, entry,
|
|
3298
|
+
operatorAuthorityId: `${TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX}${successorSessionId}`, isCurrent });
|
|
3299
|
+
if (committed.duplicate) {
|
|
3300
|
+
result.retainedUpdateIds.push(entry.updateId);
|
|
3301
|
+
continue;
|
|
3302
|
+
}
|
|
3303
|
+
assertCurrent();
|
|
3304
|
+
successor.journal.appendBatch([entry.update]);
|
|
3305
|
+
result.adoptedUpdateIds.push(entry.updateId);
|
|
3306
|
+
}
|
|
3307
|
+
return result;
|
|
3308
|
+
},
|
|
3309
|
+
createRecipientResolver: (bindingKey, sessionId) =>
|
|
3310
|
+
createFollowerResolver(bindingKey, false, sessionId),
|
|
3311
|
+
createLegacyRecipientResolver: (bindingKey) => createFollowerResolver(bindingKey, false, undefined, true),
|
|
2622
3312
|
createPathResolver: (path) =>
|
|
2623
3313
|
createTelegramUpdateJournalRuntimeBindingResolver({
|
|
2624
3314
|
getProfileName: deps.base.getProfileName,
|
|
@@ -2626,6 +3316,8 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
|
|
|
2626
3316
|
getBotId: deps.base.getBotId,
|
|
2627
3317
|
...(deps.base.withWriterAdmission
|
|
2628
3318
|
? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
|
|
3319
|
+
...(deps.base.withSourceSerialization
|
|
3320
|
+
? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
|
|
2629
3321
|
...(deps.base.getWorkspaceAdmission
|
|
2630
3322
|
? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
|
|
2631
3323
|
: {}),
|
|
@@ -2633,6 +3325,7 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
|
|
|
2633
3325
|
getJournalPath: () => path,
|
|
2634
3326
|
}),
|
|
2635
3327
|
};
|
|
3328
|
+
return runtime;
|
|
2636
3329
|
}
|
|
2637
3330
|
|
|
2638
3331
|
export function createTelegramUpdateJournalStore(
|
|
@@ -2720,7 +3413,6 @@ function createJournalStoreCore(
|
|
|
2720
3413
|
};
|
|
2721
3414
|
const getQueueProcessLiveness =
|
|
2722
3415
|
options.getQueueProcessLiveness ?? getTelegramProcessLiveness;
|
|
2723
|
-
|
|
2724
3416
|
const validateQueueHandoffInput = (
|
|
2725
3417
|
input: TelegramUpdateJournalQueueHandoffInput,
|
|
2726
3418
|
operation: "offer" | "accept" | "cancel",
|
|
@@ -2841,6 +3533,9 @@ function createJournalStoreCore(
|
|
|
2841
3533
|
`exceeds its ${maxEntries}-operator-disposition limit`,
|
|
2842
3534
|
);
|
|
2843
3535
|
}
|
|
3536
|
+
if ((file.sourceCompletions?.length ?? 0) > maxEntries) {
|
|
3537
|
+
throw createJournalError("capacity", path, `exceeds its ${maxEntries}-source-completion limit`);
|
|
3538
|
+
}
|
|
2844
3539
|
const serializedBytes = Buffer.byteLength(serialized);
|
|
2845
3540
|
if (serializedBytes > maxBytes) {
|
|
2846
3541
|
throw createJournalError(
|
|
@@ -2859,7 +3554,7 @@ function createJournalStoreCore(
|
|
|
2859
3554
|
entries: [],
|
|
2860
3555
|
});
|
|
2861
3556
|
|
|
2862
|
-
const readCurrentStrict = (): ReadTelegramUpdateJournalResult => {
|
|
3557
|
+
const readCurrentStrict = (allowReconciliation = true): ReadTelegramUpdateJournalResult => {
|
|
2863
3558
|
let source: string;
|
|
2864
3559
|
let recoveringMissingSnapshot = false;
|
|
2865
3560
|
try {
|
|
@@ -2893,6 +3588,7 @@ function createJournalStoreCore(
|
|
|
2893
3588
|
);
|
|
2894
3589
|
}
|
|
2895
3590
|
if (orphanedSegmentNames.length > 0) {
|
|
3591
|
+
if (!allowReconciliation) throw createJournalError("invalid", path, "requires explicit missing-snapshot reconciliation");
|
|
2896
3592
|
if (withPairingAdmission) throw createJournalError("pairing-evidence", path, "requires explicit missing-snapshot reconciliation");
|
|
2897
3593
|
recoveringMissingSnapshot = true;
|
|
2898
3594
|
source = serializeJournalFile(emptyFile());
|
|
@@ -3001,6 +3697,7 @@ function createJournalStoreCore(
|
|
|
3001
3697
|
"belongs to another journal identity",
|
|
3002
3698
|
);
|
|
3003
3699
|
}
|
|
3700
|
+
assertJournalSourceCompletionContinuity(file, segment, segmentPath);
|
|
3004
3701
|
const entriesById = new Map(
|
|
3005
3702
|
file.entries.map((entry) => [entry.updateId, entry]),
|
|
3006
3703
|
);
|
|
@@ -3040,6 +3737,8 @@ function createJournalStoreCore(
|
|
|
3040
3737
|
: file.operatorDispositions?.length
|
|
3041
3738
|
? { operatorDispositions: file.operatorDispositions }
|
|
3042
3739
|
: {}),
|
|
3740
|
+
...(segment.sourceCompletions !== undefined ? { sourceCompletions: segment.sourceCompletions }
|
|
3741
|
+
: file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions } : {}),
|
|
3043
3742
|
},
|
|
3044
3743
|
segmentPath,
|
|
3045
3744
|
version,
|
|
@@ -3049,9 +3748,9 @@ function createJournalStoreCore(
|
|
|
3049
3748
|
const identityChanged =
|
|
3050
3749
|
storedProfile !== profile ||
|
|
3051
3750
|
!identitiesMatch(file.botIdentity, expectedIdentity) ||
|
|
3052
|
-
(withPairingAdmission && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
|
|
3751
|
+
((withPairingAdmission || !!file.sourceCompletions?.length) && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
|
|
3053
3752
|
if (identityChanged) {
|
|
3054
|
-
if (file.entries.length > 0 || withPairingAdmission) {
|
|
3753
|
+
if (file.entries.length > 0 || file.sourceCompletions?.length || withPairingAdmission || !allowReconciliation) {
|
|
3055
3754
|
throw createJournalError(
|
|
3056
3755
|
"identity-mismatch",
|
|
3057
3756
|
path,
|
|
@@ -3205,66 +3904,35 @@ function createJournalStoreCore(
|
|
|
3205
3904
|
}
|
|
3206
3905
|
}
|
|
3207
3906
|
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
basename(segmentDirectory),
|
|
3218
|
-
);
|
|
3219
|
-
if (
|
|
3220
|
-
snapshotExists &&
|
|
3221
|
-
!renameTelegramPathWithRetry(path, snapshotQuarantinePath)
|
|
3222
|
-
) {
|
|
3223
|
-
throw createJournalError(
|
|
3224
|
-
"io",
|
|
3225
|
-
path,
|
|
3226
|
-
"could not quarantine an unrecoverable journal snapshot",
|
|
3227
|
-
error,
|
|
3228
|
-
);
|
|
3229
|
-
}
|
|
3230
|
-
if (!renameTelegramPathWithRetry(segmentDirectory, segmentQuarantinePath)) {
|
|
3231
|
-
if (snapshotExists) {
|
|
3232
|
-
renameTelegramPathWithRetry(snapshotQuarantinePath, path);
|
|
3907
|
+
// Approved corruption policy: delete damaged evidence instead of quarantining it under recovery/.
|
|
3908
|
+
// Segments go first so a fresh revisionless snapshot can never replay them; a crash repeats the reset.
|
|
3909
|
+
const deletedPaths: string[] = [];
|
|
3910
|
+
const deleteDamaged = (target: string, label: string): void => {
|
|
3911
|
+
try {
|
|
3912
|
+
lstatSync(target);
|
|
3913
|
+
} catch (statError) {
|
|
3914
|
+
if ((statError as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
3915
|
+
throw createJournalError("io", target, `could not inspect an unrecoverable ${label}`, statError);
|
|
3233
3916
|
}
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
"could not
|
|
3238
|
-
|
|
3239
|
-
);
|
|
3240
|
-
}
|
|
3917
|
+
try {
|
|
3918
|
+
rmSync(target, { recursive: true, force: true });
|
|
3919
|
+
} catch (removeError) {
|
|
3920
|
+
throw createJournalError("io", target, `could not delete an unrecoverable ${label}`, removeError);
|
|
3921
|
+
}
|
|
3922
|
+
deletedPaths.push(target);
|
|
3923
|
+
};
|
|
3924
|
+
deleteDamaged(segmentDirectory, "journal segment history");
|
|
3925
|
+
// A regular snapshot is replaced atomically below; anything else cannot be a rename target.
|
|
3926
|
+
if (snapshotExists && !lstatSync(path).isFile()) deleteDamaged(path, "journal snapshot");
|
|
3241
3927
|
const reset = emptyFile();
|
|
3242
3928
|
const serialized = serializeJournalFile(reset);
|
|
3243
3929
|
const serializedBytes = assertCapacity(reset, serialized);
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
} catch (publicationError) {
|
|
3247
|
-
const segmentsRestored = renameTelegramPathWithRetry(
|
|
3248
|
-
segmentQuarantinePath,
|
|
3249
|
-
segmentDirectory,
|
|
3250
|
-
);
|
|
3251
|
-
const snapshotRestored =
|
|
3252
|
-
!snapshotExists ||
|
|
3253
|
-
renameTelegramPathWithRetry(snapshotQuarantinePath, path);
|
|
3254
|
-
if (!segmentsRestored || !snapshotRestored) {
|
|
3255
|
-
throw createJournalError(
|
|
3256
|
-
"io",
|
|
3257
|
-
path,
|
|
3258
|
-
"reset publication failed after journal evidence was quarantined",
|
|
3259
|
-
publicationError,
|
|
3260
|
-
);
|
|
3261
|
-
}
|
|
3262
|
-
throw publicationError;
|
|
3263
|
-
}
|
|
3930
|
+
writeJournalFile(path, serialized, onPublicationBoundary);
|
|
3931
|
+
if (snapshotExists && !deletedPaths.includes(path)) deletedPaths.push(path);
|
|
3264
3932
|
notifyRecovery({
|
|
3265
3933
|
kind: "reset",
|
|
3266
3934
|
path,
|
|
3267
|
-
|
|
3935
|
+
deletedPaths,
|
|
3268
3936
|
reason: error.message,
|
|
3269
3937
|
});
|
|
3270
3938
|
return { file: reset, exists: true, serializedBytes };
|
|
@@ -3315,7 +3983,7 @@ function createJournalStoreCore(
|
|
|
3315
3983
|
const inputBindingHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH);
|
|
3316
3984
|
const inputHandoffHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH);
|
|
3317
3985
|
const getFileWork = (file: TelegramUpdateJournalFile): number =>
|
|
3318
|
-
file.entries.length + (file.operatorDispositions?.length ?? 0);
|
|
3986
|
+
file.entries.length + (file.operatorDispositions?.length ?? 0) + (file.sourceCompletions?.length ?? 0);
|
|
3319
3987
|
const advanceInputHeadroomState = (state: InputHeadroomState,
|
|
3320
3988
|
entries: TelegramUpdateJournalEntry[], projectionSlack: number): InputHeadroomState => {
|
|
3321
3989
|
const revision = (state.file.revision ?? 0) + 1;
|
|
@@ -3491,7 +4159,7 @@ function createJournalStoreCore(
|
|
|
3491
4159
|
): TelegramUpdateJournalSegment => {
|
|
3492
4160
|
const previousEntries = new Map(previous.entries.map(entry => [entry.updateId, entry]));
|
|
3493
4161
|
const nextEntries = new Map(next.entries.map(entry => [entry.updateId, entry]));
|
|
3494
|
-
return { version
|
|
4162
|
+
return { version,
|
|
3495
4163
|
revision: next.revision!, previousRevision: next.revision! - 1,
|
|
3496
4164
|
profile: next.profile, botIdentity: next.botIdentity,
|
|
3497
4165
|
upsertedEntries: next.entries.filter(entry =>
|
|
@@ -3521,7 +4189,7 @@ function createJournalStoreCore(
|
|
|
3521
4189
|
: { files: 1, bytes: publishedBytes, work: getFileWork(publishedFile) };
|
|
3522
4190
|
if (publishedSegment) {
|
|
3523
4191
|
const segmentWork = publishedSegment.upsertedEntries.length + publishedSegment.removedUpdateIds.length +
|
|
3524
|
-
(publishedSegment.operatorDispositions?.length ?? 0);
|
|
4192
|
+
(publishedSegment.operatorDispositions?.length ?? 0) + (publishedSegment.sourceCompletions?.length ?? 0);
|
|
3525
4193
|
const segmentBytes = Buffer.byteLength(`${JSON.stringify(publishedSegment, null, 2)}\n`);
|
|
3526
4194
|
baseRetainedCount += 1;
|
|
3527
4195
|
baseRetainedBytes += segmentBytes;
|
|
@@ -3580,9 +4248,10 @@ function createJournalStoreCore(
|
|
|
3580
4248
|
const segmentText = `${JSON.stringify(segment, null, 2)}\n`;
|
|
3581
4249
|
const segmentBytes = Buffer.byteLength(segmentText);
|
|
3582
4250
|
const collections = [segment.upsertedEntries.length, segment.removedUpdateIds.length,
|
|
3583
|
-
segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0
|
|
3584
|
-
|
|
3585
|
-
const
|
|
4251
|
+
segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0,
|
|
4252
|
+
segment.sourceCompletions?.length ?? 0, file.sourceCompletions?.length ?? 0];
|
|
4253
|
+
const segmentWork = collections[0]! + collections[1]! + collections[2]! + collections[5]!;
|
|
4254
|
+
const fileWork = collections[3]! + collections[4]! + collections[6]!;
|
|
3586
4255
|
const accounting = source.evidence.accounting;
|
|
3587
4256
|
// Both the segment-first state and snapshot-first cleanup residue must fit.
|
|
3588
4257
|
assertSourceResources(accounting.files + 1, accounting.bytes + segmentBytes,
|
|
@@ -3625,7 +4294,8 @@ function createJournalStoreCore(
|
|
|
3625
4294
|
acceptedThroughUpdateId = current.file.acceptedThroughUpdateId,
|
|
3626
4295
|
publicationBoundary = onPublicationBoundary,
|
|
3627
4296
|
inputHeadroom: number | readonly number[] | "all" | false =
|
|
3628
|
-
version
|
|
4297
|
+
journalHasCustodyFields(version) ? "all" : false,
|
|
4298
|
+
sourceCompletions = current.file.sourceCompletions,
|
|
3629
4299
|
): { file: TelegramUpdateJournalFile; serializedBytes: number } => {
|
|
3630
4300
|
const botIdentity = current.source ? current.file.botIdentity : mergeBotIdentity(
|
|
3631
4301
|
current.file.botIdentity,
|
|
@@ -3638,7 +4308,8 @@ function createJournalStoreCore(
|
|
|
3638
4308
|
current.file.operatorDispositions ?? [],
|
|
3639
4309
|
operatorDispositions ?? [],
|
|
3640
4310
|
) &&
|
|
3641
|
-
current.file.acceptedThroughUpdateId === acceptedThroughUpdateId
|
|
4311
|
+
current.file.acceptedThroughUpdateId === acceptedThroughUpdateId &&
|
|
4312
|
+
isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? [])
|
|
3642
4313
|
) {
|
|
3643
4314
|
return { file: current.file, serializedBytes: current.serializedBytes };
|
|
3644
4315
|
}
|
|
@@ -3653,21 +4324,24 @@ function createJournalStoreCore(
|
|
|
3653
4324
|
...(operatorDispositions?.length
|
|
3654
4325
|
? { operatorDispositions }
|
|
3655
4326
|
: {}),
|
|
4327
|
+
...(sourceCompletions?.length ? { sourceCompletions } : {}),
|
|
3656
4328
|
};
|
|
4329
|
+
assertJournalRoutingInputContinuity(current.file, entries, path);
|
|
3657
4330
|
if (withPairingAdmission || current.source) parseJournalFile(file, path, version);
|
|
3658
4331
|
const serialized = serializeJournalFile(file);
|
|
3659
4332
|
const serializedBytes = assertCapacity(file, serialized);
|
|
3660
4333
|
const changed =
|
|
3661
4334
|
contentChanged ||
|
|
3662
4335
|
current.file.acceptedThroughUpdateId !== acceptedThroughUpdateId ||
|
|
3663
|
-
!isDeepStrictEqual(current.file.botIdentity, file.botIdentity)
|
|
4336
|
+
!isDeepStrictEqual(current.file.botIdentity, file.botIdentity) ||
|
|
4337
|
+
!isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []);
|
|
3664
4338
|
if (!changed) {
|
|
3665
4339
|
return { file, serializedBytes: current.serializedBytes };
|
|
3666
4340
|
}
|
|
3667
4341
|
if (!current.exists) {
|
|
3668
4342
|
if (current.source) {
|
|
3669
|
-
const collections = [file.entries.length, file.operatorDispositions?.length ?? 0];
|
|
3670
|
-
assertSourceResources(1, serializedBytes, collections[0]! + collections[1]!, collections);
|
|
4343
|
+
const collections = [file.entries.length, file.operatorDispositions?.length ?? 0, file.sourceCompletions?.length ?? 0];
|
|
4344
|
+
assertSourceResources(1, serializedBytes, collections[0]! + collections[1]! + collections[2]!, collections);
|
|
3671
4345
|
}
|
|
3672
4346
|
if (inputHeadroom !== false) assertInputProgressHeadroom(current, file, inputHeadroom);
|
|
3673
4347
|
writeJournalFile(path, serialized, publicationBoundary);
|
|
@@ -3709,13 +4383,14 @@ function createJournalStoreCore(
|
|
|
3709
4383
|
)
|
|
3710
4384
|
? { operatorDispositions: operatorDispositions ?? [] }
|
|
3711
4385
|
: {}),
|
|
4386
|
+
...(!isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []) ? { sourceCompletions: sourceCompletions ?? [] } : {}),
|
|
3712
4387
|
};
|
|
3713
4388
|
if (current.source) {
|
|
3714
4389
|
if (!isSafePositiveInteger(revision)) throw createJournalError("capacity", path, "exhausted source revisions");
|
|
3715
4390
|
const revisedFile = { ...file, revision };
|
|
3716
4391
|
if (inputHeadroom !== false) assertInputProgressHeadroom(current, revisedFile, inputHeadroom, segment);
|
|
3717
4392
|
return publishSourceSegment(current, file, segment, publicationBoundary,
|
|
3718
|
-
version
|
|
4393
|
+
journalHasCustodyFields(version));
|
|
3719
4394
|
}
|
|
3720
4395
|
publishTelegramUpdateJournalSegmentUnlocked(path, segment, publicationBoundary);
|
|
3721
4396
|
const revisedFile: TelegramUpdateJournalFile = { ...file, revision };
|
|
@@ -3768,6 +4443,160 @@ function createJournalStoreCore(
|
|
|
3768
4443
|
return { file: revisedFile, serializedBytes };
|
|
3769
4444
|
};
|
|
3770
4445
|
|
|
4446
|
+
const removeCompleted = (updateIds: readonly number[], expectedSources?: readonly TelegramUpdateJournalEntryDigest[], exact = false,
|
|
4447
|
+
completionInput?: readonly TelegramUpdateJournalSourceCompletion[]): TelegramUpdateJournalRemoveResult =>
|
|
4448
|
+
runMutation((readCurrent) => {
|
|
4449
|
+
const current = exact && !sourceAccess ? readCurrentStrict(false) : readCurrent();
|
|
4450
|
+
if (completionInput !== undefined && !sourceAccess) throw createJournalError("invalid", path, "source completion requires an exact source handle");
|
|
4451
|
+
const completions = completionInput === undefined ? undefined : validateJournalSourceCompletions(completionInput, path, version);
|
|
4452
|
+
if (exact && expectedSources === undefined) throw createJournalError("invalid", path, "received no exact completion sources");
|
|
4453
|
+
const requestedIds = new Set<number>();
|
|
4454
|
+
for (const updateId of updateIds) {
|
|
4455
|
+
if (!isSafeNonNegativeInteger(updateId)) throw createJournalError("invalid", path, "received an invalid removal update id");
|
|
4456
|
+
requestedIds.add(updateId);
|
|
4457
|
+
}
|
|
4458
|
+
if (expectedSources !== undefined) {
|
|
4459
|
+
if (!Array.isArray(expectedSources) || !expectedSources.length) throw createJournalError("invalid", path, "received no exact completion sources");
|
|
4460
|
+
const expectedIds = new Set<number>();
|
|
4461
|
+
for (const expected of expectedSources) {
|
|
4462
|
+
if (!isRecord(expected) || Object.keys(expected).some(key => !["updateId", "sourceSha256"].includes(key)) ||
|
|
4463
|
+
!isSafeNonNegativeInteger(expected.updateId) || !requestedIds.has(expected.updateId) || expectedIds.has(expected.updateId) ||
|
|
4464
|
+
typeof expected.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(expected.sourceSha256)) {
|
|
4465
|
+
throw createJournalError("invalid", path, "received an invalid exact completion source");
|
|
4466
|
+
}
|
|
4467
|
+
expectedIds.add(expected.updateId);
|
|
4468
|
+
const entry = current.file.entries.find(value => value.updateId === expected.updateId);
|
|
4469
|
+
if (!entry || createTelegramUpdateJournalEntryDigest(entry).sourceSha256 !== expected.sourceSha256) {
|
|
4470
|
+
throw createJournalError("conflict", path, "exact completion source changed or disappeared");
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
if (completions) {
|
|
4475
|
+
const guards = new Map((expectedSources ?? []).map(source => [source.updateId, source.sourceSha256]));
|
|
4476
|
+
if (completions.some(completion => guards.get(completion.updateId) !== completion.sourceSha256)) {
|
|
4477
|
+
throw createJournalError("invalid", path, "source completion has no matching exact guard");
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
const requestedEntries = current.file.entries.filter(entry => requestedIds.has(entry.updateId));
|
|
4481
|
+
const protectedEntry = requestedEntries.find(entry => entry.state === "failed" || entry.state === "queued");
|
|
4482
|
+
if (protectedEntry) throw createJournalError("conflict", path, protectedEntry.state === "failed"
|
|
4483
|
+
? `cannot complete terminal update ${protectedEntry.updateId} without an operator disposition`
|
|
4484
|
+
: `cannot complete queued update ${protectedEntry.updateId} without its exact owner receipt`);
|
|
4485
|
+
const removedUpdateIds = requestedEntries.map(entry => entry.updateId);
|
|
4486
|
+
const sourceCompletions = completions
|
|
4487
|
+
? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId) : current.file.sourceCompletions;
|
|
4488
|
+
if (sourceCompletions?.length) validateJournalSourceCompletions(sourceCompletions, path, version);
|
|
4489
|
+
const published = publishMutation(current, current.file.entries.filter(entry => !requestedIds.has(entry.updateId)), removedUpdateIds.length > 0,
|
|
4490
|
+
current.file.operatorDispositions, current.file.acceptedThroughUpdateId, onPublicationBoundary, false, sourceCompletions);
|
|
4491
|
+
return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
|
|
4492
|
+
...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
|
|
4493
|
+
});
|
|
4494
|
+
const completeQueued = (receipts: readonly TelegramUpdateJournalQueuedCompletion[],
|
|
4495
|
+
completionInput?: readonly TelegramUpdateJournalSourceCompletion[], exact = false): TelegramUpdateJournalRemoveResult =>
|
|
4496
|
+
runMutation((readCurrent) => {
|
|
4497
|
+
if (exact && !sourceAccess) throw createJournalError("invalid", path, "source completion requires an exact source handle");
|
|
4498
|
+
const completions = exact ? validateJournalSourceCompletions(completionInput, path, version) : undefined;
|
|
4499
|
+
if (!Array.isArray(receipts) || receipts.length === 0) throw createJournalError("invalid", path, "received no queued receipts to complete");
|
|
4500
|
+
const current = readCurrent();
|
|
4501
|
+
const receiptIds = new Set<string>(), requestedUpdateIds = new Set<number>();
|
|
4502
|
+
for (const receipt of receipts) {
|
|
4503
|
+
if ((receipt.queueKind !== "prompt" && receipt.queueKind !== "control") || !isNonEmptyString(receipt.receiptId) ||
|
|
4504
|
+
receiptIds.has(receipt.receiptId) || !Array.isArray(receipt.sourceUpdateIds) || receipt.sourceUpdateIds.length === 0) {
|
|
4505
|
+
throw createJournalError("invalid", path, "received an invalid queued completion receipt");
|
|
4506
|
+
}
|
|
4507
|
+
receiptIds.add(receipt.receiptId);
|
|
4508
|
+
const queueOwner = validateJournalQueueOwner(receipt.queueOwner, path);
|
|
4509
|
+
if (queueRuntimeIdentity && (queueOwner.instanceId !== queueRuntimeIdentity.instanceId || queueOwner.processId !== queueRuntimeIdentity.processId ||
|
|
4510
|
+
queueOwner.processBirthId !== queueRuntimeIdentity.processBirthId)) {
|
|
4511
|
+
throw createJournalError("conflict", path, `cannot complete foreign queue receipt ${receipt.receiptId}`);
|
|
4512
|
+
}
|
|
4513
|
+
const sourceUpdateIds = new Set<number>();
|
|
4514
|
+
for (const updateId of receipt.sourceUpdateIds) {
|
|
4515
|
+
if (!isSafeNonNegativeInteger(updateId) || sourceUpdateIds.has(updateId) || requestedUpdateIds.has(updateId)) {
|
|
4516
|
+
throw createJournalError("invalid", path, "received overlapping queued completion update ids");
|
|
4517
|
+
}
|
|
4518
|
+
sourceUpdateIds.add(updateId); requestedUpdateIds.add(updateId);
|
|
4519
|
+
}
|
|
4520
|
+
const persistedReceiptEntries = current.file.entries.filter(entry => entry.queueReceiptId === receipt.receiptId);
|
|
4521
|
+
if (persistedReceiptEntries.length !== sourceUpdateIds.size || persistedReceiptEntries.some(entry =>
|
|
4522
|
+
!sourceUpdateIds.has(entry.updateId) || entry.state !== "queued" || entry.queueKind !== receipt.queueKind || !entry.queueOwner ||
|
|
4523
|
+
entry.queueHandoff !== undefined || !areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, queueOwner))) {
|
|
4524
|
+
throw createJournalError("conflict", path, `cannot complete stale or foreign queue receipt ${receipt.receiptId}`);
|
|
4525
|
+
}
|
|
4526
|
+
}
|
|
4527
|
+
const removedEntries = current.file.entries.filter(entry => requestedUpdateIds.has(entry.updateId));
|
|
4528
|
+
const removedUpdateIds = removedEntries.map(entry => entry.updateId);
|
|
4529
|
+
if (removedUpdateIds.length !== requestedUpdateIds.size) throw createJournalError("conflict", path, "queued completion did not resolve every source update");
|
|
4530
|
+
if (completions) {
|
|
4531
|
+
const sources = new Map(removedEntries.map(entry => [entry.updateId, createTelegramUpdateJournalEntryDigest(entry).sourceSha256]));
|
|
4532
|
+
if (completions.some(completion => sources.get(completion.updateId) !== completion.sourceSha256)) {
|
|
4533
|
+
throw createJournalError("conflict", path, "queued completion source changed or belongs to another receipt");
|
|
4534
|
+
}
|
|
4535
|
+
}
|
|
4536
|
+
const sourceCompletions = completions ? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId)
|
|
4537
|
+
: current.file.sourceCompletions;
|
|
4538
|
+
if (sourceCompletions?.length) validateJournalSourceCompletions(sourceCompletions, path, version);
|
|
4539
|
+
const published = publishMutation(current, current.file.entries.filter(entry => !requestedUpdateIds.has(entry.updateId)), true,
|
|
4540
|
+
current.file.operatorDispositions, current.file.acceptedThroughUpdateId, onPublicationBoundary, exact ? false : undefined, sourceCompletions);
|
|
4541
|
+
return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
|
|
4542
|
+
...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
|
|
4543
|
+
});
|
|
4544
|
+
const mutateRoutingInputs = (input: TelegramRoutingInputAuthority, select: boolean, publishedAtMs?: number) => runMutation(readCurrent => {
|
|
4545
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) throw createJournalError("unsupported-version", path, "routing input lifetime requires a private v1 source");
|
|
4546
|
+
const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
4547
|
+
if (input.journalBindingKey !== binding || !isSafePositiveInteger(input.operatorUserId) ||
|
|
4548
|
+
typeof input.isCurrent !== "function" || !Array.isArray(input.entries) || !input.entries.length || input.entries.length > maxEntries) {
|
|
4549
|
+
throw createJournalError("invalid", path, "invalid routing input lifetime authority");
|
|
4550
|
+
}
|
|
4551
|
+
const assertCurrent = () => { if (!input.isCurrent()) throw createJournalError("conflict", path, "routing input lifetime authority ended"); };
|
|
4552
|
+
assertCurrent();
|
|
4553
|
+
const expected = input.entries.map(entry => validateJournalEntry(entry, path, version));
|
|
4554
|
+
if (new Set(expected.map(entry => entry.updateId)).size !== expected.length) throw createJournalError("invalid", path, "duplicate routing input sources");
|
|
4555
|
+
const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
|
|
4556
|
+
const nowMs = select ? getNowMs() : publishedAtMs;
|
|
4557
|
+
if (!isSafeNonNegativeInteger(nowMs) || !Number.isSafeInteger(nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS) || (!select && nowMs > getNowMs())) throw createJournalError("invalid", path, "invalid routing input lifetime clock");
|
|
4558
|
+
let issued = false;
|
|
4559
|
+
const changed = new Map<number, TelegramUpdateJournalEntry>();
|
|
4560
|
+
const result = expected.map(original => {
|
|
4561
|
+
const entry = current.file.entries.find(value => value.updateId === original.updateId);
|
|
4562
|
+
if (!entry || entry.state !== "pending" || original.state !== "pending" || entry.inputClaim || entry.inputProvenance || entry.queueOwner) {
|
|
4563
|
+
throw createJournalError("conflict", path, "routing input source is not unclaimed pending work");
|
|
4564
|
+
}
|
|
4565
|
+
const withoutLifetime = (value: TelegramUpdateJournalEntry) => { const copy = cloneEntry(value); delete copy.routingInput; return copy; };
|
|
4566
|
+
if (!isDeepStrictEqual(entry, original) && (!isDeepStrictEqual(withoutLifetime(entry), withoutLifetime(original)) ||
|
|
4567
|
+
(original.routingInput && !isDeepStrictEqual(original.routingInput, entry.routingInput)))) {
|
|
4568
|
+
throw createJournalError("conflict", path, "routing input source changed");
|
|
4569
|
+
}
|
|
4570
|
+
const lifetime = entry.routingInput;
|
|
4571
|
+
if (lifetime && lifetime.operatorUserId !== input.operatorUserId) throw createJournalError("conflict", path, "routing input belongs to another operator");
|
|
4572
|
+
if (createPendingRetentionReference({ path, journalBindingKey: binding, entry, maxBytes }).read()) {
|
|
4573
|
+
throw createJournalError("conflict", path, "routing input has protected retention");
|
|
4574
|
+
}
|
|
4575
|
+
if (select) {
|
|
4576
|
+
if (!lifetime || (lifetime.phase === "waiting" && nowMs >= lifetime.expiresAtMs)) throw createJournalError("conflict", path, "routing input choice expired or was not armed");
|
|
4577
|
+
if (lifetime.phase === "selected") return cloneEntry(entry);
|
|
4578
|
+
issued = true;
|
|
4579
|
+
const selected = { ...cloneEntry(entry), routingInput: { ...lifetime, phase: "selected" as const } };
|
|
4580
|
+
changed.set(entry.updateId, selected); return cloneEntry(selected);
|
|
4581
|
+
}
|
|
4582
|
+
if (lifetime) {
|
|
4583
|
+
if (lifetime.phase !== "waiting") throw createJournalError("conflict", path, "selected routing input cannot renew its choice");
|
|
4584
|
+
return cloneEntry(entry);
|
|
4585
|
+
}
|
|
4586
|
+
if (nowMs < entry.admittedAtMs) throw createJournalError("conflict", path, "routing input publication predates admission");
|
|
4587
|
+
issued = true;
|
|
4588
|
+
const armed = { ...cloneEntry(entry), routingInput: { operatorUserId: input.operatorUserId,
|
|
4589
|
+
publishedAtMs: nowMs, expiresAtMs: nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS, phase: "waiting" as const } };
|
|
4590
|
+
changed.set(entry.updateId, armed); return cloneEntry(armed);
|
|
4591
|
+
});
|
|
4592
|
+
assertCurrent();
|
|
4593
|
+
if (select && issued && result.some(entry => !changed.has(entry.updateId))) throw createJournalError("conflict", path, "routing input selection has mixed prior phases");
|
|
4594
|
+
if (changed.size) publishMutation(current, current.file.entries.map(entry => changed.get(entry.updateId) ?? entry), true,
|
|
4595
|
+
current.file.operatorDispositions, current.file.acceptedThroughUpdateId,
|
|
4596
|
+
(boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
|
|
4597
|
+
assertCurrent();
|
|
4598
|
+
return { issued, entries: result };
|
|
4599
|
+
});
|
|
3771
4600
|
const journal: TelegramUpdateJournalStore = {
|
|
3772
4601
|
read() {
|
|
3773
4602
|
return runMutation((readCurrent) => {
|
|
@@ -3815,11 +4644,12 @@ function createJournalStoreCore(
|
|
|
3815
4644
|
);
|
|
3816
4645
|
const previousAcceptedThroughUpdateId =
|
|
3817
4646
|
current.file.acceptedThroughUpdateId;
|
|
3818
|
-
const discardedUpdateIds = new Set(
|
|
3819
|
-
(current.file.operatorDispositions ?? [])
|
|
4647
|
+
const discardedUpdateIds = new Set([
|
|
4648
|
+
...(current.file.operatorDispositions ?? [])
|
|
3820
4649
|
.filter((disposition) => disposition.action === "discard")
|
|
3821
4650
|
.map((disposition) => disposition.updateId),
|
|
3822
|
-
|
|
4651
|
+
...(current.file.sourceCompletions ?? []).map(completion => completion.updateId),
|
|
4652
|
+
]);
|
|
3823
4653
|
let admittedAtMs: number | undefined;
|
|
3824
4654
|
const addedUpdateIds: number[] = [];
|
|
3825
4655
|
const duplicateUpdateIds: number[] = [];
|
|
@@ -4193,6 +5023,7 @@ function createJournalStoreCore(
|
|
|
4193
5023
|
updateId: entry.updateId,
|
|
4194
5024
|
update: entry.update,
|
|
4195
5025
|
admittedAtMs: entry.admittedAtMs,
|
|
5026
|
+
...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
|
|
4196
5027
|
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
4197
5028
|
state: input.disposition,
|
|
4198
5029
|
failure,
|
|
@@ -4328,9 +5159,9 @@ function createJournalStoreCore(
|
|
|
4328
5159
|
applyLegacyCustodyDisposition(authority) {
|
|
4329
5160
|
return runMutation((readCurrent) => {
|
|
4330
5161
|
const current = readCurrent();
|
|
4331
|
-
if (current.file.version
|
|
5162
|
+
if (!journalHasCustodyFields(current.file.version))
|
|
4332
5163
|
throw createJournalError("conflict", path,
|
|
4333
|
-
"legacy custody disposition requires a
|
|
5164
|
+
"legacy custody disposition requires a custody journal");
|
|
4334
5165
|
const existing = current.file.operatorDispositions?.find(
|
|
4335
5166
|
candidate => candidate.failureId === authority.dispositionId);
|
|
4336
5167
|
if (existing) {
|
|
@@ -4647,116 +5478,8 @@ function createJournalStoreCore(
|
|
|
4647
5478
|
};
|
|
4648
5479
|
});
|
|
4649
5480
|
},
|
|
4650
|
-
completeQueued(receipts) {
|
|
4651
|
-
|
|
4652
|
-
if (!Array.isArray(receipts) || receipts.length === 0) {
|
|
4653
|
-
throw createJournalError(
|
|
4654
|
-
"invalid",
|
|
4655
|
-
path,
|
|
4656
|
-
"received no queued receipts to complete",
|
|
4657
|
-
);
|
|
4658
|
-
}
|
|
4659
|
-
const current = readCurrent();
|
|
4660
|
-
const receiptIds = new Set<string>();
|
|
4661
|
-
const requestedUpdateIds = new Set<number>();
|
|
4662
|
-
for (const receipt of receipts) {
|
|
4663
|
-
if (
|
|
4664
|
-
(receipt.queueKind !== "prompt" &&
|
|
4665
|
-
receipt.queueKind !== "control") ||
|
|
4666
|
-
!isNonEmptyString(receipt.receiptId) ||
|
|
4667
|
-
receiptIds.has(receipt.receiptId) ||
|
|
4668
|
-
!Array.isArray(receipt.sourceUpdateIds) ||
|
|
4669
|
-
receipt.sourceUpdateIds.length === 0
|
|
4670
|
-
) {
|
|
4671
|
-
throw createJournalError(
|
|
4672
|
-
"invalid",
|
|
4673
|
-
path,
|
|
4674
|
-
"received an invalid queued completion receipt",
|
|
4675
|
-
);
|
|
4676
|
-
}
|
|
4677
|
-
receiptIds.add(receipt.receiptId);
|
|
4678
|
-
const queueOwner = validateJournalQueueOwner(
|
|
4679
|
-
receipt.queueOwner,
|
|
4680
|
-
path,
|
|
4681
|
-
);
|
|
4682
|
-
if (
|
|
4683
|
-
queueRuntimeIdentity &&
|
|
4684
|
-
(queueOwner.instanceId !== queueRuntimeIdentity.instanceId ||
|
|
4685
|
-
queueOwner.processId !== queueRuntimeIdentity.processId ||
|
|
4686
|
-
queueOwner.processBirthId !==
|
|
4687
|
-
queueRuntimeIdentity.processBirthId)
|
|
4688
|
-
) {
|
|
4689
|
-
throw createJournalError(
|
|
4690
|
-
"conflict",
|
|
4691
|
-
path,
|
|
4692
|
-
`cannot complete foreign queue receipt ${receipt.receiptId}`,
|
|
4693
|
-
);
|
|
4694
|
-
}
|
|
4695
|
-
const sourceUpdateIds = new Set<number>();
|
|
4696
|
-
for (const updateId of receipt.sourceUpdateIds) {
|
|
4697
|
-
if (
|
|
4698
|
-
!isSafeNonNegativeInteger(updateId) ||
|
|
4699
|
-
sourceUpdateIds.has(updateId) ||
|
|
4700
|
-
requestedUpdateIds.has(updateId)
|
|
4701
|
-
) {
|
|
4702
|
-
throw createJournalError(
|
|
4703
|
-
"invalid",
|
|
4704
|
-
path,
|
|
4705
|
-
"received overlapping queued completion update ids",
|
|
4706
|
-
);
|
|
4707
|
-
}
|
|
4708
|
-
sourceUpdateIds.add(updateId);
|
|
4709
|
-
requestedUpdateIds.add(updateId);
|
|
4710
|
-
}
|
|
4711
|
-
const persistedReceiptEntries = current.file.entries.filter(
|
|
4712
|
-
(entry) => entry.queueReceiptId === receipt.receiptId,
|
|
4713
|
-
);
|
|
4714
|
-
if (
|
|
4715
|
-
persistedReceiptEntries.length !== sourceUpdateIds.size ||
|
|
4716
|
-
persistedReceiptEntries.some(
|
|
4717
|
-
(entry) =>
|
|
4718
|
-
!sourceUpdateIds.has(entry.updateId) ||
|
|
4719
|
-
entry.state !== "queued" ||
|
|
4720
|
-
entry.queueKind !== receipt.queueKind ||
|
|
4721
|
-
!entry.queueOwner ||
|
|
4722
|
-
entry.queueHandoff !== undefined ||
|
|
4723
|
-
!areTelegramUpdateJournalQueueOwnersEqual(
|
|
4724
|
-
entry.queueOwner,
|
|
4725
|
-
queueOwner,
|
|
4726
|
-
),
|
|
4727
|
-
)
|
|
4728
|
-
) {
|
|
4729
|
-
throw createJournalError(
|
|
4730
|
-
"conflict",
|
|
4731
|
-
path,
|
|
4732
|
-
`cannot complete stale or foreign queue receipt ${receipt.receiptId}`,
|
|
4733
|
-
);
|
|
4734
|
-
}
|
|
4735
|
-
}
|
|
4736
|
-
const removedUpdateIds = current.file.entries
|
|
4737
|
-
.filter((entry) => requestedUpdateIds.has(entry.updateId))
|
|
4738
|
-
.map((entry) => entry.updateId);
|
|
4739
|
-
if (removedUpdateIds.length !== requestedUpdateIds.size) {
|
|
4740
|
-
throw createJournalError(
|
|
4741
|
-
"conflict",
|
|
4742
|
-
path,
|
|
4743
|
-
"queued completion did not resolve every source update",
|
|
4744
|
-
);
|
|
4745
|
-
}
|
|
4746
|
-
const published = publishMutation(
|
|
4747
|
-
current,
|
|
4748
|
-
current.file.entries.filter(
|
|
4749
|
-
(entry) => !requestedUpdateIds.has(entry.updateId),
|
|
4750
|
-
),
|
|
4751
|
-
true,
|
|
4752
|
-
);
|
|
4753
|
-
return {
|
|
4754
|
-
removedUpdateIds,
|
|
4755
|
-
entryCount: published.file.entries.length,
|
|
4756
|
-
serializedBytes: published.serializedBytes,
|
|
4757
|
-
};
|
|
4758
|
-
});
|
|
4759
|
-
},
|
|
5481
|
+
completeQueued(receipts) { return completeQueued(receipts); },
|
|
5482
|
+
completeQueuedExact(receipts, completions) { return completeQueued(receipts, completions, true); },
|
|
4760
5483
|
discardQueued(input) {
|
|
4761
5484
|
return runMutation((readCurrent) => {
|
|
4762
5485
|
if (
|
|
@@ -4957,53 +5680,186 @@ function createJournalStoreCore(
|
|
|
4957
5680
|
};
|
|
4958
5681
|
});
|
|
4959
5682
|
},
|
|
4960
|
-
|
|
5683
|
+
inspectAbandonedPending(updateId) {
|
|
4961
5684
|
return runMutation((readCurrent) => {
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
for (const updateId of updateIds) {
|
|
4965
|
-
if (!isSafeNonNegativeInteger(updateId)) {
|
|
4966
|
-
throw createJournalError(
|
|
4967
|
-
"invalid",
|
|
4968
|
-
path,
|
|
4969
|
-
"received an invalid removal update id",
|
|
4970
|
-
);
|
|
4971
|
-
}
|
|
4972
|
-
requestedIds.add(updateId);
|
|
5685
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
5686
|
+
throw createJournalError("unsupported-version", path, "abandonment inspection requires the legacy v1 source");
|
|
4973
5687
|
}
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
);
|
|
4977
|
-
const
|
|
4978
|
-
|
|
4979
|
-
)
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
5688
|
+
if (!isSafeNonNegativeInteger(updateId)) throw createJournalError("invalid", path, "invalid abandonment update id");
|
|
5689
|
+
// Inspection never repairs or quarantines source evidence.
|
|
5690
|
+
const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
|
|
5691
|
+
const matches = (current.file.operatorDispositions ?? []).filter(item => item.updateId === updateId);
|
|
5692
|
+
const disposition = matches[0];
|
|
5693
|
+
if (matches.length !== 1 || !disposition || !("dispositionKind" in disposition) ||
|
|
5694
|
+
disposition.dispositionKind !== "legacy-custody" || disposition.action !== "discard" ||
|
|
5695
|
+
disposition.failureId !== `abandon-${disposition.evidenceSha256}` ||
|
|
5696
|
+
current.file.entries.some(entry => entry.updateId === updateId)) return undefined;
|
|
5697
|
+
const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
5698
|
+
const retainedPath = join(`${path}.retained`, `${disposition.failureId}.json`);
|
|
5699
|
+
let stat;
|
|
5700
|
+
try { stat = lstatSync(retainedPath); }
|
|
5701
|
+
catch (error) {
|
|
5702
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw createJournalError("conflict", retainedPath, "committed abandonment lost its retained input");
|
|
5703
|
+
throw error;
|
|
4988
5704
|
}
|
|
4989
|
-
|
|
4990
|
-
(
|
|
4991
|
-
|
|
4992
|
-
const
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5705
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
|
|
5706
|
+
throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
|
|
5707
|
+
}
|
|
5708
|
+
const parsed: unknown = JSON.parse(readFileSync(retainedPath, "utf8"));
|
|
5709
|
+
if (!isRecord(parsed)) throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
|
|
5710
|
+
const entry = validateJournalEntry(parsed.entry, retainedPath, version);
|
|
5711
|
+
const reference = createPendingRetentionReference({ path, journalBindingKey, entry, maxBytes });
|
|
5712
|
+
const retained = reference.read();
|
|
5713
|
+
if (entry.updateId !== updateId || reference.retainedPath !== retainedPath ||
|
|
5714
|
+
!isDeepStrictEqual(retained?.requestedDisposition, disposition)) {
|
|
5715
|
+
throw createJournalError("conflict", retainedPath, "retained input does not match committed abandonment");
|
|
5716
|
+
}
|
|
5717
|
+
return { journalBindingKey, updateId, retainedPath, operatorAuthorityId: disposition.operatorAuthorityId };
|
|
5718
|
+
});
|
|
5719
|
+
},
|
|
5720
|
+
inspectPendingRetention(entry) {
|
|
5721
|
+
return runMutation(() => {
|
|
5722
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
5723
|
+
throw createJournalError("unsupported-version", path, "pending retention requires the legacy v1 source");
|
|
5724
|
+
}
|
|
5725
|
+
const expected = validateJournalEntry(entry, path, version);
|
|
5726
|
+
if (expected.state !== "pending") throw createJournalError("conflict", path, "retention inspection requires a pending source");
|
|
5727
|
+
const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
5728
|
+
const reference = createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
|
|
5729
|
+
const retained = reference.read();
|
|
5730
|
+
return retained ? { journalBindingKey, retainedPath: reference.retainedPath,
|
|
5731
|
+
requestedDisposition: retained.requestedDisposition } : undefined;
|
|
5732
|
+
});
|
|
5733
|
+
},
|
|
5734
|
+
abandonPending(input) {
|
|
5735
|
+
return runMutation((readCurrent) => {
|
|
5736
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
5737
|
+
throw createJournalError("unsupported-version", path, "pending abandonment requires the legacy v1 source");
|
|
5738
|
+
}
|
|
5739
|
+
const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
5740
|
+
if (input.journalBindingKey !== journalBindingKey) {
|
|
5741
|
+
throw createJournalError("identity-mismatch", path, "pending abandonment belongs to another source");
|
|
5742
|
+
}
|
|
5743
|
+
if (!isBoundedString(input.operatorAuthorityId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
5744
|
+
!input.operatorAuthorityId.trim() || typeof input.isCurrent !== "function") {
|
|
5745
|
+
throw createJournalError("invalid", path, "pending abandonment requires explicit operator authority");
|
|
5746
|
+
}
|
|
5747
|
+
const assertCurrent = () => {
|
|
5748
|
+
if (!input.isCurrent()) throw createJournalError("conflict", path, "pending abandonment lost authority");
|
|
5749
|
+
};
|
|
5750
|
+
assertCurrent();
|
|
5751
|
+
const expected = validateJournalEntry(input.entry, path, version);
|
|
5752
|
+
if (expected.state !== "pending" || expected.inputClaim || expected.inputProvenance ||
|
|
5753
|
+
expected.queueOwner || expected.queueHandoff || expected.queueReceiptId || expected.failure) {
|
|
5754
|
+
throw createJournalError("conflict", path, "only an exact unclaimed pending source may be abandoned");
|
|
5755
|
+
}
|
|
5756
|
+
// Never repair/quarantine source evidence while deciding an operator cancellation.
|
|
5757
|
+
const readStrict = sourceAccess ? readCurrent : () => readCurrentStrict(false);
|
|
5758
|
+
const current = readStrict();
|
|
5759
|
+
const { evidenceSha256, failureId, retainedPath, read: readRetention } =
|
|
5760
|
+
createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
|
|
5761
|
+
const existing = current.file.operatorDispositions?.find(item =>
|
|
5762
|
+
item.failureId === failureId || item.updateId === expected.updateId);
|
|
5763
|
+
const currentEntry = current.file.entries.find(entry => entry.updateId === expected.updateId);
|
|
5764
|
+
const assertRetention = (retained: TelegramUpdateJournalRetainedInput | undefined,
|
|
5765
|
+
disposition: TelegramUpdateJournalLegacyCustodyDisposition): void => {
|
|
5766
|
+
if (!retained || !isDeepStrictEqual(retained.requestedDisposition, disposition)) {
|
|
5767
|
+
throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
|
|
5768
|
+
}
|
|
5003
5769
|
};
|
|
5770
|
+
if (existing) {
|
|
5771
|
+
if (currentEntry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" ||
|
|
5772
|
+
existing.failureId !== failureId || existing.action !== "discard" || existing.evidenceSha256 !== evidenceSha256) {
|
|
5773
|
+
throw createJournalError("conflict", path, "pending source already has another disposition");
|
|
5774
|
+
}
|
|
5775
|
+
assertRetention(readRetention(), existing);
|
|
5776
|
+
assertCurrent();
|
|
5777
|
+
return { disposition: existing, retainedPath, duplicate: true,
|
|
5778
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
5779
|
+
}
|
|
5780
|
+
if (!currentEntry || !isDeepStrictEqual(currentEntry, expected)) {
|
|
5781
|
+
throw createJournalError("conflict", path, "pending source changed before abandonment");
|
|
5782
|
+
}
|
|
5783
|
+
const nowMs = getNowMs();
|
|
5784
|
+
if (!isSafeNonNegativeInteger(nowMs)) throw createJournalError("invalid", path, "invalid abandonment timestamp");
|
|
5785
|
+
const retained = readRetention();
|
|
5786
|
+
let disposition: TelegramUpdateJournalLegacyCustodyDisposition;
|
|
5787
|
+
if (retained) {
|
|
5788
|
+
disposition = retained.requestedDisposition;
|
|
5789
|
+
} else {
|
|
5790
|
+
// Reuse the existing legacy discard tombstone so older v1 readers also refuse replay.
|
|
5791
|
+
// This is an operator disposition, not a fabricated execution failure or completion.
|
|
5792
|
+
disposition = { dispositionKind: "legacy-custody", failureId, updateId: expected.updateId,
|
|
5793
|
+
action: "discard", committedAtMs: nowMs, evidenceSha256,
|
|
5794
|
+
operatorAuthorityId: input.operatorAuthorityId, authorizedAtMs: nowMs };
|
|
5795
|
+
const copy: TelegramUpdateJournalRetainedInput = { version: 1, kind: "pending-input-retention",
|
|
5796
|
+
journalBindingKey, entry: expected, requestedDisposition: disposition };
|
|
5797
|
+
writeJournalFile(retainedPath, `${JSON.stringify(copy)}\n`, (boundary, target) => {
|
|
5798
|
+
onPublicationBoundary?.(boundary, target);
|
|
5799
|
+
assertCurrent();
|
|
5800
|
+
});
|
|
5801
|
+
}
|
|
5802
|
+
assertCurrent();
|
|
5803
|
+
assertRetention(readRetention(), disposition);
|
|
5804
|
+
const committed = (snapshot: ReadTelegramUpdateJournalResult) =>
|
|
5805
|
+
!snapshot.file.entries.some(entry => entry.updateId === expected.updateId) &&
|
|
5806
|
+
snapshot.file.operatorDispositions?.some(item => isDeepStrictEqual(item, disposition));
|
|
5807
|
+
try {
|
|
5808
|
+
const published = publishMutation(current,
|
|
5809
|
+
current.file.entries.filter(entry => entry.updateId !== expected.updateId), true,
|
|
5810
|
+
[...(current.file.operatorDispositions ?? []), disposition], current.file.acceptedThroughUpdateId,
|
|
5811
|
+
(boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
|
|
5812
|
+
return { disposition, retainedPath, duplicate: false,
|
|
5813
|
+
entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
5814
|
+
} catch (error) {
|
|
5815
|
+
// A segment may be durable even if later snapshot compaction failed.
|
|
5816
|
+
// Only exact read-back can turn that unknown acknowledgement into success.
|
|
5817
|
+
let observed: ReadTelegramUpdateJournalResult;
|
|
5818
|
+
try { observed = readStrict(); } catch { throw error; }
|
|
5819
|
+
if (!committed(observed)) throw error;
|
|
5820
|
+
assertRetention(readRetention(), disposition);
|
|
5821
|
+
return { disposition, retainedPath, duplicate: false,
|
|
5822
|
+
entryCount: observed.file.entries.length, serializedBytes: observed.serializedBytes };
|
|
5823
|
+
}
|
|
5824
|
+
});
|
|
5825
|
+
},
|
|
5826
|
+
removeCompleted(updateIds) { return removeCompleted(updateIds); },
|
|
5827
|
+
removeCompletedExact(updateIds, expectedSources, completions) { return removeCompleted(updateIds, expectedSources, true, completions); },
|
|
5828
|
+
inspectQueuedReceipt(expected) {
|
|
5829
|
+
return runMutationCore(readCurrent => {
|
|
5830
|
+
if (!sourceAccess || !isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
5831
|
+
throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
|
|
5832
|
+
}
|
|
5833
|
+
const queueOwner = validateQueuedReceiptObservation(expected, path);
|
|
5834
|
+
const current = readCurrent();
|
|
5835
|
+
return inspectJournalQueuedReceipt(current.file, { ...expected, queueOwner }, path);
|
|
5836
|
+
});
|
|
5837
|
+
},
|
|
5838
|
+
inspectSourceCompletion(expected) {
|
|
5839
|
+
return runMutation(readCurrent => {
|
|
5840
|
+
if (!sourceAccess) throw createJournalError("invalid", path, "source completion requires an exact source handle");
|
|
5841
|
+
const completion = validateJournalSourceCompletion(expected, path);
|
|
5842
|
+
const current = readCurrent();
|
|
5843
|
+
return inspectJournalSourceCompletion(current.file, completion, path);
|
|
5004
5844
|
});
|
|
5005
5845
|
},
|
|
5006
5846
|
};
|
|
5847
|
+
const abandonRoutingPending = journal.abandonPending.bind(journal);
|
|
5848
|
+
if (isTelegramUpdateJournalLegacyFamilyVersion(version)) journal.routingInputs = {
|
|
5849
|
+
arm(input) { return mutateRoutingInputs(input, false, input.publishedAtMs).entries; },
|
|
5850
|
+
select(input) { return mutateRoutingInputs(input, true); },
|
|
5851
|
+
expire(input) {
|
|
5852
|
+
const original = validateJournalEntry(input.entry, path, version), lifetime = original.routingInput;
|
|
5853
|
+
if (!lifetime || lifetime.phase !== "waiting" || lifetime.operatorUserId !== input.operatorUserId ||
|
|
5854
|
+
!isSafePositiveInteger(input.operatorUserId) || typeof input.isCurrent !== "function") {
|
|
5855
|
+
throw createJournalError("conflict", path, "routing input expiry lacks exact waiting authority");
|
|
5856
|
+
}
|
|
5857
|
+
const current = () => { const now = getNowMs(); return isSafeNonNegativeInteger(now) && now >= lifetime.expiresAtMs && input.isCurrent(); };
|
|
5858
|
+
if (!current()) throw createJournalError("conflict", path, "routing input has not expired under current authority");
|
|
5859
|
+
return abandonRoutingPending({ journalBindingKey: input.journalBindingKey, entry: original,
|
|
5860
|
+
operatorAuthorityId: `telegram-owner:${input.operatorUserId}`, isCurrent: current });
|
|
5861
|
+
},
|
|
5862
|
+
};
|
|
5007
5863
|
if (!getInputContext) return { journal };
|
|
5008
5864
|
// Strict source acquisition already requires this exact stored receipt scope.
|
|
5009
5865
|
const bindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
@@ -5178,7 +6034,7 @@ function createJournalStoreCore(
|
|
|
5178
6034
|
const evidence = readTelegramUpdateJournalSource({
|
|
5179
6035
|
directory: sourceAccess.directory, path, profile,
|
|
5180
6036
|
botIdentity: expectedIdentity, limits: sourceAccess.limits,
|
|
5181
|
-
version
|
|
6037
|
+
version,
|
|
5182
6038
|
});
|
|
5183
6039
|
return evidence.kind === "present"
|
|
5184
6040
|
? listTelegramUpdateJournalLegacyCustodyCandidates(evidence.file) : [];
|