@llblab/pi-telegram 0.45.11 → 0.47.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 +5 -1
- package/CHANGELOG.md +12 -0
- package/README.md +6 -6
- package/dist/api/activity.d.ts +6 -0
- package/dist/api/activity.js +6 -0
- package/dist/api/commands.d.ts +6 -0
- package/dist/api/commands.js +6 -0
- package/dist/api/delivery.d.ts +6 -0
- package/dist/api/delivery.js +6 -0
- package/dist/api/inbound.d.ts +6 -0
- package/dist/api/inbound.js +6 -0
- package/dist/api/keyboard.d.ts +6 -0
- package/dist/api/keyboard.js +6 -0
- package/dist/api/outbound.d.ts +6 -0
- package/dist/api/outbound.js +6 -0
- package/dist/api/sections.d.ts +7 -0
- package/dist/api/sections.js +6 -0
- package/dist/api/status.d.ts +6 -0
- package/dist/api/status.js +6 -0
- package/dist/api/updates.d.ts +6 -0
- package/dist/api/updates.js +6 -0
- package/dist/api/voice.d.ts +6 -0
- package/dist/api/voice.js +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/lib/activity-verbosity.d.ts +52 -0
- package/dist/lib/activity-verbosity.js +596 -0
- package/dist/lib/activity.d.ts +220 -0
- package/dist/lib/activity.js +574 -0
- package/dist/lib/agent-messages.d.ts +28 -0
- package/dist/lib/agent-messages.js +86 -0
- package/dist/lib/bindings.d.ts +250 -0
- package/dist/lib/bindings.js +936 -0
- package/dist/lib/bus-api.d.ts +18 -0
- package/dist/lib/bus-api.js +254 -0
- package/dist/lib/bus-follower.d.ts +464 -0
- package/dist/lib/bus-follower.js +1686 -0
- package/dist/lib/bus-leader.d.ts +292 -0
- package/dist/lib/bus-leader.js +2242 -0
- package/dist/lib/bus-transport.d.ts +64 -0
- package/dist/lib/bus-transport.js +140 -0
- package/dist/lib/bus.d.ts +518 -0
- package/dist/lib/bus.js +2055 -0
- package/dist/lib/channel-posts.d.ts +170 -0
- package/dist/lib/channel-posts.js +611 -0
- package/dist/lib/command-templates.d.ts +70 -0
- package/dist/lib/command-templates.js +744 -0
- package/dist/lib/commands.d.ts +541 -0
- package/dist/lib/commands.js +1155 -0
- package/dist/lib/config.d.ts +252 -0
- package/dist/lib/config.js +889 -0
- package/dist/lib/delivery.d.ts +163 -0
- package/dist/lib/delivery.js +542 -0
- package/dist/lib/extension.d.ts +7 -0
- package/dist/lib/extension.js +1608 -0
- package/dist/lib/generative-app-worker.mjs +104 -0
- package/dist/lib/generative-apps.d.ts +212 -0
- package/dist/lib/generative-apps.js +1006 -0
- package/dist/lib/inbound.d.ts +91 -0
- package/dist/lib/inbound.js +502 -0
- package/dist/lib/journal.d.ts +665 -0
- package/dist/lib/journal.js +3701 -0
- package/dist/lib/keyboard.d.ts +23 -0
- package/dist/lib/keyboard.js +37 -0
- package/dist/lib/lifecycle.d.ts +157 -0
- package/dist/lib/lifecycle.js +395 -0
- package/dist/lib/locks.d.ts +164 -0
- package/dist/lib/locks.js +1208 -0
- package/dist/lib/logging.d.ts +50 -0
- package/dist/lib/logging.js +274 -0
- package/dist/lib/media.d.ts +181 -0
- package/dist/lib/media.js +602 -0
- package/dist/lib/menu-model.d.ts +217 -0
- package/dist/lib/menu-model.js +663 -0
- package/dist/lib/menu-queue.d.ts +37 -0
- package/dist/lib/menu-queue.js +408 -0
- package/dist/lib/menu-settings.d.ts +116 -0
- package/dist/lib/menu-settings.js +595 -0
- package/dist/lib/menu-status.d.ts +32 -0
- package/dist/lib/menu-status.js +112 -0
- package/dist/lib/menu-thinking.d.ts +29 -0
- package/dist/lib/menu-thinking.js +82 -0
- package/dist/lib/menu.d.ts +171 -0
- package/dist/lib/menu.js +323 -0
- package/dist/lib/model.d.ts +127 -0
- package/dist/lib/model.js +409 -0
- package/dist/lib/outbound-attachments.d.ts +241 -0
- package/dist/lib/outbound-attachments.js +639 -0
- package/dist/lib/outbound-buttons.d.ts +69 -0
- package/dist/lib/outbound-buttons.js +248 -0
- package/dist/lib/outbound-markup.d.ts +42 -0
- package/dist/lib/outbound-markup.js +678 -0
- package/dist/lib/outbound-voice.d.ts +55 -0
- package/dist/lib/outbound-voice.js +152 -0
- package/dist/lib/outbound.d.ts +185 -0
- package/dist/lib/outbound.js +516 -0
- package/dist/lib/ownership.d.ts +77 -0
- package/dist/lib/ownership.js +174 -0
- package/dist/lib/paths.d.ts +40 -0
- package/dist/lib/paths.js +94 -0
- package/dist/lib/pi.d.ts +72 -0
- package/dist/lib/pi.js +121 -0
- package/dist/lib/polling.d.ts +351 -0
- package/dist/lib/polling.js +1196 -0
- package/dist/lib/preview.d.ts +192 -0
- package/dist/lib/preview.js +614 -0
- package/dist/lib/prompt-templates.d.ts +24 -0
- package/dist/lib/prompt-templates.js +118 -0
- package/dist/lib/prompts.d.ts +57 -0
- package/dist/lib/prompts.js +167 -0
- package/dist/lib/queue.d.ts +766 -0
- package/dist/lib/queue.js +1965 -0
- package/dist/lib/recovery.d.ts +86 -0
- package/dist/lib/recovery.js +285 -0
- package/dist/lib/rendering.d.ts +20 -0
- package/dist/lib/rendering.js +983 -0
- package/dist/lib/replies.d.ts +214 -0
- package/dist/lib/replies.js +737 -0
- package/dist/lib/routing.d.ts +207 -0
- package/dist/lib/routing.js +2282 -0
- package/dist/lib/runtime.d.ts +172 -0
- package/dist/lib/runtime.js +402 -0
- package/dist/lib/sections.d.ts +165 -0
- package/dist/lib/sections.js +327 -0
- package/dist/lib/setup.d.ts +82 -0
- package/dist/lib/setup.js +150 -0
- package/dist/lib/skills.d.ts +8 -0
- package/dist/lib/skills.js +12 -0
- package/dist/lib/status.d.ts +442 -0
- package/dist/lib/status.js +1006 -0
- package/dist/lib/sync.d.ts +179 -0
- package/dist/lib/sync.js +782 -0
- package/dist/lib/target.d.ts +20 -0
- package/dist/lib/target.js +27 -0
- package/dist/lib/telegram-api.d.ts +541 -0
- package/dist/lib/telegram-api.js +1159 -0
- package/dist/lib/text-groups.d.ts +89 -0
- package/dist/lib/text-groups.js +317 -0
- package/dist/lib/thread-cleanup-manager.d.ts +288 -0
- package/dist/lib/thread-cleanup-manager.js +560 -0
- package/dist/lib/thread-display.d.ts +46 -0
- package/dist/lib/thread-display.js +257 -0
- package/dist/lib/thread-naming.d.ts +46 -0
- package/dist/lib/thread-naming.js +78 -0
- package/dist/lib/thread-reconciler.d.ts +239 -0
- package/dist/lib/thread-reconciler.js +644 -0
- package/dist/lib/threads.d.ts +621 -0
- package/dist/lib/threads.js +3679 -0
- package/dist/lib/time-injection.d.ts +15 -0
- package/dist/lib/time-injection.js +56 -0
- package/dist/lib/turns.d.ts +109 -0
- package/dist/lib/turns.js +500 -0
- package/dist/lib/updates.d.ts +1290 -0
- package/dist/lib/updates.js +3634 -0
- package/dist/lib/voice.d.ts +117 -0
- package/dist/lib/voice.js +174 -0
- package/dist/lib/workspace-admission.d.ts +264 -0
- package/dist/lib/workspace-admission.js +1136 -0
- package/dist/lib/workspace-retirement.d.ts +219 -0
- package/dist/lib/workspace-retirement.js +587 -0
- package/dist/lib/workspace-slots.d.ts +30 -0
- package/dist/lib/workspace-slots.js +54 -0
- package/dist/package.json +126 -0
- package/dist/pi-telegram/index.js +1 -0
- package/dist/skills/generated-control-surface/SKILL.md +107 -0
- package/dist/skills/generated-control-surface/references/capability-adapters.md +27 -0
- package/dist/skills/generated-control-surface/references/layout-and-state.md +37 -0
- package/dist/skills/generative-apps/SKILL.md +115 -0
- package/dist/skills/show-me/SKILL.md +166 -0
- package/dist/skills/show-me/references/telegram-surfaces.md +43 -0
- package/dist/skills/telegram-bridge/SKILL.md +129 -0
- package/dist/skills/telegram-bridge/references/configuration.md +15 -0
- package/dist/skills/telegram-bridge/references/delivery-and-threads.md +27 -0
- package/dist/skills/telegram-bridge/references/diagnosis.md +18 -0
- package/docs/architecture.md +1 -1
- package/docs/compact-matrix-literal.md +10 -3
- package/docs/generative-apps.md +15 -13
- package/docs/multi-instance-bus.md +17 -3
- package/docs/outbound.md +1 -1
- package/docs/public-api.md +2 -2
- package/lib/bindings.ts +89 -6
- package/lib/bus-follower.ts +11 -3
- package/lib/bus-leader.ts +57 -20
- package/lib/bus.ts +12 -2
- package/lib/commands.ts +2 -2
- package/lib/config.ts +14 -7
- package/lib/delivery.ts +38 -6
- package/lib/extension.ts +26 -1
- package/lib/generative-apps.ts +379 -13
- package/lib/menu-settings.ts +21 -5
- package/lib/outbound-markup.ts +16 -7
- package/lib/pi.ts +4 -0
- package/lib/sync.ts +3 -0
- package/lib/telegram-api.ts +12 -1
- package/lib/thread-cleanup-manager.ts +36 -6
- package/lib/thread-display.ts +96 -18
- package/lib/threads.ts +150 -30
- package/package.json +56 -13
- package/scripts/build-dist.mjs +44 -0
- package/scripts/measure-bus.mjs +8 -1
- package/scripts/measure-workspace.mjs +8 -1
- package/skills/generative-apps/SKILL.md +1 -1
- package/skills/show-me/SKILL.md +1 -1
- package/skills/telegram-bridge/SKILL.md +1 -1
|
@@ -0,0 +1,3701 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram durable inbound update journal
|
|
3
|
+
* Zones: telegram inbound, filesystem authority, crash recovery
|
|
4
|
+
* Owns profile/bot-scoped raw updates, schema validation, deduplication,
|
|
5
|
+
* bounded atomic publication, durable queue-receipt/failure state, and compaction.
|
|
6
|
+
* It does not own polling, update execution, queue admission, or follower routing.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
9
|
+
import { chmodSync, constants, closeSync, fstatSync, lstatSync, openSync, opendirSync, readSync, realpathSync, mkdirSync, readFileSync, readdirSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
10
|
+
import { basename, dirname, join, resolve, relative, isAbsolute, sep } from "node:path";
|
|
11
|
+
import { isDeepStrictEqual } from "node:util";
|
|
12
|
+
import { renameTelegramPathWithRetry, withTelegramFileTransaction, } from "./locks.js";
|
|
13
|
+
import { getTelegramProcessLiveness, } from "./bus.js";
|
|
14
|
+
import { getTelegramProfilePathSuffix, TELEGRAM_DEFAULT_PROFILE_NAME, } from "./paths.js";
|
|
15
|
+
import { runWithTelegramWorkspaceAdmissions, } from "./workspace-admission.js";
|
|
16
|
+
export const TELEGRAM_UPDATE_JOURNAL_VERSION = 1;
|
|
17
|
+
export const TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION = 2;
|
|
18
|
+
export const TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION = 3;
|
|
19
|
+
// Receipt and binding identities survive storage-schema upgrades.
|
|
20
|
+
const TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION = 1;
|
|
21
|
+
export const TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES = 10_000;
|
|
22
|
+
export const TELEGRAM_UPDATE_JOURNAL_MAX_BYTES = 32 * 1024 * 1024;
|
|
23
|
+
export const TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH = 128;
|
|
24
|
+
export const TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH = 256;
|
|
25
|
+
export const TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH = 256;
|
|
26
|
+
const TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES = 4 * 1024;
|
|
27
|
+
const TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES = 64;
|
|
28
|
+
export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH = 128;
|
|
29
|
+
export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH = 32;
|
|
30
|
+
export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH = 256;
|
|
31
|
+
export const TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH = 128;
|
|
32
|
+
export const TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH = 512;
|
|
33
|
+
function escapeTelegramJournalPathPattern(value) {
|
|
34
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
35
|
+
}
|
|
36
|
+
/** Read-only discovery for canonical follower journal snapshots and segment roots. */
|
|
37
|
+
export function discoverTelegramFollowerJournalPaths(input) {
|
|
38
|
+
const suffix = escapeTelegramJournalPathPattern(getTelegramProfilePathSuffix(input.profileName));
|
|
39
|
+
const pattern = new RegExp(`^(follower-inbox-[a-f0-9]{16}${suffix}\\.json)(?:\\.segments)?$`, "u");
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = readdirSync(input.directory, { withFileTypes: true });
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
return error.code === "ENOENT"
|
|
46
|
+
? { paths: [], complete: true }
|
|
47
|
+
: { paths: [], complete: false };
|
|
48
|
+
}
|
|
49
|
+
const paths = new Set();
|
|
50
|
+
let complete = true;
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
const match = pattern.exec(entry.name);
|
|
53
|
+
if (!match)
|
|
54
|
+
continue;
|
|
55
|
+
const candidatePath = join(input.directory, entry.name);
|
|
56
|
+
const expectsDirectory = entry.name.endsWith(".segments");
|
|
57
|
+
try {
|
|
58
|
+
const stat = statSync(candidatePath);
|
|
59
|
+
if (expectsDirectory ? !stat.isDirectory() : !stat.isFile()) {
|
|
60
|
+
complete = false;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
paths.add(join(input.directory, match[1]));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
complete = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { paths: Array.from(paths).sort(), complete };
|
|
70
|
+
}
|
|
71
|
+
export const TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH = 256;
|
|
72
|
+
export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_COUNT = 256;
|
|
73
|
+
export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_BYTES = 4 * 1024 * 1024;
|
|
74
|
+
export class TelegramUpdateJournalError extends Error {
|
|
75
|
+
code;
|
|
76
|
+
path;
|
|
77
|
+
constructor(code, path, message, options = {}) {
|
|
78
|
+
super(message, options);
|
|
79
|
+
this.name = "TelegramUpdateJournalError";
|
|
80
|
+
this.code = code;
|
|
81
|
+
this.path = path;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const TELEGRAM_UPDATE_CHAT_CARRIERS = new Set([
|
|
85
|
+
"message",
|
|
86
|
+
"edited_message",
|
|
87
|
+
"channel_post",
|
|
88
|
+
"edited_channel_post",
|
|
89
|
+
"business_message",
|
|
90
|
+
"edited_business_message",
|
|
91
|
+
"message_reaction",
|
|
92
|
+
"message_reaction_count",
|
|
93
|
+
"my_chat_member",
|
|
94
|
+
"chat_member",
|
|
95
|
+
"chat_join_request",
|
|
96
|
+
"chat_boost",
|
|
97
|
+
"removed_chat_boost",
|
|
98
|
+
"deleted_business_messages",
|
|
99
|
+
]);
|
|
100
|
+
function getUpdateCarrierScope(value) {
|
|
101
|
+
if (!isRecord(value) || !isRecord(value.chat))
|
|
102
|
+
return undefined;
|
|
103
|
+
const chatId = value.chat.id;
|
|
104
|
+
if (!Number.isSafeInteger(chatId) || chatId === 0)
|
|
105
|
+
return undefined;
|
|
106
|
+
const threadId = value.message_thread_id;
|
|
107
|
+
if (threadId === undefined)
|
|
108
|
+
return { kind: "chat", chatId: chatId };
|
|
109
|
+
if (!Number.isSafeInteger(threadId) || threadId <= 0) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
kind: "target",
|
|
114
|
+
target: { chatId: chatId, threadId: threadId },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function getUpdateAdmissionScope(update) {
|
|
118
|
+
const payloadKeys = Object.keys(update).filter((key) => key !== "update_id");
|
|
119
|
+
if (payloadKeys.length !== 1)
|
|
120
|
+
return { kind: "profile" };
|
|
121
|
+
const payloadKey = payloadKeys[0];
|
|
122
|
+
if (TELEGRAM_UPDATE_CHAT_CARRIERS.has(payloadKey)) {
|
|
123
|
+
return getUpdateCarrierScope(update[payloadKey]) ?? { kind: "profile" };
|
|
124
|
+
}
|
|
125
|
+
if (payloadKey === "callback_query") {
|
|
126
|
+
const query = update.callback_query;
|
|
127
|
+
return isRecord(query)
|
|
128
|
+
? getUpdateCarrierScope(query.message) ?? { kind: "profile" }
|
|
129
|
+
: { kind: "profile" };
|
|
130
|
+
}
|
|
131
|
+
return { kind: "profile" };
|
|
132
|
+
}
|
|
133
|
+
function getWorkspaceAdmissionScopeKey(scope) {
|
|
134
|
+
if (scope.kind === "profile")
|
|
135
|
+
return "profile";
|
|
136
|
+
if (scope.kind === "chat")
|
|
137
|
+
return `chat:${scope.chatId}`;
|
|
138
|
+
return `target:${scope.target.chatId}:${scope.target.threadId}`;
|
|
139
|
+
}
|
|
140
|
+
export function getTelegramUpdateJournalAdmissionScopes(updates) {
|
|
141
|
+
if (updates.length === 0)
|
|
142
|
+
return [{ kind: "profile" }];
|
|
143
|
+
const scopes = updates.map(getUpdateAdmissionScope);
|
|
144
|
+
if (scopes.some((scope) => scope.kind === "profile")) {
|
|
145
|
+
return [{ kind: "profile" }];
|
|
146
|
+
}
|
|
147
|
+
const chatIds = new Set(scopes
|
|
148
|
+
.filter((scope) => scope.kind === "chat")
|
|
149
|
+
.map((scope) => scope.chatId));
|
|
150
|
+
const unique = new Map();
|
|
151
|
+
for (const scope of scopes) {
|
|
152
|
+
if (scope.kind === "target" && chatIds.has(scope.target.chatId))
|
|
153
|
+
continue;
|
|
154
|
+
unique.set(getWorkspaceAdmissionScopeKey(scope), scope);
|
|
155
|
+
}
|
|
156
|
+
return Array.from(unique.entries())
|
|
157
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
158
|
+
.map(([, scope]) => scope);
|
|
159
|
+
}
|
|
160
|
+
export function listTelegramUpdateJournalLegacyCustodyCandidates(snapshot) {
|
|
161
|
+
return snapshot.entries.flatMap(entry => {
|
|
162
|
+
const evidence = createTelegramUpdateJournalLegacyCustodyEvidence(entry);
|
|
163
|
+
return evidence ? [{ updateId: evidence.updateId, state: evidence.state,
|
|
164
|
+
attemptCount: evidence.attemptCount, failureClass: evidence.failureClass,
|
|
165
|
+
evidenceSha256: evidence.evidenceSha256 }] : [];
|
|
166
|
+
}).sort((left, right) => left.updateId - right.updateId);
|
|
167
|
+
}
|
|
168
|
+
export function createTelegramUpdateJournalLegacyCustodyEvidence(entry) {
|
|
169
|
+
if ((entry.state !== "retry-wait" && entry.state !== "failed") || !entry.failure ||
|
|
170
|
+
entry.inputClaim || entry.inputProvenance)
|
|
171
|
+
return undefined;
|
|
172
|
+
const base = { updateId: entry.updateId, state: entry.state,
|
|
173
|
+
attemptCount: entry.failure.attemptCount, failedAtMs: entry.failure.failedAtMs,
|
|
174
|
+
failureClass: entry.failure.failureClass, summary: entry.failure.summary,
|
|
175
|
+
...(entry.nextRetryAtMs === undefined ? {} : { nextRetryAtMs: entry.nextRetryAtMs }),
|
|
176
|
+
...(entry.terminalAtMs === undefined ? {} : { terminalAtMs: entry.terminalAtMs }),
|
|
177
|
+
...(entry.terminalReason === undefined ? {} : { terminalReason: entry.terminalReason }),
|
|
178
|
+
...(entry.terminalFailureId === undefined ? {} : { terminalFailureId: entry.terminalFailureId }) };
|
|
179
|
+
return { ...base, evidenceSha256: createHash("sha256").update(JSON.stringify(base)).digest("hex") };
|
|
180
|
+
}
|
|
181
|
+
export function normalizeTelegramUpdateJournalLegacyCustodyDispositionAuthority(value, expected) {
|
|
182
|
+
if (!isRecord(value))
|
|
183
|
+
return undefined;
|
|
184
|
+
const keys = ["version", "dispositionId", "updateId", "evidenceSha256", "action",
|
|
185
|
+
"operatorAuthorityId", "authorizedAtMs"];
|
|
186
|
+
if (Object.keys(value).some(key => !keys.includes(key)) || value.version !== 1 ||
|
|
187
|
+
!isBoundedString(value.dispositionId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
188
|
+
value.updateId !== expected.updateId || value.evidenceSha256 !== expected.evidenceSha256 ||
|
|
189
|
+
(value.action !== "requeue-v3" && value.action !== "discard") ||
|
|
190
|
+
!isBoundedString(value.operatorAuthorityId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
191
|
+
!isSafeNonNegativeInteger(value.authorizedAtMs))
|
|
192
|
+
return undefined;
|
|
193
|
+
return { version: 1, dispositionId: value.dispositionId, updateId: expected.updateId,
|
|
194
|
+
evidenceSha256: expected.evidenceSha256, action: value.action,
|
|
195
|
+
operatorAuthorityId: value.operatorAuthorityId, authorizedAtMs: value.authorizedAtMs };
|
|
196
|
+
}
|
|
197
|
+
function createJournalError(code, path, detail, cause) {
|
|
198
|
+
return new TelegramUpdateJournalError(code, path, `Telegram update journal ${detail}: ${path}`, cause === undefined ? undefined : { cause });
|
|
199
|
+
}
|
|
200
|
+
function isRecord(value) {
|
|
201
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
202
|
+
}
|
|
203
|
+
function hasOnlyKeys(value, allowedKeys) {
|
|
204
|
+
const allowed = new Set(allowedKeys);
|
|
205
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
206
|
+
}
|
|
207
|
+
function isSafeNonNegativeInteger(value) {
|
|
208
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
209
|
+
}
|
|
210
|
+
function isSafePositiveInteger(value) {
|
|
211
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
212
|
+
}
|
|
213
|
+
function isNonEmptyString(value) {
|
|
214
|
+
return typeof value === "string" && value.length > 0;
|
|
215
|
+
}
|
|
216
|
+
function isBoundedString(value, maxLength) {
|
|
217
|
+
return isNonEmptyString(value) && value.length <= maxLength;
|
|
218
|
+
}
|
|
219
|
+
function validateBotIdentity(value, path) {
|
|
220
|
+
if (!isRecord(value) ||
|
|
221
|
+
!hasOnlyKeys(value, ["botId", "tokenSha256"]) ||
|
|
222
|
+
!isNonEmptyString(value.tokenSha256) ||
|
|
223
|
+
!/^[a-f0-9]{64}$/u.test(value.tokenSha256) ||
|
|
224
|
+
(value.botId !== undefined && !isSafePositiveInteger(value.botId))) {
|
|
225
|
+
throw createJournalError("invalid", path, "has invalid bot identity");
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
...(value.botId !== undefined ? { botId: value.botId } : {}),
|
|
229
|
+
tokenSha256: value.tokenSha256,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function validateJournaledUpdate(value, path) {
|
|
233
|
+
if (!isRecord(value) || !isSafeNonNegativeInteger(value.update_id)) {
|
|
234
|
+
throw createJournalError("invalid", path, "contains an update without a safe integer update_id");
|
|
235
|
+
}
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
function normalizeIncomingJournaledUpdate(value, path) {
|
|
239
|
+
let normalized;
|
|
240
|
+
try {
|
|
241
|
+
const serialized = JSON.stringify(value);
|
|
242
|
+
if (serialized === undefined) {
|
|
243
|
+
throw new Error("Update is not JSON serializable.");
|
|
244
|
+
}
|
|
245
|
+
normalized = JSON.parse(serialized);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
throw createJournalError("invalid", path, "received a non-JSON update", error);
|
|
249
|
+
}
|
|
250
|
+
return validateJournaledUpdate(normalized, path);
|
|
251
|
+
}
|
|
252
|
+
function validateJournalQueueOwnerIdentity(value, path) {
|
|
253
|
+
if (!isRecord(value) ||
|
|
254
|
+
!hasOnlyKeys(value, [
|
|
255
|
+
"instanceId",
|
|
256
|
+
"processId",
|
|
257
|
+
"processBirthId",
|
|
258
|
+
"sessionGeneration",
|
|
259
|
+
]) ||
|
|
260
|
+
!isBoundedString(value.instanceId, TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH) ||
|
|
261
|
+
!isSafePositiveInteger(value.processId) ||
|
|
262
|
+
!isBoundedString(value.processBirthId, TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH) ||
|
|
263
|
+
!isSafePositiveInteger(value.sessionGeneration)) {
|
|
264
|
+
throw createJournalError("invalid", path, "contains invalid queue owner identity");
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
instanceId: value.instanceId,
|
|
268
|
+
processId: value.processId,
|
|
269
|
+
processBirthId: value.processBirthId,
|
|
270
|
+
sessionGeneration: value.sessionGeneration,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function validateJournalQueueOwner(value, path) {
|
|
274
|
+
if (!isRecord(value) ||
|
|
275
|
+
!hasOnlyKeys(value, [
|
|
276
|
+
"instanceId",
|
|
277
|
+
"processId",
|
|
278
|
+
"processBirthId",
|
|
279
|
+
"sessionGeneration",
|
|
280
|
+
"acquisitionId",
|
|
281
|
+
"acquiredAtMs",
|
|
282
|
+
"handoffId",
|
|
283
|
+
]) ||
|
|
284
|
+
!isBoundedString(value.acquisitionId, TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH) ||
|
|
285
|
+
!isSafeNonNegativeInteger(value.acquiredAtMs) ||
|
|
286
|
+
(value.handoffId !== undefined &&
|
|
287
|
+
!isBoundedString(value.handoffId, TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH))) {
|
|
288
|
+
throw createJournalError("invalid", path, "contains invalid queue receipt acquisition");
|
|
289
|
+
}
|
|
290
|
+
const identity = validateJournalQueueOwnerIdentity({
|
|
291
|
+
instanceId: value.instanceId,
|
|
292
|
+
processId: value.processId,
|
|
293
|
+
processBirthId: value.processBirthId,
|
|
294
|
+
sessionGeneration: value.sessionGeneration,
|
|
295
|
+
}, path);
|
|
296
|
+
return {
|
|
297
|
+
...identity,
|
|
298
|
+
acquisitionId: value.acquisitionId,
|
|
299
|
+
acquiredAtMs: value.acquiredAtMs,
|
|
300
|
+
...(typeof value.handoffId === "string"
|
|
301
|
+
? { handoffId: value.handoffId }
|
|
302
|
+
: {}),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
export function parseTelegramUpdateJournalQueueOwner(value) {
|
|
306
|
+
try {
|
|
307
|
+
return validateJournalQueueOwner(value, "Telegram queue handoff acknowledgement");
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
export function isTelegramUpdateJournalQueueOwnerProcess(owner, identity) {
|
|
314
|
+
return (owner.instanceId === identity.instanceId &&
|
|
315
|
+
owner.processId === identity.processId &&
|
|
316
|
+
owner.processBirthId === identity.processBirthId);
|
|
317
|
+
}
|
|
318
|
+
export function areTelegramUpdateJournalQueueOwnersEqual(left, right) {
|
|
319
|
+
return (isTelegramUpdateJournalQueueOwnerProcess(left, right) &&
|
|
320
|
+
left.sessionGeneration === right.sessionGeneration &&
|
|
321
|
+
left.acquisitionId === right.acquisitionId &&
|
|
322
|
+
left.acquiredAtMs === right.acquiredAtMs &&
|
|
323
|
+
left.handoffId === right.handoffId);
|
|
324
|
+
}
|
|
325
|
+
function cloneJournalQueueOwner(owner) {
|
|
326
|
+
return { ...owner };
|
|
327
|
+
}
|
|
328
|
+
function createTelegramUpdateQueueHandoffId(input) {
|
|
329
|
+
return `handoff-${createHash("sha256")
|
|
330
|
+
.update(JSON.stringify({
|
|
331
|
+
version: 1,
|
|
332
|
+
token: input.handoffToken,
|
|
333
|
+
queueKind: input.queueKind,
|
|
334
|
+
receiptId: input.receiptId,
|
|
335
|
+
sourceUpdateIds: [...input.sourceUpdateIds].sort((a, b) => a - b),
|
|
336
|
+
donorAcquisitionId: input.expectedOwner.acquisitionId,
|
|
337
|
+
recipientOwner: input.recipientOwner,
|
|
338
|
+
}))
|
|
339
|
+
.digest("hex")
|
|
340
|
+
.slice(0, 32)}`;
|
|
341
|
+
}
|
|
342
|
+
function isTelegramInputHandoffId(value) {
|
|
343
|
+
return isBoundedString(value, TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH) &&
|
|
344
|
+
/^input-handoff-[a-f0-9]{32}$/u.test(value);
|
|
345
|
+
}
|
|
346
|
+
function createTelegramInputHandoffId(input) {
|
|
347
|
+
return `input-handoff-${createHash("sha256").update(JSON.stringify({
|
|
348
|
+
version: 1,
|
|
349
|
+
token: input.handoffToken,
|
|
350
|
+
source: input.journalBindingKey,
|
|
351
|
+
updateId: input.updateId,
|
|
352
|
+
donorOwner: input.donorOwner,
|
|
353
|
+
recipientOwner: input.recipientOwner,
|
|
354
|
+
recipientBindingKey: input.recipientBindingKey,
|
|
355
|
+
})).digest("hex").slice(0, 32)}`;
|
|
356
|
+
}
|
|
357
|
+
function validateJournalHandoff(value, path, kind) {
|
|
358
|
+
if (!isRecord(value) ||
|
|
359
|
+
!hasOnlyKeys(value, ["handoffId", "offeredAtMs", "recipientOwner"]) ||
|
|
360
|
+
!isBoundedString(value.handoffId, TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH) ||
|
|
361
|
+
!isSafeNonNegativeInteger(value.offeredAtMs)) {
|
|
362
|
+
throw createJournalError("invalid", path, `contains invalid ${kind} handoff metadata`);
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
handoffId: value.handoffId,
|
|
366
|
+
offeredAtMs: value.offeredAtMs,
|
|
367
|
+
recipientOwner: validateJournalQueueOwnerIdentity(value.recipientOwner, path),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function cloneJournalQueueHandoff(handoff) {
|
|
371
|
+
return {
|
|
372
|
+
...handoff,
|
|
373
|
+
recipientOwner: { ...handoff.recipientOwner },
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function validateJournalFailure(value, path) {
|
|
377
|
+
if (!isRecord(value) ||
|
|
378
|
+
!hasOnlyKeys(value, [
|
|
379
|
+
"attemptCount",
|
|
380
|
+
"failedAtMs",
|
|
381
|
+
"failureClass",
|
|
382
|
+
"summary",
|
|
383
|
+
]) ||
|
|
384
|
+
!isSafePositiveInteger(value.attemptCount) ||
|
|
385
|
+
!isSafeNonNegativeInteger(value.failedAtMs) ||
|
|
386
|
+
!isBoundedString(value.failureClass, TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH) ||
|
|
387
|
+
!isBoundedString(value.summary, TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH)) {
|
|
388
|
+
throw createJournalError("invalid", path, "contains invalid execution failure metadata");
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
attemptCount: value.attemptCount,
|
|
392
|
+
failedAtMs: value.failedAtMs,
|
|
393
|
+
failureClass: value.failureClass,
|
|
394
|
+
summary: value.summary,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function createTelegramUpdateTerminalFailureId(input) {
|
|
398
|
+
return `failure-${createHash("sha256")
|
|
399
|
+
.update(JSON.stringify(input))
|
|
400
|
+
.digest("hex")
|
|
401
|
+
.slice(0, 32)}`;
|
|
402
|
+
}
|
|
403
|
+
function validateJournalOperatorDisposition(value, path) {
|
|
404
|
+
if (isRecord(value) && value.dispositionKind === "legacy-custody") {
|
|
405
|
+
if (!hasOnlyKeys(value, ["dispositionKind", "failureId", "updateId", "action",
|
|
406
|
+
"committedAtMs", "evidenceSha256", "operatorAuthorityId", "authorizedAtMs"]) ||
|
|
407
|
+
!isBoundedString(value.failureId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
408
|
+
!isSafeNonNegativeInteger(value.updateId) ||
|
|
409
|
+
(value.action !== "requeue-v3" && value.action !== "discard") ||
|
|
410
|
+
!isSafeNonNegativeInteger(value.committedAtMs) ||
|
|
411
|
+
typeof value.evidenceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.evidenceSha256) ||
|
|
412
|
+
!isBoundedString(value.operatorAuthorityId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
413
|
+
!isSafeNonNegativeInteger(value.authorizedAtMs) || value.committedAtMs < value.authorizedAtMs)
|
|
414
|
+
throw createJournalError("invalid", path, "contains invalid legacy custody disposition metadata");
|
|
415
|
+
return { dispositionKind: "legacy-custody", failureId: value.failureId,
|
|
416
|
+
updateId: value.updateId, action: value.action, committedAtMs: value.committedAtMs,
|
|
417
|
+
evidenceSha256: value.evidenceSha256, operatorAuthorityId: value.operatorAuthorityId,
|
|
418
|
+
authorizedAtMs: value.authorizedAtMs };
|
|
419
|
+
}
|
|
420
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["failureId", "updateId", "action",
|
|
421
|
+
"committedAtMs", "attemptCount", "failureClass", "terminalAtMs", "terminalReason"]) ||
|
|
422
|
+
!isBoundedString(value.failureId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
423
|
+
!isSafeNonNegativeInteger(value.updateId) ||
|
|
424
|
+
(value.action !== "retry" && value.action !== "discard") ||
|
|
425
|
+
!isSafeNonNegativeInteger(value.committedAtMs) || !isSafePositiveInteger(value.attemptCount) ||
|
|
426
|
+
!isBoundedString(value.failureClass, TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH) ||
|
|
427
|
+
!isSafeNonNegativeInteger(value.terminalAtMs) || value.committedAtMs < value.terminalAtMs ||
|
|
428
|
+
!isBoundedString(value.terminalReason, TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH))
|
|
429
|
+
throw createJournalError("invalid", path, "contains invalid operator disposition metadata");
|
|
430
|
+
return { failureId: value.failureId, updateId: value.updateId, action: value.action,
|
|
431
|
+
committedAtMs: value.committedAtMs, attemptCount: value.attemptCount,
|
|
432
|
+
failureClass: value.failureClass, terminalAtMs: value.terminalAtMs,
|
|
433
|
+
terminalReason: value.terminalReason };
|
|
434
|
+
}
|
|
435
|
+
function journalRequiresExclusion(version) {
|
|
436
|
+
return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION ||
|
|
437
|
+
version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
|
|
438
|
+
}
|
|
439
|
+
function validateJournalInputClaim(value, path, updateId) {
|
|
440
|
+
if (!isRecord(value) ||
|
|
441
|
+
!hasOnlyKeys(value, ["phase", "owner", "recipientBindingKey", "handoff", "executionUpdate"]) ||
|
|
442
|
+
(value.phase !== "ready" && value.phase !== "running") ||
|
|
443
|
+
!isBoundedString(value.recipientBindingKey, TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH) ||
|
|
444
|
+
!value.recipientBindingKey.trim()) {
|
|
445
|
+
throw createJournalError("invalid", path, "contains invalid input claim metadata");
|
|
446
|
+
}
|
|
447
|
+
const owner = validateJournalQueueOwner(value.owner, path);
|
|
448
|
+
const handoff = value.handoff === undefined ? undefined : validateJournalHandoff(value.handoff, path, "input");
|
|
449
|
+
if ((owner.handoffId !== undefined && !isTelegramInputHandoffId(owner.handoffId)) ||
|
|
450
|
+
(handoff && (!isTelegramInputHandoffId(handoff.handoffId) || value.phase !== "ready" ||
|
|
451
|
+
isTelegramUpdateJournalQueueOwnerProcess(owner, handoff.recipientOwner)))) {
|
|
452
|
+
throw createJournalError("invalid", path, "contains conflicting input handoff metadata");
|
|
453
|
+
}
|
|
454
|
+
const executionUpdate = value.executionUpdate === undefined
|
|
455
|
+
? undefined : validateJournaledUpdate(value.executionUpdate, path);
|
|
456
|
+
if (executionUpdate && executionUpdate.update_id !== updateId) {
|
|
457
|
+
throw createJournalError("invalid", path, "contains an input claim/update id mismatch");
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
phase: value.phase,
|
|
461
|
+
owner,
|
|
462
|
+
recipientBindingKey: value.recipientBindingKey,
|
|
463
|
+
...(handoff ? { handoff } : {}),
|
|
464
|
+
...(executionUpdate ? { executionUpdate } : {}),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
function validateJournalInputProvenance(value, path, updateId) {
|
|
468
|
+
if (!isRecord(value) ||
|
|
469
|
+
!hasOnlyKeys(value, ["owner", "recipientBindingKey", "executionUpdate"]) ||
|
|
470
|
+
!isBoundedString(value.recipientBindingKey, TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH) ||
|
|
471
|
+
!value.recipientBindingKey.trim()) {
|
|
472
|
+
throw createJournalError("invalid", path, "contains invalid input queue provenance");
|
|
473
|
+
}
|
|
474
|
+
const owner = validateJournalQueueOwner(value.owner, path);
|
|
475
|
+
if (owner.handoffId !== undefined && !isTelegramInputHandoffId(owner.handoffId)) {
|
|
476
|
+
throw createJournalError("invalid", path, "contains invalid input queue provenance");
|
|
477
|
+
}
|
|
478
|
+
const executionUpdate = value.executionUpdate === undefined
|
|
479
|
+
? undefined : validateJournaledUpdate(value.executionUpdate, path);
|
|
480
|
+
if (executionUpdate && executionUpdate.update_id !== updateId) {
|
|
481
|
+
throw createJournalError("invalid", path, "contains an input provenance/update id mismatch");
|
|
482
|
+
}
|
|
483
|
+
return { owner, recipientBindingKey: value.recipientBindingKey,
|
|
484
|
+
...(executionUpdate ? { executionUpdate } : {}) };
|
|
485
|
+
}
|
|
486
|
+
function validateJournalEntry(value, path, version) {
|
|
487
|
+
if (journalRequiresExclusion(version) &&
|
|
488
|
+
(!isRecord(value) || typeof value.preApprovalExcluded !== "boolean" ||
|
|
489
|
+
(value.preApprovalExcluded && value.state === "queued"))) {
|
|
490
|
+
throw createJournalError("pairing-evidence", path, "contains missing, malformed, or queued exclusion evidence");
|
|
491
|
+
}
|
|
492
|
+
if (!isRecord(value) ||
|
|
493
|
+
!hasOnlyKeys(value, [
|
|
494
|
+
"updateId",
|
|
495
|
+
"update",
|
|
496
|
+
"admittedAtMs",
|
|
497
|
+
"state",
|
|
498
|
+
"queueKind",
|
|
499
|
+
"queueReceiptId",
|
|
500
|
+
"queueOwner",
|
|
501
|
+
"queueHandoff",
|
|
502
|
+
"failure",
|
|
503
|
+
"nextRetryAtMs",
|
|
504
|
+
"terminalAtMs",
|
|
505
|
+
"terminalReason",
|
|
506
|
+
"terminalFailureId",
|
|
507
|
+
...(journalRequiresExclusion(version) ? ["preApprovalExcluded"] : []),
|
|
508
|
+
...(version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION
|
|
509
|
+
? ["inputClaim", "inputProvenance"] : []),
|
|
510
|
+
]) ||
|
|
511
|
+
!isSafeNonNegativeInteger(value.updateId) ||
|
|
512
|
+
!isSafeNonNegativeInteger(value.admittedAtMs) ||
|
|
513
|
+
(value.state !== "pending" &&
|
|
514
|
+
value.state !== "retry-wait" &&
|
|
515
|
+
value.state !== "queued" &&
|
|
516
|
+
value.state !== "failed")) {
|
|
517
|
+
throw createJournalError("invalid", path, "contains an invalid entry");
|
|
518
|
+
}
|
|
519
|
+
const update = validateJournaledUpdate(value.update, path);
|
|
520
|
+
if (update.update_id !== value.updateId) {
|
|
521
|
+
throw createJournalError("invalid", path, "contains an entry/update id mismatch");
|
|
522
|
+
}
|
|
523
|
+
const queueKind = value.queueKind;
|
|
524
|
+
const queueReceiptId = value.queueReceiptId;
|
|
525
|
+
const hasQueueMetadata = queueKind !== undefined ||
|
|
526
|
+
queueReceiptId !== undefined ||
|
|
527
|
+
value.queueOwner !== undefined ||
|
|
528
|
+
value.queueHandoff !== undefined;
|
|
529
|
+
const hasFailureMetadata = value.failure !== undefined ||
|
|
530
|
+
value.nextRetryAtMs !== undefined ||
|
|
531
|
+
value.terminalAtMs !== undefined ||
|
|
532
|
+
value.terminalReason !== undefined ||
|
|
533
|
+
value.terminalFailureId !== undefined;
|
|
534
|
+
if (value.state === "pending" && (hasQueueMetadata || hasFailureMetadata)) {
|
|
535
|
+
throw createJournalError("invalid", path, "contains metadata on a pending entry");
|
|
536
|
+
}
|
|
537
|
+
if (value.state === "queued" &&
|
|
538
|
+
((queueKind !== "prompt" && queueKind !== "control") ||
|
|
539
|
+
!isNonEmptyString(queueReceiptId) ||
|
|
540
|
+
value.queueOwner === undefined ||
|
|
541
|
+
hasFailureMetadata)) {
|
|
542
|
+
throw createJournalError("invalid", path, "contains invalid queued entry metadata");
|
|
543
|
+
}
|
|
544
|
+
const queueOwner = value.state === "queued" && value.queueOwner !== undefined
|
|
545
|
+
? validateJournalQueueOwner(value.queueOwner, path)
|
|
546
|
+
: undefined;
|
|
547
|
+
const queueHandoff = value.state === "queued" && value.queueHandoff !== undefined
|
|
548
|
+
? validateJournalHandoff(value.queueHandoff, path, "queue")
|
|
549
|
+
: undefined;
|
|
550
|
+
if (queueHandoff && !queueOwner) {
|
|
551
|
+
throw createJournalError("invalid", path, "contains a queue handoff without donor authority");
|
|
552
|
+
}
|
|
553
|
+
if (value.preApprovalExcluded === true &&
|
|
554
|
+
(value.inputClaim !== undefined || value.inputProvenance !== undefined)) {
|
|
555
|
+
throw createJournalError("pairing-evidence", path, "contains claimed exclusion evidence");
|
|
556
|
+
}
|
|
557
|
+
const inputClaim = value.inputClaim === undefined
|
|
558
|
+
? undefined : validateJournalInputClaim(value.inputClaim, path, value.updateId);
|
|
559
|
+
const inputProvenance = value.inputProvenance === undefined
|
|
560
|
+
? undefined : validateJournalInputProvenance(value.inputProvenance, path, value.updateId);
|
|
561
|
+
if ((inputClaim && (value.state === "queued" ||
|
|
562
|
+
(inputClaim.phase === "running" && value.state !== "pending"))) ||
|
|
563
|
+
(inputProvenance && (value.state !== "queued" || inputClaim))) {
|
|
564
|
+
throw createJournalError("invalid", path, "contains conflicting input claim state");
|
|
565
|
+
}
|
|
566
|
+
let failure;
|
|
567
|
+
if (value.state === "retry-wait" || value.state === "failed") {
|
|
568
|
+
if (hasQueueMetadata) {
|
|
569
|
+
throw createJournalError("invalid", path, "contains queue metadata on a failed execution entry");
|
|
570
|
+
}
|
|
571
|
+
failure = validateJournalFailure(value.failure, path);
|
|
572
|
+
}
|
|
573
|
+
if (value.state === "retry-wait" &&
|
|
574
|
+
(!isSafeNonNegativeInteger(value.nextRetryAtMs) ||
|
|
575
|
+
value.nextRetryAtMs < failure.failedAtMs ||
|
|
576
|
+
value.terminalAtMs !== undefined ||
|
|
577
|
+
value.terminalReason !== undefined ||
|
|
578
|
+
value.terminalFailureId !== undefined)) {
|
|
579
|
+
throw createJournalError("invalid", path, "contains invalid retry-wait metadata");
|
|
580
|
+
}
|
|
581
|
+
if (value.state === "failed" &&
|
|
582
|
+
(!isSafeNonNegativeInteger(value.terminalAtMs) ||
|
|
583
|
+
value.terminalAtMs < failure.failedAtMs ||
|
|
584
|
+
!isBoundedString(value.terminalReason, TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH) ||
|
|
585
|
+
(value.terminalFailureId !== undefined &&
|
|
586
|
+
!isBoundedString(value.terminalFailureId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH)) ||
|
|
587
|
+
value.nextRetryAtMs !== undefined)) {
|
|
588
|
+
throw createJournalError("invalid", path, "contains invalid terminal failure metadata");
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
updateId: value.updateId,
|
|
592
|
+
update,
|
|
593
|
+
...(journalRequiresExclusion(version)
|
|
594
|
+
? { preApprovalExcluded: value.preApprovalExcluded } : {}),
|
|
595
|
+
...(inputClaim ? { inputClaim } : {}),
|
|
596
|
+
...(inputProvenance ? { inputProvenance } : {}),
|
|
597
|
+
admittedAtMs: value.admittedAtMs,
|
|
598
|
+
state: value.state,
|
|
599
|
+
...(queueKind === "prompt" || queueKind === "control"
|
|
600
|
+
? { queueKind }
|
|
601
|
+
: {}),
|
|
602
|
+
...(isNonEmptyString(queueReceiptId) ? { queueReceiptId } : {}),
|
|
603
|
+
...(queueOwner ? { queueOwner } : {}),
|
|
604
|
+
...(queueHandoff ? { queueHandoff } : {}),
|
|
605
|
+
...(failure ? { failure } : {}),
|
|
606
|
+
...(isSafeNonNegativeInteger(value.nextRetryAtMs)
|
|
607
|
+
? { nextRetryAtMs: value.nextRetryAtMs }
|
|
608
|
+
: {}),
|
|
609
|
+
...(isSafeNonNegativeInteger(value.terminalAtMs)
|
|
610
|
+
? { terminalAtMs: value.terminalAtMs }
|
|
611
|
+
: {}),
|
|
612
|
+
...(isNonEmptyString(value.terminalReason)
|
|
613
|
+
? { terminalReason: value.terminalReason }
|
|
614
|
+
: {}),
|
|
615
|
+
...(value.state === "failed"
|
|
616
|
+
? {
|
|
617
|
+
terminalFailureId: isBoundedString(value.terminalFailureId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH)
|
|
618
|
+
? value.terminalFailureId
|
|
619
|
+
: createTelegramUpdateTerminalFailureId({
|
|
620
|
+
updateId: value.updateId,
|
|
621
|
+
attemptCount: failure.attemptCount,
|
|
622
|
+
failedAtMs: failure.failedAtMs,
|
|
623
|
+
failureClass: failure.failureClass,
|
|
624
|
+
terminalAtMs: value.terminalAtMs,
|
|
625
|
+
terminalReason: value.terminalReason,
|
|
626
|
+
}),
|
|
627
|
+
}
|
|
628
|
+
: {}),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function assertSupportedJournalVersion(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION) {
|
|
632
|
+
if (isRecord(value) && Number.isSafeInteger(value.version) &&
|
|
633
|
+
value.version !== version) {
|
|
634
|
+
throw createJournalError("unsupported-version", path, `uses unsupported version ${String(value.version)}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION) {
|
|
638
|
+
assertSupportedJournalVersion(value, path, version);
|
|
639
|
+
if (journalRequiresExclusion(version) &&
|
|
640
|
+
(!isRecord(value) || !isSafeNonNegativeInteger(value.acceptedThroughUpdateId))) {
|
|
641
|
+
throw createJournalError("pairing-evidence", path, "is missing its exclusion-mode admission cursor");
|
|
642
|
+
}
|
|
643
|
+
if (!isRecord(value) ||
|
|
644
|
+
!hasOnlyKeys(value, [
|
|
645
|
+
"version",
|
|
646
|
+
"revision",
|
|
647
|
+
"acceptedThroughUpdateId",
|
|
648
|
+
"profile",
|
|
649
|
+
"botIdentity",
|
|
650
|
+
"entries",
|
|
651
|
+
"operatorDispositions",
|
|
652
|
+
]) ||
|
|
653
|
+
!Number.isSafeInteger(value.version) ||
|
|
654
|
+
(value.revision !== undefined &&
|
|
655
|
+
(!isSafeNonNegativeInteger(value.revision) || value.revision === 0)) ||
|
|
656
|
+
(value.acceptedThroughUpdateId !== undefined &&
|
|
657
|
+
!isSafeNonNegativeInteger(value.acceptedThroughUpdateId)) ||
|
|
658
|
+
!isNonEmptyString(value.profile) ||
|
|
659
|
+
!Array.isArray(value.entries) ||
|
|
660
|
+
(value.operatorDispositions !== undefined &&
|
|
661
|
+
!Array.isArray(value.operatorDispositions))) {
|
|
662
|
+
throw createJournalError("invalid", path, "has a malformed schema");
|
|
663
|
+
}
|
|
664
|
+
const entries = value.entries.map((entry) => validateJournalEntry(entry, path, version));
|
|
665
|
+
for (let index = 1; index < entries.length; index += 1) {
|
|
666
|
+
if (entries[index].updateId <= entries[index - 1].updateId) {
|
|
667
|
+
throw createJournalError("invalid", path, "contains duplicate or unordered entry ids");
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (entries.length > 0 &&
|
|
671
|
+
value.acceptedThroughUpdateId !== undefined &&
|
|
672
|
+
value.acceptedThroughUpdateId < entries.at(-1).updateId) {
|
|
673
|
+
throw createJournalError("invalid", path, "has an admission cursor behind its active entries");
|
|
674
|
+
}
|
|
675
|
+
const queuedReceipts = new Map();
|
|
676
|
+
for (const entry of entries) {
|
|
677
|
+
if (entry.state !== "queued" ||
|
|
678
|
+
!entry.queueKind ||
|
|
679
|
+
!entry.queueReceiptId) {
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
const existing = queuedReceipts.get(entry.queueReceiptId);
|
|
683
|
+
if (existing &&
|
|
684
|
+
(existing.queueKind !== entry.queueKind ||
|
|
685
|
+
(existing.queueOwner === undefined) !==
|
|
686
|
+
(entry.queueOwner === undefined) ||
|
|
687
|
+
(existing.queueOwner !== undefined &&
|
|
688
|
+
entry.queueOwner !== undefined &&
|
|
689
|
+
!areTelegramUpdateJournalQueueOwnersEqual(existing.queueOwner, entry.queueOwner)) ||
|
|
690
|
+
(existing.queueHandoff === undefined) !==
|
|
691
|
+
(entry.queueHandoff === undefined) ||
|
|
692
|
+
(existing.queueHandoff !== undefined &&
|
|
693
|
+
entry.queueHandoff !== undefined &&
|
|
694
|
+
!isDeepStrictEqual(existing.queueHandoff, entry.queueHandoff)))) {
|
|
695
|
+
throw createJournalError("invalid", path, `contains inconsistent queued receipt ${entry.queueReceiptId}`);
|
|
696
|
+
}
|
|
697
|
+
if (!existing) {
|
|
698
|
+
queuedReceipts.set(entry.queueReceiptId, {
|
|
699
|
+
queueKind: entry.queueKind,
|
|
700
|
+
...(entry.queueOwner
|
|
701
|
+
? { queueOwner: cloneJournalQueueOwner(entry.queueOwner) }
|
|
702
|
+
: {}),
|
|
703
|
+
...(entry.queueHandoff
|
|
704
|
+
? { queueHandoff: cloneJournalQueueHandoff(entry.queueHandoff) }
|
|
705
|
+
: {}),
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const operatorDispositions = (value.operatorDispositions ?? []).map((disposition) => validateJournalOperatorDisposition(disposition, path));
|
|
710
|
+
const dispositionFailureIds = new Set();
|
|
711
|
+
const entriesByUpdateId = new Map(entries.map((entry) => [entry.updateId, entry]));
|
|
712
|
+
for (const disposition of operatorDispositions) {
|
|
713
|
+
if (dispositionFailureIds.has(disposition.failureId)) {
|
|
714
|
+
throw createJournalError("invalid", path, "contains duplicate operator disposition failure ids");
|
|
715
|
+
}
|
|
716
|
+
const currentEntry = entriesByUpdateId.get(disposition.updateId);
|
|
717
|
+
if (currentEntry?.terminalFailureId === disposition.failureId ||
|
|
718
|
+
(disposition.action === "discard" && currentEntry !== undefined)) {
|
|
719
|
+
throw createJournalError("invalid", path, "contains operator-disposed active authority");
|
|
720
|
+
}
|
|
721
|
+
dispositionFailureIds.add(disposition.failureId);
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
version,
|
|
725
|
+
...(value.revision !== undefined
|
|
726
|
+
? { revision: value.revision }
|
|
727
|
+
: {}),
|
|
728
|
+
...(value.acceptedThroughUpdateId !== undefined
|
|
729
|
+
? { acceptedThroughUpdateId: value.acceptedThroughUpdateId }
|
|
730
|
+
: {}),
|
|
731
|
+
profile: value.profile,
|
|
732
|
+
botIdentity: validateBotIdentity(value.botIdentity, path),
|
|
733
|
+
entries,
|
|
734
|
+
...(operatorDispositions.length > 0 ? { operatorDispositions } : {}),
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION) {
|
|
738
|
+
assertSupportedJournalVersion(value, path, version);
|
|
739
|
+
if (journalRequiresExclusion(version) &&
|
|
740
|
+
(!isRecord(value) || !isSafeNonNegativeInteger(value.acceptedThroughUpdateId))) {
|
|
741
|
+
throw createJournalError("pairing-evidence", path, "is missing its exclusion-mode admission cursor");
|
|
742
|
+
}
|
|
743
|
+
if (!isRecord(value) ||
|
|
744
|
+
!hasOnlyKeys(value, [
|
|
745
|
+
"version",
|
|
746
|
+
"revision",
|
|
747
|
+
"previousRevision",
|
|
748
|
+
"acceptedThroughUpdateId",
|
|
749
|
+
"profile",
|
|
750
|
+
"botIdentity",
|
|
751
|
+
"upsertedEntries",
|
|
752
|
+
"removedUpdateIds",
|
|
753
|
+
"operatorDispositions",
|
|
754
|
+
]) ||
|
|
755
|
+
value.version !== version ||
|
|
756
|
+
!isSafePositiveInteger(value.revision) ||
|
|
757
|
+
!isSafeNonNegativeInteger(value.previousRevision) ||
|
|
758
|
+
(value.acceptedThroughUpdateId !== undefined &&
|
|
759
|
+
!isSafeNonNegativeInteger(value.acceptedThroughUpdateId)) ||
|
|
760
|
+
!isNonEmptyString(value.profile) ||
|
|
761
|
+
!Array.isArray(value.upsertedEntries) ||
|
|
762
|
+
!Array.isArray(value.removedUpdateIds) ||
|
|
763
|
+
(value.operatorDispositions !== undefined &&
|
|
764
|
+
!Array.isArray(value.operatorDispositions))) {
|
|
765
|
+
throw createJournalError("invalid", path, "has a malformed segment schema");
|
|
766
|
+
}
|
|
767
|
+
const upsertedEntries = value.upsertedEntries.map((entry) => validateJournalEntry(entry, path, version));
|
|
768
|
+
const upsertedIds = new Set();
|
|
769
|
+
for (const entry of upsertedEntries) {
|
|
770
|
+
if (upsertedIds.has(entry.updateId)) {
|
|
771
|
+
throw createJournalError("invalid", path, "has duplicate segment upserts");
|
|
772
|
+
}
|
|
773
|
+
upsertedIds.add(entry.updateId);
|
|
774
|
+
}
|
|
775
|
+
if (upsertedEntries.length > 0 &&
|
|
776
|
+
value.acceptedThroughUpdateId !== undefined &&
|
|
777
|
+
value.acceptedThroughUpdateId <
|
|
778
|
+
upsertedEntries.reduce((maximum, entry) => Math.max(maximum, entry.updateId), 0)) {
|
|
779
|
+
throw createJournalError("invalid", path, "has an admission cursor behind its segment upserts");
|
|
780
|
+
}
|
|
781
|
+
const removedUpdateIds = [];
|
|
782
|
+
const removedIds = new Set();
|
|
783
|
+
for (const updateId of value.removedUpdateIds) {
|
|
784
|
+
if (!isSafeNonNegativeInteger(updateId) ||
|
|
785
|
+
removedIds.has(updateId) ||
|
|
786
|
+
upsertedIds.has(updateId)) {
|
|
787
|
+
throw createJournalError("invalid", path, "has invalid segment removals");
|
|
788
|
+
}
|
|
789
|
+
removedIds.add(updateId);
|
|
790
|
+
removedUpdateIds.push(updateId);
|
|
791
|
+
}
|
|
792
|
+
const operatorDispositions = (value.operatorDispositions ?? []).map((disposition) => validateJournalOperatorDisposition(disposition, path));
|
|
793
|
+
return {
|
|
794
|
+
version,
|
|
795
|
+
revision: value.revision,
|
|
796
|
+
previousRevision: value.previousRevision,
|
|
797
|
+
...(value.acceptedThroughUpdateId !== undefined
|
|
798
|
+
? { acceptedThroughUpdateId: value.acceptedThroughUpdateId }
|
|
799
|
+
: {}),
|
|
800
|
+
profile: value.profile,
|
|
801
|
+
botIdentity: validateBotIdentity(value.botIdentity, path),
|
|
802
|
+
upsertedEntries,
|
|
803
|
+
removedUpdateIds,
|
|
804
|
+
...(value.operatorDispositions !== undefined
|
|
805
|
+
? { operatorDispositions }
|
|
806
|
+
: {}),
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Isolated evidence only: caller must serialize/quiesce writers before inspection
|
|
811
|
+
* and consumption. Metadata checks detect observable changes, not hostile same-user
|
|
812
|
+
* swaps or whole-profile completeness. No locks, recovery, or publication occurs.
|
|
813
|
+
* maxFiles counts snapshot + every enumerated segment entry (one overflow witness).
|
|
814
|
+
* maxBytes aggregates all retained bytes. maxEntries bounds each raw collection and
|
|
815
|
+
* reconstructed collection; maxWork charges each raw/revalidated collection element.
|
|
816
|
+
* JSON allocation is bounded by maxBytes before decoding; collections before codecs.
|
|
817
|
+
*/
|
|
818
|
+
export function inspectTelegramInputCustodySourceStatus(input) {
|
|
819
|
+
try {
|
|
820
|
+
const evidence = inspectTelegramUpdateJournalFamily(input);
|
|
821
|
+
if (evidence.kind === "absent")
|
|
822
|
+
return "absent";
|
|
823
|
+
if (evidence.file.version === 3)
|
|
824
|
+
return "v3";
|
|
825
|
+
if (evidence.file.version === 1 || evidence.file.version === 2)
|
|
826
|
+
return "legacy";
|
|
827
|
+
return "unsupported";
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
return "ambiguous";
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
export function inspectTelegramUpdateJournalFamily(input) {
|
|
834
|
+
return acquireTelegramUpdateJournalFamily(input).evidence;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Read-only source evidence for cooperating writers serialized by the caller through
|
|
838
|
+
* consumption; never readiness, recovery, or permission to publish. Ancestors retain
|
|
839
|
+
* canonical directory type and endpoint dev/ino/mode/uid/gid, tolerating sibling churn.
|
|
840
|
+
* This deliberately loses ancestor size/nlink/mtime/ctime witnesses: no transient
|
|
841
|
+
* namespace/permission/ACL continuity, inode-ABA resistance, or hostile-same-user
|
|
842
|
+
* protection. Manual relocation/restore/security manipulation is outside the protocol.
|
|
843
|
+
* Files and the segment directory retain full inspection checks and bounded census.
|
|
844
|
+
* Identity is never enriched; accounting is physical inspection work, not store capacity.
|
|
845
|
+
*/
|
|
846
|
+
export function readTelegramUpdateJournalSource(input) {
|
|
847
|
+
const version = input.version;
|
|
848
|
+
if (version !== 1 && version !== 2 && version !== 3) {
|
|
849
|
+
throw createJournalError("unsupported-version", input.path, "requires a selected source version");
|
|
850
|
+
}
|
|
851
|
+
return acquireTelegramUpdateJournalFamily({
|
|
852
|
+
...input,
|
|
853
|
+
botIdentity: { ...input.botIdentity },
|
|
854
|
+
limits: { ...input.limits },
|
|
855
|
+
}, version).evidence;
|
|
856
|
+
}
|
|
857
|
+
function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
|
|
858
|
+
const { path, limits } = input;
|
|
859
|
+
const acquiredSegments = [];
|
|
860
|
+
let snapshotRevision = 0;
|
|
861
|
+
const fail = (message) => { throw createJournalError("invalid", path, message); };
|
|
862
|
+
const capacity = () => { throw createJournalError("capacity", path, "exceeds inspection resource limits"); };
|
|
863
|
+
for (const key of ["maxFiles", "maxBytes", "maxEntries", "maxWork"]) {
|
|
864
|
+
if (!isSafePositiveInteger(limits[key]))
|
|
865
|
+
fail("requires positive safe-integer limits");
|
|
866
|
+
}
|
|
867
|
+
const expected = validateBotIdentity(input.botIdentity, path);
|
|
868
|
+
if (!isNonEmptyString(input.profile))
|
|
869
|
+
fail("requires an exact profile");
|
|
870
|
+
const anchor = input.directory;
|
|
871
|
+
const contained = relative(anchor, path);
|
|
872
|
+
if (!isAbsolute(anchor) || resolve(anchor) !== anchor || !isAbsolute(path) ||
|
|
873
|
+
resolve(path) !== path || !contained || contained === ".." ||
|
|
874
|
+
contained.startsWith(`..${sep}`) || isAbsolute(contained))
|
|
875
|
+
fail("escapes its canonical directory anchor");
|
|
876
|
+
if (!constants.O_NOFOLLOW || !constants.O_NONBLOCK)
|
|
877
|
+
fail("platform lacks no-follow nonblocking open evidence");
|
|
878
|
+
let files = 0;
|
|
879
|
+
let bytes = 0;
|
|
880
|
+
let work = 0;
|
|
881
|
+
const observed = new Map();
|
|
882
|
+
const ancestors = new Set();
|
|
883
|
+
const sameAncestor = (a, b) => b.isDirectory() && !b.isSymbolicLink() && a.dev === b.dev && a.ino === b.ino &&
|
|
884
|
+
a.mode === b.mode && a.uid === b.uid && a.gid === b.gid;
|
|
885
|
+
const same = (a, b) => a.dev === b.dev && a.ino === b.ino && a.mode === b.mode &&
|
|
886
|
+
a.nlink === b.nlink && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
|
|
887
|
+
const status = (target) => {
|
|
888
|
+
try {
|
|
889
|
+
return lstatSync(target, { bigint: true });
|
|
890
|
+
}
|
|
891
|
+
catch (error) {
|
|
892
|
+
if (error.code === "ENOENT")
|
|
893
|
+
return undefined;
|
|
894
|
+
throw error;
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
const observe = (target, directory, optional = false) => {
|
|
898
|
+
const value = status(target);
|
|
899
|
+
if (!value && !optional)
|
|
900
|
+
fail("has a missing path component");
|
|
901
|
+
if (value && (value.isSymbolicLink() || !(directory ? value.isDirectory() : value.isFile())))
|
|
902
|
+
fail("has a linked or unexpected file type");
|
|
903
|
+
observed.set(target, value);
|
|
904
|
+
return value;
|
|
905
|
+
};
|
|
906
|
+
const charge = (value) => {
|
|
907
|
+
if (!isRecord(value))
|
|
908
|
+
return;
|
|
909
|
+
for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions"]) {
|
|
910
|
+
const collection = value[key];
|
|
911
|
+
if (!Array.isArray(collection))
|
|
912
|
+
continue;
|
|
913
|
+
if (collection.length > limits.maxEntries || collection.length > limits.maxWork - work)
|
|
914
|
+
capacity();
|
|
915
|
+
work += collection.length;
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
const read = (target, before) => {
|
|
919
|
+
if (before.size > BigInt(limits.maxBytes - bytes))
|
|
920
|
+
capacity();
|
|
921
|
+
const fd = openSync(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
922
|
+
try {
|
|
923
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
924
|
+
if (!opened.isFile() || !same(before, opened))
|
|
925
|
+
fail("changed before handle acquisition");
|
|
926
|
+
const length = Number(opened.size);
|
|
927
|
+
const buffer = Buffer.alloc(length);
|
|
928
|
+
let offset = 0;
|
|
929
|
+
while (offset < length) {
|
|
930
|
+
const count = readSync(fd, buffer, offset, length - offset, offset);
|
|
931
|
+
if (!count)
|
|
932
|
+
fail("disappeared or shrank during read");
|
|
933
|
+
offset += count;
|
|
934
|
+
}
|
|
935
|
+
if (readSync(fd, Buffer.alloc(1), 0, 1, length) !== 0 ||
|
|
936
|
+
!same(opened, fstatSync(fd, { bigint: true })))
|
|
937
|
+
fail("changed during handle read");
|
|
938
|
+
const after = status(target);
|
|
939
|
+
if (!after || !same(opened, after))
|
|
940
|
+
fail("changed path during read");
|
|
941
|
+
bytes += length;
|
|
942
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer));
|
|
943
|
+
charge(value);
|
|
944
|
+
return value;
|
|
945
|
+
}
|
|
946
|
+
finally {
|
|
947
|
+
closeSync(fd);
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
951
|
+
const enumerate = () => {
|
|
952
|
+
const names = [];
|
|
953
|
+
const directory = opendirSync(segmentDirectory, { bufferSize: 1 });
|
|
954
|
+
try {
|
|
955
|
+
for (;;) {
|
|
956
|
+
const entry = directory.readSync();
|
|
957
|
+
if (!entry)
|
|
958
|
+
break;
|
|
959
|
+
if (names.length >= limits.maxFiles - files)
|
|
960
|
+
capacity();
|
|
961
|
+
if (!/^\d{16}\.json$/u.test(entry.name) || !entry.isFile() || entry.isSymbolicLink())
|
|
962
|
+
fail("has an unexpected segment entry");
|
|
963
|
+
names.push(entry.name);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
finally {
|
|
967
|
+
directory.closeSync();
|
|
968
|
+
}
|
|
969
|
+
return names.sort();
|
|
970
|
+
};
|
|
971
|
+
try {
|
|
972
|
+
observe(anchor, true);
|
|
973
|
+
ancestors.add(anchor);
|
|
974
|
+
if (realpathSync(anchor) !== anchor)
|
|
975
|
+
fail("requires a canonical directory anchor");
|
|
976
|
+
let parent = anchor;
|
|
977
|
+
for (const component of relative(anchor, dirname(path)).split(sep).filter(Boolean)) {
|
|
978
|
+
parent = join(parent, component);
|
|
979
|
+
observe(parent, true);
|
|
980
|
+
ancestors.add(parent);
|
|
981
|
+
}
|
|
982
|
+
const snapshot = observe(path, false, true);
|
|
983
|
+
const segments = observe(segmentDirectory, true, true);
|
|
984
|
+
if (!snapshot && segments)
|
|
985
|
+
fail("retains segments without a snapshot");
|
|
986
|
+
files = snapshot ? 1 : 0;
|
|
987
|
+
const names = segments ? enumerate() : [];
|
|
988
|
+
let file;
|
|
989
|
+
let knownBotId = expected.botId;
|
|
990
|
+
const identity = (value) => {
|
|
991
|
+
if (value.profile !== input.profile || value.botIdentity.tokenSha256 !== expected.tokenSha256 ||
|
|
992
|
+
(knownBotId !== undefined && value.botIdentity.botId !== undefined && knownBotId !== value.botIdentity.botId)) {
|
|
993
|
+
throw createJournalError("identity-mismatch", path, "belongs to another exact journal identity");
|
|
994
|
+
}
|
|
995
|
+
knownBotId ??= value.botIdentity.botId;
|
|
996
|
+
};
|
|
997
|
+
if (snapshot) {
|
|
998
|
+
const raw = read(path, snapshot);
|
|
999
|
+
if (!isRecord(raw) || (raw.version !== 1 && raw.version !== 2 && raw.version !== 3)) {
|
|
1000
|
+
throw createJournalError("unsupported-version", path, "has an unsupported snapshot version");
|
|
1001
|
+
}
|
|
1002
|
+
file = parseJournalFile(raw, path, raw.version);
|
|
1003
|
+
snapshotRevision = file.revision ?? 0;
|
|
1004
|
+
identity(file);
|
|
1005
|
+
for (const name of names) {
|
|
1006
|
+
const target = join(segmentDirectory, name);
|
|
1007
|
+
const metadata = observe(target, false);
|
|
1008
|
+
const previousWork = work;
|
|
1009
|
+
const segment = parseJournalSegment(read(target, metadata), target, file.version);
|
|
1010
|
+
acquiredSegments.push({ name, bytes: Number(metadata.size), work: work - previousWork,
|
|
1011
|
+
...(segment.botIdentity.botId !== undefined ? { botId: segment.botIdentity.botId } : {}) });
|
|
1012
|
+
files += 1;
|
|
1013
|
+
identity(segment);
|
|
1014
|
+
if (segment.revision !== Number(name.slice(0, 16)) || segment.previousRevision !== segment.revision - 1)
|
|
1015
|
+
fail("has an invalid intrinsic segment revision");
|
|
1016
|
+
const dispositionIds = new Set();
|
|
1017
|
+
for (const disposition of segment.operatorDispositions ?? []) {
|
|
1018
|
+
if (dispositionIds.has(disposition.failureId))
|
|
1019
|
+
fail("has duplicate segment disposition failure ids");
|
|
1020
|
+
dispositionIds.add(disposition.failureId);
|
|
1021
|
+
}
|
|
1022
|
+
const revision = file.revision ?? 0;
|
|
1023
|
+
if (segment.revision <= revision)
|
|
1024
|
+
continue;
|
|
1025
|
+
if (segment.revision !== revision + 1 || segment.previousRevision !== revision)
|
|
1026
|
+
fail("has a newer revision gap");
|
|
1027
|
+
if (segment.acceptedThroughUpdateId !== undefined && file.acceptedThroughUpdateId !== undefined &&
|
|
1028
|
+
segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId)
|
|
1029
|
+
fail("regresses the admission cursor");
|
|
1030
|
+
const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
|
|
1031
|
+
for (const id of segment.removedUpdateIds)
|
|
1032
|
+
entries.delete(id);
|
|
1033
|
+
for (const entry of segment.upsertedEntries) {
|
|
1034
|
+
const previous = entries.get(entry.updateId);
|
|
1035
|
+
if (journalRequiresExclusion(file.version) && ((previous && previous.preApprovalExcluded !== entry.preApprovalExcluded) ||
|
|
1036
|
+
(!previous && entry.updateId <= (file.acceptedThroughUpdateId ?? -1)))) {
|
|
1037
|
+
throw createJournalError("pairing-evidence", target, "changes exclusion evidence or resurrects a settled source");
|
|
1038
|
+
}
|
|
1039
|
+
if (!entries.has(entry.updateId) && entries.size >= limits.maxEntries)
|
|
1040
|
+
capacity();
|
|
1041
|
+
entries.set(entry.updateId, entry);
|
|
1042
|
+
}
|
|
1043
|
+
const next = {
|
|
1044
|
+
...file,
|
|
1045
|
+
revision: segment.revision,
|
|
1046
|
+
...(segment.acceptedThroughUpdateId !== undefined ? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId } : {}),
|
|
1047
|
+
entries: [...entries.values()].sort((a, b) => a.updateId - b.updateId),
|
|
1048
|
+
operatorDispositions: segment.operatorDispositions ?? file.operatorDispositions,
|
|
1049
|
+
};
|
|
1050
|
+
charge(next);
|
|
1051
|
+
file = parseJournalFile(next, target, file.version);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
// Re-enumeration has the same bound, rather than allocating an unbounded census.
|
|
1055
|
+
files = snapshot ? 1 : 0;
|
|
1056
|
+
if (segments && !isDeepStrictEqual(names, enumerate()))
|
|
1057
|
+
fail("changed segment namespace");
|
|
1058
|
+
files += names.length;
|
|
1059
|
+
for (const [target, before] of observed) {
|
|
1060
|
+
const after = status(target);
|
|
1061
|
+
const compare = selectedVersion !== undefined && ancestors.has(target) ? sameAncestor : same;
|
|
1062
|
+
if (before ? !after || !compare(before, after) : after !== undefined)
|
|
1063
|
+
fail("changed observed namespace or file");
|
|
1064
|
+
}
|
|
1065
|
+
if (selectedVersion !== undefined) {
|
|
1066
|
+
if (realpathSync(anchor) !== anchor)
|
|
1067
|
+
fail("requires a canonical directory anchor");
|
|
1068
|
+
if (file) {
|
|
1069
|
+
if (file.version !== selectedVersion) {
|
|
1070
|
+
throw createJournalError("unsupported-version", path, "does not match the selected source version");
|
|
1071
|
+
}
|
|
1072
|
+
if (createTelegramUpdateJournalReceiptScope({ profileName: input.profile, botIdentity: expected }) !==
|
|
1073
|
+
createTelegramUpdateJournalReceiptScope({ profileName: file.profile, botIdentity: file.botIdentity })) {
|
|
1074
|
+
throw createJournalError("identity-mismatch", path, "does not retain the expected receipt scope");
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return {
|
|
1079
|
+
evidence: file ? { kind: "present", file, ...(knownBotId !== undefined ? { knownBotId } : {}), accounting: { files, bytes, work } } : { kind: "absent" },
|
|
1080
|
+
snapshotRevision,
|
|
1081
|
+
segments: acquiredSegments,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
catch (error) {
|
|
1085
|
+
if (error instanceof TelegramUpdateJournalError)
|
|
1086
|
+
throw error;
|
|
1087
|
+
throw createJournalError("io", path, "could not establish strict journal evidence", error);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Canonical namespace evidence only, never source readiness or authorization.
|
|
1092
|
+
* Caller-proven serialization/quiescence is mandatory through consumption.
|
|
1093
|
+
* Recensus detects observable changes, not hostile same-user path swaps; arbitrary
|
|
1094
|
+
* consumer references and archive consumption require separate audits.
|
|
1095
|
+
*/
|
|
1096
|
+
export function inspectTelegramProfileJournalNamespace(input) {
|
|
1097
|
+
const { directory, profile, limits } = input;
|
|
1098
|
+
const fail = (message) => { throw createJournalError("invalid", directory, message); };
|
|
1099
|
+
const capacity = () => { throw createJournalError("capacity", directory, "exceeds namespace inspection resource limits"); };
|
|
1100
|
+
for (const key of ["maxDirectoryEntries", "maxFiles", "maxBytes", "maxEntries", "maxWork"]) {
|
|
1101
|
+
if (!isSafePositiveInteger(limits[key]))
|
|
1102
|
+
fail("requires positive safe-integer limits");
|
|
1103
|
+
}
|
|
1104
|
+
if (!/^[a-z0-9]{1,32}$/u.test(profile))
|
|
1105
|
+
fail("requires a canonical profile namespace");
|
|
1106
|
+
const expected = validateBotIdentity(input.botIdentity, directory);
|
|
1107
|
+
if (!isAbsolute(directory) || resolve(directory) !== directory)
|
|
1108
|
+
fail("requires a canonical directory anchor");
|
|
1109
|
+
if (!constants.O_NOFOLLOW || !constants.O_NONBLOCK)
|
|
1110
|
+
fail("platform lacks no-follow nonblocking open evidence");
|
|
1111
|
+
const metadata = (target) => {
|
|
1112
|
+
const value = lstatSync(target, { bigint: true });
|
|
1113
|
+
return { dev: value.dev, ino: value.ino, mode: value.mode, nlink: value.nlink,
|
|
1114
|
+
size: value.size, mtimeNs: value.mtimeNs, ctimeNs: value.ctimeNs };
|
|
1115
|
+
};
|
|
1116
|
+
const suffix = profile === "default" ? "" : `.${profile}`;
|
|
1117
|
+
const polling = `inbox${suffix}.json`;
|
|
1118
|
+
try {
|
|
1119
|
+
const root = lstatSync(directory);
|
|
1120
|
+
if (!root.isDirectory() || root.isSymbolicLink() || realpathSync(directory) !== directory)
|
|
1121
|
+
fail("requires a canonical directory anchor");
|
|
1122
|
+
const beforeRoot = metadata(directory);
|
|
1123
|
+
const census = () => {
|
|
1124
|
+
const entries = new Map();
|
|
1125
|
+
const followers = new Set();
|
|
1126
|
+
const handle = opendirSync(directory, { bufferSize: 1 });
|
|
1127
|
+
try {
|
|
1128
|
+
for (;;) {
|
|
1129
|
+
const entry = handle.readSync();
|
|
1130
|
+
if (!entry)
|
|
1131
|
+
break;
|
|
1132
|
+
if (entries.size >= limits.maxDirectoryEntries)
|
|
1133
|
+
capacity();
|
|
1134
|
+
if (entry.name.toLowerCase() === "recovery")
|
|
1135
|
+
fail("retains unclassified recovery storage");
|
|
1136
|
+
const target = join(directory, entry.name);
|
|
1137
|
+
entries.set(entry.name, metadata(target));
|
|
1138
|
+
if (!/inbox/iu.test(entry.name))
|
|
1139
|
+
continue;
|
|
1140
|
+
const match = /^(inbox|follower-inbox-[a-f0-9]{16})(?:\.([a-z0-9]{1,32}))?\.json(\.segments)?$/u.exec(entry.name);
|
|
1141
|
+
if (!match || match[2] === "default")
|
|
1142
|
+
fail("has a noncanonical journal-like entry");
|
|
1143
|
+
const value = lstatSync(target);
|
|
1144
|
+
if (value.isSymbolicLink() || !(match[3] ? value.isDirectory() : value.isFile()))
|
|
1145
|
+
fail("has a linked or unexpected journal type");
|
|
1146
|
+
if ((match[2] ?? "default") !== profile)
|
|
1147
|
+
continue;
|
|
1148
|
+
if (match[1] !== "inbox")
|
|
1149
|
+
followers.add(entry.name.replace(/\.segments$/u, ""));
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
finally {
|
|
1153
|
+
handle.closeSync();
|
|
1154
|
+
}
|
|
1155
|
+
return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), followers: [...followers].sort() };
|
|
1156
|
+
};
|
|
1157
|
+
const before = census();
|
|
1158
|
+
const sources = [];
|
|
1159
|
+
const accounting = { directoryEntries: before.entries.length, files: 0, bytes: 0, work: 0 };
|
|
1160
|
+
let knownBotId = expected.botId;
|
|
1161
|
+
for (const name of [polling, ...before.followers]) {
|
|
1162
|
+
const remaining = { maxFiles: limits.maxFiles - accounting.files, maxBytes: limits.maxBytes - accounting.bytes,
|
|
1163
|
+
maxWork: limits.maxWork - accounting.work, maxEntries: limits.maxEntries };
|
|
1164
|
+
if (remaining.maxFiles <= 0 || remaining.maxBytes <= 0 || remaining.maxWork <= 0)
|
|
1165
|
+
capacity();
|
|
1166
|
+
const path = join(directory, name);
|
|
1167
|
+
const evidence = inspectTelegramUpdateJournalFamily({ directory, path, profile,
|
|
1168
|
+
botIdentity: { ...expected, ...(knownBotId !== undefined ? { botId: knownBotId } : {}) }, limits: remaining });
|
|
1169
|
+
if (evidence.kind === "absent" && (name !== polling || before.entries.some(([entry]) => entry === polling || entry === `${polling}.segments`))) {
|
|
1170
|
+
fail("lost a discovered journal family");
|
|
1171
|
+
}
|
|
1172
|
+
if (evidence.kind === "present") {
|
|
1173
|
+
knownBotId = evidence.knownBotId;
|
|
1174
|
+
accounting.files += evidence.accounting.files;
|
|
1175
|
+
accounting.bytes += evidence.accounting.bytes;
|
|
1176
|
+
}
|
|
1177
|
+
accounting.work += evidence.kind === "present" ? Math.max(1, evidence.accounting.work) : 1;
|
|
1178
|
+
sources.push({ role: name === polling ? "polling" : "follower", path, evidence });
|
|
1179
|
+
}
|
|
1180
|
+
if (!isDeepStrictEqual(before, census()) || !isDeepStrictEqual(beforeRoot, metadata(directory)))
|
|
1181
|
+
fail("changed observed root namespace or identity");
|
|
1182
|
+
return { sources, accounting, ...(knownBotId !== undefined ? { knownBotId } : {}) };
|
|
1183
|
+
}
|
|
1184
|
+
catch (error) {
|
|
1185
|
+
if (error instanceof TelegramUpdateJournalError)
|
|
1186
|
+
throw error;
|
|
1187
|
+
throw createJournalError("io", directory, "could not establish canonical namespace evidence", error);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
function cloneEntry(entry) {
|
|
1191
|
+
return {
|
|
1192
|
+
...entry,
|
|
1193
|
+
update: structuredClone(entry.update),
|
|
1194
|
+
...(entry.inputClaim ? { inputClaim: structuredClone(entry.inputClaim) } : {}),
|
|
1195
|
+
...(entry.inputProvenance
|
|
1196
|
+
? { inputProvenance: structuredClone(entry.inputProvenance) } : {}),
|
|
1197
|
+
...(entry.queueOwner
|
|
1198
|
+
? { queueOwner: cloneJournalQueueOwner(entry.queueOwner) }
|
|
1199
|
+
: {}),
|
|
1200
|
+
...(entry.queueHandoff
|
|
1201
|
+
? { queueHandoff: cloneJournalQueueHandoff(entry.queueHandoff) }
|
|
1202
|
+
: {}),
|
|
1203
|
+
...(entry.failure ? { failure: { ...entry.failure } } : {}),
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
function cloneFile(file) {
|
|
1207
|
+
return {
|
|
1208
|
+
version: file.version,
|
|
1209
|
+
...(file.revision !== undefined ? { revision: file.revision } : {}),
|
|
1210
|
+
...(file.acceptedThroughUpdateId !== undefined
|
|
1211
|
+
? { acceptedThroughUpdateId: file.acceptedThroughUpdateId }
|
|
1212
|
+
: {}),
|
|
1213
|
+
profile: file.profile,
|
|
1214
|
+
botIdentity: { ...file.botIdentity },
|
|
1215
|
+
entries: file.entries.map(cloneEntry),
|
|
1216
|
+
...(file.operatorDispositions?.length
|
|
1217
|
+
? {
|
|
1218
|
+
operatorDispositions: file.operatorDispositions.map((disposition) => ({ ...disposition })),
|
|
1219
|
+
}
|
|
1220
|
+
: {}),
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
function serializeJournalFile(file) {
|
|
1224
|
+
return `${JSON.stringify(file, null, 2)}\n`;
|
|
1225
|
+
}
|
|
1226
|
+
function getTelegramUpdateJournalSegmentDirectory(path) {
|
|
1227
|
+
return `${path}.segments`;
|
|
1228
|
+
}
|
|
1229
|
+
function getTelegramUpdateJournalSegmentPath(path, revision) {
|
|
1230
|
+
return join(getTelegramUpdateJournalSegmentDirectory(path), `${String(revision).padStart(16, "0")}.json`);
|
|
1231
|
+
}
|
|
1232
|
+
function publishTelegramUpdateJournalSegmentUnlocked(path, segment, onPublicationBoundary) {
|
|
1233
|
+
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
1234
|
+
const segmentPath = getTelegramUpdateJournalSegmentPath(path, segment.revision);
|
|
1235
|
+
const serialized = `${JSON.stringify(segment, null, 2)}\n`;
|
|
1236
|
+
mkdirSync(segmentDirectory, { recursive: true, mode: 0o700 });
|
|
1237
|
+
const revisions = readdirSync(segmentDirectory)
|
|
1238
|
+
.flatMap((name) => {
|
|
1239
|
+
const match = name.match(/^(\d{16})\.json$/u);
|
|
1240
|
+
return match ? [Number(match[1])] : [];
|
|
1241
|
+
})
|
|
1242
|
+
.filter(Number.isSafeInteger);
|
|
1243
|
+
let snapshotRevision = 0;
|
|
1244
|
+
try {
|
|
1245
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1246
|
+
snapshotRevision = parseJournalFile(parsed, path, segment.version).revision ?? 0;
|
|
1247
|
+
}
|
|
1248
|
+
catch (error) {
|
|
1249
|
+
if (error?.code !== "ENOENT")
|
|
1250
|
+
throw error;
|
|
1251
|
+
}
|
|
1252
|
+
const latestRevision = Math.max(snapshotRevision, ...revisions, 0);
|
|
1253
|
+
if (latestRevision >= segment.revision) {
|
|
1254
|
+
try {
|
|
1255
|
+
if (readFileSync(segmentPath, "utf8") === serialized) {
|
|
1256
|
+
return {
|
|
1257
|
+
path: segmentPath,
|
|
1258
|
+
revision: segment.revision,
|
|
1259
|
+
serializedBytes: Buffer.byteLength(serialized),
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
catch {
|
|
1264
|
+
// A compacted snapshot may already contain this revision.
|
|
1265
|
+
}
|
|
1266
|
+
throw new Error("Telegram update journal segment revision conflicts.");
|
|
1267
|
+
}
|
|
1268
|
+
if (latestRevision !== segment.previousRevision) {
|
|
1269
|
+
throw new Error("Telegram update journal segment revision has a gap.");
|
|
1270
|
+
}
|
|
1271
|
+
writeJournalFile(segmentPath, serialized, onPublicationBoundary);
|
|
1272
|
+
return {
|
|
1273
|
+
path: segmentPath,
|
|
1274
|
+
revision: segment.revision,
|
|
1275
|
+
serializedBytes: Buffer.byteLength(serialized),
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
export function publishTelegramUpdateJournalSegment(path, segment) {
|
|
1279
|
+
if (segment.version !== TELEGRAM_UPDATE_JOURNAL_VERSION ||
|
|
1280
|
+
!isSafePositiveInteger(segment.revision) ||
|
|
1281
|
+
segment.previousRevision !== segment.revision - 1 ||
|
|
1282
|
+
!isNonEmptyString(segment.profile) ||
|
|
1283
|
+
!Array.isArray(segment.upsertedEntries) ||
|
|
1284
|
+
!Array.isArray(segment.removedUpdateIds)) {
|
|
1285
|
+
throw new Error("Telegram update journal segment is invalid.");
|
|
1286
|
+
}
|
|
1287
|
+
return withTelegramFileTransaction(`${path}.transaction`, () => publishTelegramUpdateJournalSegmentUnlocked(path, segment));
|
|
1288
|
+
}
|
|
1289
|
+
function normalizeCapacityLimit(value, fallback, label) {
|
|
1290
|
+
const resolved = value ?? fallback;
|
|
1291
|
+
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
1292
|
+
throw new Error(`Telegram update journal ${label} must be a positive integer.`);
|
|
1293
|
+
}
|
|
1294
|
+
return resolved;
|
|
1295
|
+
}
|
|
1296
|
+
function identitiesMatch(left, right) {
|
|
1297
|
+
if (left.botId !== undefined &&
|
|
1298
|
+
right.botId !== undefined &&
|
|
1299
|
+
left.botId !== right.botId) {
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
return ((left.botId !== undefined && left.botId === right.botId) ||
|
|
1303
|
+
left.tokenSha256 === right.tokenSha256);
|
|
1304
|
+
}
|
|
1305
|
+
function mergeBotIdentity(stored, current) {
|
|
1306
|
+
return {
|
|
1307
|
+
...(current.botId !== undefined
|
|
1308
|
+
? { botId: current.botId }
|
|
1309
|
+
: stored.botId !== undefined
|
|
1310
|
+
? { botId: stored.botId }
|
|
1311
|
+
: {}),
|
|
1312
|
+
tokenSha256: current.tokenSha256,
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
function writeJournalFile(path, serialized, onPublicationBoundary, stagingPath = path) {
|
|
1316
|
+
const tempPath = `${stagingPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1317
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
1318
|
+
try {
|
|
1319
|
+
onPublicationBoundary?.("before-write", path);
|
|
1320
|
+
writeFileSync(tempPath, serialized, { encoding: "utf8", mode: 0o600 });
|
|
1321
|
+
chmodSync(tempPath, 0o600);
|
|
1322
|
+
onPublicationBoundary?.("after-write-before-rename", path);
|
|
1323
|
+
if (!renameTelegramPathWithRetry(tempPath, path)) {
|
|
1324
|
+
throw new Error("Temporary journal file disappeared before publication.");
|
|
1325
|
+
}
|
|
1326
|
+
chmodSync(path, 0o600);
|
|
1327
|
+
}
|
|
1328
|
+
finally {
|
|
1329
|
+
try {
|
|
1330
|
+
unlinkSync(tempPath);
|
|
1331
|
+
}
|
|
1332
|
+
catch {
|
|
1333
|
+
// The successful atomic rename already consumed the temporary path.
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
export function createTelegramUpdateQueueHandoffToken() {
|
|
1338
|
+
return randomBytes(32).toString("base64url");
|
|
1339
|
+
}
|
|
1340
|
+
export function createTelegramUpdateJournalBotIdentity(input) {
|
|
1341
|
+
if (!input.botToken) {
|
|
1342
|
+
throw new Error("Telegram update journal requires a configured bot token.");
|
|
1343
|
+
}
|
|
1344
|
+
if (input.botId !== undefined && !isSafePositiveInteger(input.botId)) {
|
|
1345
|
+
throw new Error("Telegram update journal bot id must be a safe integer.");
|
|
1346
|
+
}
|
|
1347
|
+
return {
|
|
1348
|
+
...(input.botId !== undefined ? { botId: input.botId } : {}),
|
|
1349
|
+
tokenSha256: createHash("sha256").update(input.botToken).digest("hex"),
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
export function createTelegramUpdateJournalReceiptScope(input) {
|
|
1353
|
+
const profile = (input.profileName ?? TELEGRAM_DEFAULT_PROFILE_NAME).trim();
|
|
1354
|
+
if (!profile) {
|
|
1355
|
+
throw new Error("Telegram update journal receipt scope requires a profile.");
|
|
1356
|
+
}
|
|
1357
|
+
if (input.botIdentity.botId !== undefined &&
|
|
1358
|
+
!isSafePositiveInteger(input.botIdentity.botId)) {
|
|
1359
|
+
throw new Error("Telegram update journal bot id must be a safe integer.");
|
|
1360
|
+
}
|
|
1361
|
+
if (!/^[a-f0-9]{64}$/u.test(input.botIdentity.tokenSha256)) {
|
|
1362
|
+
throw new Error("Telegram update journal receipt scope requires a SHA-256 token fingerprint.");
|
|
1363
|
+
}
|
|
1364
|
+
return JSON.stringify({
|
|
1365
|
+
version: TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION,
|
|
1366
|
+
profile,
|
|
1367
|
+
bot: input.botIdentity.botId === undefined
|
|
1368
|
+
? { tokenSha256: input.botIdentity.tokenSha256 }
|
|
1369
|
+
: { botId: input.botIdentity.botId },
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
export function createTelegramUpdateJournalBindingKey(input) {
|
|
1373
|
+
if (!input.path) {
|
|
1374
|
+
throw new Error("Telegram update journal binding requires a path.");
|
|
1375
|
+
}
|
|
1376
|
+
return JSON.stringify({
|
|
1377
|
+
version: TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION,
|
|
1378
|
+
path: input.path,
|
|
1379
|
+
receiptScope: createTelegramUpdateJournalReceiptScope(input),
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
export function getTelegramUpdateJournalBindingPath(journalBindingKey) {
|
|
1383
|
+
try {
|
|
1384
|
+
const value = JSON.parse(journalBindingKey);
|
|
1385
|
+
return value.version === TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION &&
|
|
1386
|
+
typeof value.path === "string" &&
|
|
1387
|
+
value.path.length > 0 &&
|
|
1388
|
+
typeof value.receiptScope === "string"
|
|
1389
|
+
? value.path
|
|
1390
|
+
: undefined;
|
|
1391
|
+
}
|
|
1392
|
+
catch {
|
|
1393
|
+
return undefined;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
export function createTelegramUpdateJournalReceiptScopeResolver(deps) {
|
|
1397
|
+
let identityKey;
|
|
1398
|
+
let receiptScope;
|
|
1399
|
+
return () => {
|
|
1400
|
+
const botToken = deps.getBotToken();
|
|
1401
|
+
if (!botToken) {
|
|
1402
|
+
identityKey = undefined;
|
|
1403
|
+
receiptScope = undefined;
|
|
1404
|
+
return undefined;
|
|
1405
|
+
}
|
|
1406
|
+
const profileName = deps.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME;
|
|
1407
|
+
const botIdentity = createTelegramUpdateJournalBotIdentity({
|
|
1408
|
+
botToken,
|
|
1409
|
+
botId: deps.getBotId(),
|
|
1410
|
+
});
|
|
1411
|
+
const nextIdentityKey = `${profileName}\u0000${botIdentity.tokenSha256}`;
|
|
1412
|
+
if (nextIdentityKey === identityKey && receiptScope)
|
|
1413
|
+
return receiptScope;
|
|
1414
|
+
identityKey = nextIdentityKey;
|
|
1415
|
+
receiptScope = createTelegramUpdateJournalReceiptScope({
|
|
1416
|
+
profileName,
|
|
1417
|
+
botIdentity,
|
|
1418
|
+
});
|
|
1419
|
+
return receiptScope;
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
export function createTelegramUpdateJournalRuntimeBindingResolver(deps) {
|
|
1423
|
+
return () => {
|
|
1424
|
+
const botToken = deps.getBotToken();
|
|
1425
|
+
if (!botToken)
|
|
1426
|
+
return undefined;
|
|
1427
|
+
const configuredProfileName = deps.getProfileName();
|
|
1428
|
+
const profileName = configuredProfileName ?? TELEGRAM_DEFAULT_PROFILE_NAME;
|
|
1429
|
+
const botIdentity = createTelegramUpdateJournalBotIdentity({
|
|
1430
|
+
botToken,
|
|
1431
|
+
botId: deps.getBotId(),
|
|
1432
|
+
});
|
|
1433
|
+
const path = deps.getJournalPath(configuredProfileName);
|
|
1434
|
+
const workspaceAdmission = deps.getWorkspaceAdmission?.();
|
|
1435
|
+
return {
|
|
1436
|
+
runtimeKey: JSON.stringify({
|
|
1437
|
+
path,
|
|
1438
|
+
profileName,
|
|
1439
|
+
botIdentity,
|
|
1440
|
+
}),
|
|
1441
|
+
recoveryKey: createTelegramUpdateJournalBindingKey({
|
|
1442
|
+
path,
|
|
1443
|
+
profileName,
|
|
1444
|
+
botIdentity,
|
|
1445
|
+
}),
|
|
1446
|
+
journal: createTelegramUpdateJournalStore({
|
|
1447
|
+
path,
|
|
1448
|
+
profileName,
|
|
1449
|
+
botIdentity,
|
|
1450
|
+
...(deps.getQueueRuntimeIdentity
|
|
1451
|
+
? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() }
|
|
1452
|
+
: {}),
|
|
1453
|
+
...(workspaceAdmission ? { workspaceAdmission } : {}),
|
|
1454
|
+
...(deps.withWriterAdmission ? { withWriterAdmission: deps.withWriterAdmission } : {}),
|
|
1455
|
+
...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}),
|
|
1456
|
+
}),
|
|
1457
|
+
};
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
export function createTelegramUpdateJournalReferenceRegistry(input = {}) {
|
|
1461
|
+
const maxActive = input.maxActive ?? 64;
|
|
1462
|
+
let sequence = 0;
|
|
1463
|
+
const active = new Map();
|
|
1464
|
+
return {
|
|
1465
|
+
acquire(reference) {
|
|
1466
|
+
if (!reference.recoveryKey || active.size >= maxActive)
|
|
1467
|
+
throw new Error("Telegram update journal reference registry is unavailable or full.");
|
|
1468
|
+
const id = ++sequence;
|
|
1469
|
+
active.set(id, { ...reference });
|
|
1470
|
+
let released = false;
|
|
1471
|
+
return () => {
|
|
1472
|
+
if (released || !active.delete(id))
|
|
1473
|
+
throw new Error("Telegram update journal reference lease is stale.");
|
|
1474
|
+
released = true;
|
|
1475
|
+
};
|
|
1476
|
+
},
|
|
1477
|
+
list: () => [...active.values()].map(reference => ({ ...reference })),
|
|
1478
|
+
withReference(reference, operation) {
|
|
1479
|
+
const release = this.acquire(reference);
|
|
1480
|
+
try {
|
|
1481
|
+
const result = operation();
|
|
1482
|
+
if (result && typeof result.finally === "function")
|
|
1483
|
+
return result.finally(release);
|
|
1484
|
+
release();
|
|
1485
|
+
return result;
|
|
1486
|
+
}
|
|
1487
|
+
catch (error) {
|
|
1488
|
+
release();
|
|
1489
|
+
throw error;
|
|
1490
|
+
}
|
|
1491
|
+
},
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
export function withTelegramResolvedUpdateJournalReference(input) {
|
|
1495
|
+
const binding = input.resolveBinding();
|
|
1496
|
+
if (!binding)
|
|
1497
|
+
return undefined;
|
|
1498
|
+
return input.registry.withReference({ referenceClass: input.referenceClass,
|
|
1499
|
+
recoveryKey: binding.recoveryKey }, () => input.operation(binding));
|
|
1500
|
+
}
|
|
1501
|
+
export function createTelegramUpdateJournalBindingRuntime(deps) {
|
|
1502
|
+
const resolveLeader = createTelegramUpdateJournalRuntimeBindingResolver({
|
|
1503
|
+
...deps.base,
|
|
1504
|
+
getJournalPath: deps.getLeaderJournalPath,
|
|
1505
|
+
});
|
|
1506
|
+
const createFollowerResolver = (bindingKey, includeQueueRuntimeIdentity) => createTelegramUpdateJournalRuntimeBindingResolver({
|
|
1507
|
+
getProfileName: deps.base.getProfileName,
|
|
1508
|
+
getBotToken: deps.base.getBotToken,
|
|
1509
|
+
getBotId: deps.base.getBotId,
|
|
1510
|
+
...(includeQueueRuntimeIdentity && deps.base.getQueueRuntimeIdentity
|
|
1511
|
+
? { getQueueRuntimeIdentity: deps.base.getQueueRuntimeIdentity }
|
|
1512
|
+
: {}),
|
|
1513
|
+
...(deps.base.withWriterAdmission
|
|
1514
|
+
? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
|
|
1515
|
+
...(deps.base.getWorkspaceAdmission
|
|
1516
|
+
? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
|
|
1517
|
+
: {}),
|
|
1518
|
+
...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
|
|
1519
|
+
getJournalPath(profileName) {
|
|
1520
|
+
return deps.getFollowerJournalPath(bindingKey, profileName);
|
|
1521
|
+
},
|
|
1522
|
+
});
|
|
1523
|
+
const resolveFollower = () => createFollowerResolver(deps.getActiveFollowerBindingKey(), true)();
|
|
1524
|
+
const resolveActive = () => deps.isFollowerRegistered() ? resolveFollower() : resolveLeader();
|
|
1525
|
+
return {
|
|
1526
|
+
resolveLeader,
|
|
1527
|
+
resolveFollower,
|
|
1528
|
+
resolveActive,
|
|
1529
|
+
getActiveRecoveryKey: () => resolveActive()?.recoveryKey,
|
|
1530
|
+
createRecipientResolver: (bindingKey) => createFollowerResolver(bindingKey, false),
|
|
1531
|
+
createPathResolver: (path) => createTelegramUpdateJournalRuntimeBindingResolver({
|
|
1532
|
+
getProfileName: deps.base.getProfileName,
|
|
1533
|
+
getBotToken: deps.base.getBotToken,
|
|
1534
|
+
getBotId: deps.base.getBotId,
|
|
1535
|
+
...(deps.base.withWriterAdmission
|
|
1536
|
+
? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
|
|
1537
|
+
...(deps.base.getWorkspaceAdmission
|
|
1538
|
+
? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
|
|
1539
|
+
: {}),
|
|
1540
|
+
...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
|
|
1541
|
+
getJournalPath: () => path,
|
|
1542
|
+
}),
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
export function createTelegramUpdateJournalStore(options) {
|
|
1546
|
+
return createJournalStoreCore(options).journal;
|
|
1547
|
+
}
|
|
1548
|
+
/** Opt-in v3 only; does not migrate old files or expose legacy unowned mutation ports. */
|
|
1549
|
+
export function createTelegramInputJournalStore(options) {
|
|
1550
|
+
const captured = { ...options };
|
|
1551
|
+
if (!captured.sourceAccess || !captured.queueRuntimeIdentity ||
|
|
1552
|
+
typeof captured.withSourceSerialization !== "function" ||
|
|
1553
|
+
typeof captured.withPairingAdmission !== "function" ||
|
|
1554
|
+
typeof captured.getInputContext !== "function" ||
|
|
1555
|
+
("withPairedAdmission" in captured && captured.withPairedAdmission !== undefined)) {
|
|
1556
|
+
throw new Error("Telegram input custody requires strict serialized polling admission and runtime identity.");
|
|
1557
|
+
}
|
|
1558
|
+
return createJournalStoreCore({ ...captured, queueRuntimeIdentity: { ...captured.queueRuntimeIdentity } }, captured.getInputContext).input;
|
|
1559
|
+
}
|
|
1560
|
+
function createJournalStoreCore(options, getInputContext) {
|
|
1561
|
+
const path = options.path;
|
|
1562
|
+
const profile = options.profileName ?? TELEGRAM_DEFAULT_PROFILE_NAME;
|
|
1563
|
+
if (!path)
|
|
1564
|
+
throw new Error("Telegram update journal path is required.");
|
|
1565
|
+
if (!profile)
|
|
1566
|
+
throw new Error("Telegram update journal profile is required.");
|
|
1567
|
+
const expectedIdentity = validateBotIdentity(options.botIdentity, path);
|
|
1568
|
+
const queueRuntimeIdentity = options.queueRuntimeIdentity;
|
|
1569
|
+
if (queueRuntimeIdentity !== undefined &&
|
|
1570
|
+
(!isBoundedString(queueRuntimeIdentity.instanceId, TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH) ||
|
|
1571
|
+
!isSafePositiveInteger(queueRuntimeIdentity.processId) ||
|
|
1572
|
+
!isBoundedString(queueRuntimeIdentity.processBirthId, TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH))) {
|
|
1573
|
+
throw new Error("Telegram update journal queue process identity is invalid.");
|
|
1574
|
+
}
|
|
1575
|
+
const maxEntries = normalizeCapacityLimit(options.maxEntries, TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, "entry limit");
|
|
1576
|
+
const maxBytes = normalizeCapacityLimit(options.maxBytes, TELEGRAM_UPDATE_JOURNAL_MAX_BYTES, "byte limit");
|
|
1577
|
+
const getNowMs = options.getNowMs ?? Date.now;
|
|
1578
|
+
const onPublicationBoundary = options.onPublicationBoundary;
|
|
1579
|
+
const workspaceAdmission = options.workspaceAdmission;
|
|
1580
|
+
const withSourceSerialization = options.withSourceSerialization;
|
|
1581
|
+
const sourceAccess = options.sourceAccess && {
|
|
1582
|
+
directory: options.sourceAccess.directory,
|
|
1583
|
+
limits: { ...options.sourceAccess.limits },
|
|
1584
|
+
};
|
|
1585
|
+
if (sourceAccess && !withSourceSerialization) {
|
|
1586
|
+
throw new Error("Telegram journal source access requires source serialization.");
|
|
1587
|
+
}
|
|
1588
|
+
const withPairingAdmission = options.withPairingAdmission;
|
|
1589
|
+
const withPairedAdmission = options.withPairedAdmission;
|
|
1590
|
+
if (withPairingAdmission && withPairedAdmission) {
|
|
1591
|
+
throw new Error("Telegram journal admission modes are mutually exclusive.");
|
|
1592
|
+
}
|
|
1593
|
+
const version = getInputContext ? TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION
|
|
1594
|
+
: withPairingAdmission ? TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION : TELEGRAM_UPDATE_JOURNAL_VERSION;
|
|
1595
|
+
const notifyRecovery = (event) => {
|
|
1596
|
+
try {
|
|
1597
|
+
options.onRecovery?.(event);
|
|
1598
|
+
}
|
|
1599
|
+
catch {
|
|
1600
|
+
// Recovery diagnostics must not break recovered journal authority.
|
|
1601
|
+
}
|
|
1602
|
+
};
|
|
1603
|
+
const getQueueProcessLiveness = options.getQueueProcessLiveness ?? getTelegramProcessLiveness;
|
|
1604
|
+
const validateQueueHandoffInput = (input, operation) => {
|
|
1605
|
+
if ((input.queueKind !== "prompt" && input.queueKind !== "control") ||
|
|
1606
|
+
!isNonEmptyString(input.receiptId) ||
|
|
1607
|
+
!Array.isArray(input.sourceUpdateIds) ||
|
|
1608
|
+
input.sourceUpdateIds.length === 0 ||
|
|
1609
|
+
!isBoundedString(input.handoffToken, TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH) ||
|
|
1610
|
+
input.handoffToken.length <
|
|
1611
|
+
TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH) {
|
|
1612
|
+
throw createJournalError("invalid", path, `received an invalid queue handoff ${operation}`);
|
|
1613
|
+
}
|
|
1614
|
+
const expectedOwner = validateJournalQueueOwner(input.expectedOwner, path);
|
|
1615
|
+
const recipientOwner = validateJournalQueueOwnerIdentity(input.recipientOwner, path);
|
|
1616
|
+
const runtimeIdentity = operation === "accept" ? recipientOwner : expectedOwner;
|
|
1617
|
+
if (queueRuntimeIdentity &&
|
|
1618
|
+
(runtimeIdentity.instanceId !== queueRuntimeIdentity.instanceId ||
|
|
1619
|
+
runtimeIdentity.processId !== queueRuntimeIdentity.processId ||
|
|
1620
|
+
runtimeIdentity.processBirthId !== queueRuntimeIdentity.processBirthId)) {
|
|
1621
|
+
throw createJournalError("conflict", path, operation === "accept"
|
|
1622
|
+
? `cannot accept queue receipt ${input.receiptId} for another runtime`
|
|
1623
|
+
: `cannot ${operation} foreign queue receipt ${input.receiptId}`);
|
|
1624
|
+
}
|
|
1625
|
+
const requestedIds = new Set();
|
|
1626
|
+
for (const updateId of input.sourceUpdateIds) {
|
|
1627
|
+
if (!isSafeNonNegativeInteger(updateId) || requestedIds.has(updateId)) {
|
|
1628
|
+
throw createJournalError("invalid", path, `received invalid queue handoff ${operation} update ids`);
|
|
1629
|
+
}
|
|
1630
|
+
requestedIds.add(updateId);
|
|
1631
|
+
}
|
|
1632
|
+
return {
|
|
1633
|
+
expectedOwner,
|
|
1634
|
+
recipientOwner,
|
|
1635
|
+
requestedIds,
|
|
1636
|
+
handoffId: createTelegramUpdateQueueHandoffId({
|
|
1637
|
+
handoffToken: input.handoffToken,
|
|
1638
|
+
queueKind: input.queueKind,
|
|
1639
|
+
receiptId: input.receiptId,
|
|
1640
|
+
sourceUpdateIds: [...requestedIds],
|
|
1641
|
+
expectedOwner,
|
|
1642
|
+
recipientOwner,
|
|
1643
|
+
}),
|
|
1644
|
+
};
|
|
1645
|
+
};
|
|
1646
|
+
const getExactQueuedReceiptEntries = (current, input, requestedIds) => {
|
|
1647
|
+
const receiptEntries = current.file.entries.filter((entry) => entry.queueReceiptId === input.receiptId);
|
|
1648
|
+
if (receiptEntries.length !== requestedIds.size ||
|
|
1649
|
+
receiptEntries.some((entry) => !requestedIds.has(entry.updateId) ||
|
|
1650
|
+
entry.state !== "queued" ||
|
|
1651
|
+
entry.queueKind !== input.queueKind)) {
|
|
1652
|
+
throw createJournalError("conflict", path, `cannot hand off stale queue receipt ${input.receiptId}`);
|
|
1653
|
+
}
|
|
1654
|
+
return receiptEntries;
|
|
1655
|
+
};
|
|
1656
|
+
const assertCapacity = (file, serialized = serializeJournalFile(file)) => {
|
|
1657
|
+
if (file.entries.length > maxEntries) {
|
|
1658
|
+
throw createJournalError("capacity", path, `exceeds its ${maxEntries}-entry limit`);
|
|
1659
|
+
}
|
|
1660
|
+
if ((file.operatorDispositions?.length ?? 0) > maxEntries) {
|
|
1661
|
+
throw createJournalError("capacity", path, `exceeds its ${maxEntries}-operator-disposition limit`);
|
|
1662
|
+
}
|
|
1663
|
+
const serializedBytes = Buffer.byteLength(serialized);
|
|
1664
|
+
if (serializedBytes > maxBytes) {
|
|
1665
|
+
throw createJournalError("capacity", path, `exceeds its ${maxBytes}-byte limit`);
|
|
1666
|
+
}
|
|
1667
|
+
return serializedBytes;
|
|
1668
|
+
};
|
|
1669
|
+
const emptyFile = () => ({
|
|
1670
|
+
version,
|
|
1671
|
+
profile,
|
|
1672
|
+
botIdentity: { ...expectedIdentity },
|
|
1673
|
+
entries: [],
|
|
1674
|
+
});
|
|
1675
|
+
const readCurrentStrict = () => {
|
|
1676
|
+
let source;
|
|
1677
|
+
let recoveringMissingSnapshot = false;
|
|
1678
|
+
try {
|
|
1679
|
+
const size = statSync(path).size;
|
|
1680
|
+
if (size > maxBytes) {
|
|
1681
|
+
throw createJournalError("capacity", path, `exceeds its ${maxBytes}-byte limit`);
|
|
1682
|
+
}
|
|
1683
|
+
source = readFileSync(path, "utf8");
|
|
1684
|
+
}
|
|
1685
|
+
catch (error) {
|
|
1686
|
+
if (error instanceof TelegramUpdateJournalError)
|
|
1687
|
+
throw error;
|
|
1688
|
+
if (error?.code === "ENOENT") {
|
|
1689
|
+
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
1690
|
+
let orphanedSegmentNames;
|
|
1691
|
+
try {
|
|
1692
|
+
orphanedSegmentNames = readdirSync(segmentDirectory).filter((name) => /^\d{16}\.json$/u.test(name));
|
|
1693
|
+
}
|
|
1694
|
+
catch (segmentError) {
|
|
1695
|
+
if (segmentError?.code === "ENOENT") {
|
|
1696
|
+
return { file: emptyFile(), exists: false, serializedBytes: 0 };
|
|
1697
|
+
}
|
|
1698
|
+
throw createJournalError("io", segmentDirectory, "could not be read while the journal snapshot is missing", segmentError);
|
|
1699
|
+
}
|
|
1700
|
+
if (orphanedSegmentNames.length > 0) {
|
|
1701
|
+
if (withPairingAdmission)
|
|
1702
|
+
throw createJournalError("pairing-evidence", path, "requires explicit missing-snapshot reconciliation");
|
|
1703
|
+
recoveringMissingSnapshot = true;
|
|
1704
|
+
source = serializeJournalFile(emptyFile());
|
|
1705
|
+
}
|
|
1706
|
+
else {
|
|
1707
|
+
return { file: emptyFile(), exists: false, serializedBytes: 0 };
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
else {
|
|
1711
|
+
throw createJournalError("io", path, "could not be read", error);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
let parsed;
|
|
1715
|
+
try {
|
|
1716
|
+
parsed = JSON.parse(source);
|
|
1717
|
+
}
|
|
1718
|
+
catch (error) {
|
|
1719
|
+
throw createJournalError("invalid", path, "contains invalid JSON", error);
|
|
1720
|
+
}
|
|
1721
|
+
let file = parseJournalFile(parsed, path, version);
|
|
1722
|
+
const storedProfile = file.profile;
|
|
1723
|
+
const storedIdentity = file.botIdentity;
|
|
1724
|
+
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
1725
|
+
let segmentNames = [];
|
|
1726
|
+
try {
|
|
1727
|
+
segmentNames = readdirSync(segmentDirectory).filter((name) => /^\d{16}\.json$/u.test(name));
|
|
1728
|
+
}
|
|
1729
|
+
catch (error) {
|
|
1730
|
+
if (error?.code !== "ENOENT") {
|
|
1731
|
+
throw createJournalError("io", segmentDirectory, "could not be read", error);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
segmentNames.sort();
|
|
1735
|
+
let revision = file.revision ?? 0;
|
|
1736
|
+
let unappliedSegmentBytes = 0;
|
|
1737
|
+
let orphanRecoverySawUpsert = false;
|
|
1738
|
+
let orphanRecoverySawBaseRemoval = false;
|
|
1739
|
+
let orphanRecoveryUnsafe = false;
|
|
1740
|
+
for (const name of segmentNames) {
|
|
1741
|
+
const nameRevision = Number(name.slice(0, 16));
|
|
1742
|
+
if (nameRevision <= revision)
|
|
1743
|
+
continue;
|
|
1744
|
+
const segmentPath = join(segmentDirectory, name);
|
|
1745
|
+
let segment;
|
|
1746
|
+
try {
|
|
1747
|
+
const segmentSize = statSync(segmentPath).size;
|
|
1748
|
+
if (segmentSize > maxBytes) {
|
|
1749
|
+
throw createJournalError("capacity", segmentPath, `exceeds its ${maxBytes}-byte limit`);
|
|
1750
|
+
}
|
|
1751
|
+
unappliedSegmentBytes += segmentSize;
|
|
1752
|
+
if (unappliedSegmentBytes > maxBytes) {
|
|
1753
|
+
throw createJournalError("capacity", segmentDirectory, `exceeds its ${maxBytes}-byte unapplied-segment limit`);
|
|
1754
|
+
}
|
|
1755
|
+
segment = parseJournalSegment(JSON.parse(readFileSync(segmentPath, "utf8")), segmentPath, version);
|
|
1756
|
+
}
|
|
1757
|
+
catch (error) {
|
|
1758
|
+
if (error instanceof TelegramUpdateJournalError)
|
|
1759
|
+
throw error;
|
|
1760
|
+
throw createJournalError("invalid", segmentPath, "contains invalid JSON", error);
|
|
1761
|
+
}
|
|
1762
|
+
if (segment.revision !== nameRevision) {
|
|
1763
|
+
throw createJournalError("invalid", segmentPath, "revision does not match its file name");
|
|
1764
|
+
}
|
|
1765
|
+
if (segment.previousRevision !== revision) {
|
|
1766
|
+
throw createJournalError("invalid", segmentPath, `has a revision gap after ${revision}`);
|
|
1767
|
+
}
|
|
1768
|
+
if (segment.acceptedThroughUpdateId !== undefined &&
|
|
1769
|
+
file.acceptedThroughUpdateId !== undefined &&
|
|
1770
|
+
segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId) {
|
|
1771
|
+
throw createJournalError("invalid", segmentPath, "regresses the admission cursor");
|
|
1772
|
+
}
|
|
1773
|
+
if (segment.profile !== storedProfile ||
|
|
1774
|
+
!identitiesMatch(segment.botIdentity, storedIdentity) ||
|
|
1775
|
+
(withPairingAdmission && segment.botIdentity.tokenSha256 !== storedIdentity.tokenSha256)) {
|
|
1776
|
+
throw createJournalError("identity-mismatch", segmentPath, "belongs to another journal identity");
|
|
1777
|
+
}
|
|
1778
|
+
const entriesById = new Map(file.entries.map((entry) => [entry.updateId, entry]));
|
|
1779
|
+
for (const updateId of segment.removedUpdateIds) {
|
|
1780
|
+
if (recoveringMissingSnapshot && !entriesById.has(updateId)) {
|
|
1781
|
+
orphanRecoverySawBaseRemoval = true;
|
|
1782
|
+
if (orphanRecoverySawUpsert)
|
|
1783
|
+
orphanRecoveryUnsafe = true;
|
|
1784
|
+
}
|
|
1785
|
+
entriesById.delete(updateId);
|
|
1786
|
+
}
|
|
1787
|
+
for (const entry of segment.upsertedEntries) {
|
|
1788
|
+
const previous = entriesById.get(entry.updateId);
|
|
1789
|
+
if (withPairingAdmission &&
|
|
1790
|
+
((previous && previous.preApprovalExcluded !== entry.preApprovalExcluded) ||
|
|
1791
|
+
(!previous && entry.updateId <= (file.acceptedThroughUpdateId ?? -1)))) {
|
|
1792
|
+
throw createJournalError("pairing-evidence", segmentPath, "changes exclusion evidence or resurrects a settled source");
|
|
1793
|
+
}
|
|
1794
|
+
entriesById.set(entry.updateId, entry);
|
|
1795
|
+
}
|
|
1796
|
+
if (segment.upsertedEntries.length > 0)
|
|
1797
|
+
orphanRecoverySawUpsert = true;
|
|
1798
|
+
file = parseJournalFile({
|
|
1799
|
+
version,
|
|
1800
|
+
revision: segment.revision,
|
|
1801
|
+
...(segment.acceptedThroughUpdateId !== undefined
|
|
1802
|
+
? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId }
|
|
1803
|
+
: file.acceptedThroughUpdateId !== undefined
|
|
1804
|
+
? { acceptedThroughUpdateId: file.acceptedThroughUpdateId }
|
|
1805
|
+
: {}),
|
|
1806
|
+
profile: storedProfile,
|
|
1807
|
+
botIdentity: mergeBotIdentity(file.botIdentity, segment.botIdentity),
|
|
1808
|
+
entries: [...entriesById.values()].sort((left, right) => left.updateId - right.updateId),
|
|
1809
|
+
...(segment.operatorDispositions !== undefined
|
|
1810
|
+
? { operatorDispositions: segment.operatorDispositions }
|
|
1811
|
+
: file.operatorDispositions?.length
|
|
1812
|
+
? { operatorDispositions: file.operatorDispositions }
|
|
1813
|
+
: {}),
|
|
1814
|
+
}, segmentPath, version);
|
|
1815
|
+
revision = segment.revision;
|
|
1816
|
+
}
|
|
1817
|
+
const identityChanged = storedProfile !== profile ||
|
|
1818
|
+
!identitiesMatch(file.botIdentity, expectedIdentity) ||
|
|
1819
|
+
(withPairingAdmission && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
|
|
1820
|
+
if (identityChanged) {
|
|
1821
|
+
if (file.entries.length > 0 || withPairingAdmission) {
|
|
1822
|
+
throw createJournalError("identity-mismatch", path, storedProfile !== profile
|
|
1823
|
+
? `belongs to profile ${storedProfile}, not ${profile}`
|
|
1824
|
+
: "belongs to another Telegram bot identity");
|
|
1825
|
+
}
|
|
1826
|
+
file = {
|
|
1827
|
+
version,
|
|
1828
|
+
...(file.revision !== undefined ? { revision: file.revision } : {}),
|
|
1829
|
+
profile,
|
|
1830
|
+
botIdentity: { ...expectedIdentity },
|
|
1831
|
+
entries: [],
|
|
1832
|
+
};
|
|
1833
|
+
const rebound = serializeJournalFile(file);
|
|
1834
|
+
const reboundBytes = assertCapacity(file, rebound);
|
|
1835
|
+
writeJournalFile(path, rebound, onPublicationBoundary);
|
|
1836
|
+
for (const name of segmentNames) {
|
|
1837
|
+
if (Number(name.slice(0, 16)) <= revision) {
|
|
1838
|
+
try {
|
|
1839
|
+
unlinkSync(join(segmentDirectory, name));
|
|
1840
|
+
}
|
|
1841
|
+
catch {
|
|
1842
|
+
// The rebound snapshot owns the current revision and no old
|
|
1843
|
+
// identity authority; redundant cleanup remains best-effort.
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
try {
|
|
1848
|
+
rmdirSync(segmentDirectory);
|
|
1849
|
+
}
|
|
1850
|
+
catch {
|
|
1851
|
+
// Redundant old segments are ignored at or below the snapshot revision.
|
|
1852
|
+
}
|
|
1853
|
+
return { file, exists: true, serializedBytes: reboundBytes };
|
|
1854
|
+
}
|
|
1855
|
+
if (recoveringMissingSnapshot) {
|
|
1856
|
+
if (orphanRecoveryUnsafe ||
|
|
1857
|
+
!orphanRecoverySawBaseRemoval ||
|
|
1858
|
+
file.entries.length > 0) {
|
|
1859
|
+
throw createJournalError("invalid", path, `is missing while ${segmentDirectory} retains revision segments`);
|
|
1860
|
+
}
|
|
1861
|
+
const recovered = serializeJournalFile(file);
|
|
1862
|
+
const recoveredBytes = assertCapacity(file, recovered);
|
|
1863
|
+
writeJournalFile(path, recovered, onPublicationBoundary);
|
|
1864
|
+
notifyRecovery({
|
|
1865
|
+
kind: "repaired",
|
|
1866
|
+
path,
|
|
1867
|
+
revision: file.revision,
|
|
1868
|
+
reason: "Recovered a missing snapshot from a complete empty segment history.",
|
|
1869
|
+
});
|
|
1870
|
+
return { file, exists: true, serializedBytes: recoveredBytes };
|
|
1871
|
+
}
|
|
1872
|
+
const serializedBytes = assertCapacity(file);
|
|
1873
|
+
return { file, exists: true, serializedBytes };
|
|
1874
|
+
};
|
|
1875
|
+
const readCurrent = () => {
|
|
1876
|
+
try {
|
|
1877
|
+
return readCurrentStrict();
|
|
1878
|
+
}
|
|
1879
|
+
catch (error) {
|
|
1880
|
+
if (withPairingAdmission ||
|
|
1881
|
+
!(error instanceof TelegramUpdateJournalError) ||
|
|
1882
|
+
error.code !== "invalid") {
|
|
1883
|
+
throw error;
|
|
1884
|
+
}
|
|
1885
|
+
let snapshotExists = false;
|
|
1886
|
+
try {
|
|
1887
|
+
statSync(path);
|
|
1888
|
+
snapshotExists = true;
|
|
1889
|
+
}
|
|
1890
|
+
catch (snapshotError) {
|
|
1891
|
+
if (snapshotError?.code !== "ENOENT")
|
|
1892
|
+
throw error;
|
|
1893
|
+
}
|
|
1894
|
+
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
1895
|
+
let segmentNames;
|
|
1896
|
+
try {
|
|
1897
|
+
segmentNames = readdirSync(segmentDirectory)
|
|
1898
|
+
.filter((name) => /^\d{16}\.json$/u.test(name))
|
|
1899
|
+
.sort();
|
|
1900
|
+
}
|
|
1901
|
+
catch {
|
|
1902
|
+
throw error;
|
|
1903
|
+
}
|
|
1904
|
+
if (segmentNames.length === 0)
|
|
1905
|
+
throw error;
|
|
1906
|
+
// Earlier corruption must not hide a newer schema behind repair or reset.
|
|
1907
|
+
let recoverySegmentBytes = 0;
|
|
1908
|
+
for (const candidatePath of [
|
|
1909
|
+
...(snapshotExists ? [path] : []),
|
|
1910
|
+
...segmentNames.map((name) => join(segmentDirectory, name)),
|
|
1911
|
+
]) {
|
|
1912
|
+
const size = statSync(candidatePath).size;
|
|
1913
|
+
if (candidatePath !== path)
|
|
1914
|
+
recoverySegmentBytes += size;
|
|
1915
|
+
if (size > maxBytes || recoverySegmentBytes > maxBytes) {
|
|
1916
|
+
throw createJournalError("capacity", candidatePath, "exceeds the recovery inspection byte limit");
|
|
1917
|
+
}
|
|
1918
|
+
try {
|
|
1919
|
+
assertSupportedJournalVersion(JSON.parse(readFileSync(candidatePath, "utf8")), candidatePath);
|
|
1920
|
+
}
|
|
1921
|
+
catch (inspectionError) {
|
|
1922
|
+
if (!(inspectionError instanceof SyntaxError))
|
|
1923
|
+
throw inspectionError;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
if (snapshotExists) {
|
|
1927
|
+
try {
|
|
1928
|
+
const snapshot = parseJournalFile(JSON.parse(readFileSync(path, "utf8")), path);
|
|
1929
|
+
const firstSegmentPath = join(segmentDirectory, segmentNames[0]);
|
|
1930
|
+
const firstSegment = parseJournalSegment(JSON.parse(readFileSync(firstSegmentPath, "utf8")), firstSegmentPath);
|
|
1931
|
+
if (snapshot.revision === undefined &&
|
|
1932
|
+
firstSegment.previousRevision > 0 &&
|
|
1933
|
+
snapshot.profile === firstSegment.profile &&
|
|
1934
|
+
identitiesMatch(snapshot.botIdentity, firstSegment.botIdentity)) {
|
|
1935
|
+
writeJournalFile(path, serializeJournalFile({
|
|
1936
|
+
...snapshot,
|
|
1937
|
+
revision: firstSegment.previousRevision,
|
|
1938
|
+
}), onPublicationBoundary);
|
|
1939
|
+
const repaired = readCurrentStrict();
|
|
1940
|
+
notifyRecovery({
|
|
1941
|
+
kind: "repaired",
|
|
1942
|
+
path,
|
|
1943
|
+
revision: repaired.file.revision,
|
|
1944
|
+
reason: `Recovered a revisionless snapshot from segment revision ${firstSegment.revision}.`,
|
|
1945
|
+
});
|
|
1946
|
+
return repaired;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
catch (recoveryError) {
|
|
1950
|
+
if (recoveryError instanceof TelegramUpdateJournalError) {
|
|
1951
|
+
if (recoveryError.code !== "invalid")
|
|
1952
|
+
throw recoveryError;
|
|
1953
|
+
}
|
|
1954
|
+
else if (!(recoveryError instanceof SyntaxError)) {
|
|
1955
|
+
throw recoveryError;
|
|
1956
|
+
}
|
|
1957
|
+
// Only known schema/JSON corruption may fall through to reset.
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
const recoveryDirectory = join(dirname(path), "recovery", `${getNowMs()}-${process.pid}-${randomUUID()}`);
|
|
1961
|
+
mkdirSync(recoveryDirectory, { recursive: true, mode: 0o700 });
|
|
1962
|
+
const snapshotQuarantinePath = join(recoveryDirectory, basename(path));
|
|
1963
|
+
const segmentQuarantinePath = join(recoveryDirectory, basename(segmentDirectory));
|
|
1964
|
+
if (snapshotExists &&
|
|
1965
|
+
!renameTelegramPathWithRetry(path, snapshotQuarantinePath)) {
|
|
1966
|
+
throw createJournalError("io", path, "could not quarantine an unrecoverable journal snapshot", error);
|
|
1967
|
+
}
|
|
1968
|
+
if (!renameTelegramPathWithRetry(segmentDirectory, segmentQuarantinePath)) {
|
|
1969
|
+
if (snapshotExists) {
|
|
1970
|
+
renameTelegramPathWithRetry(snapshotQuarantinePath, path);
|
|
1971
|
+
}
|
|
1972
|
+
throw createJournalError("io", segmentDirectory, "could not quarantine an unrecoverable journal segment history", error);
|
|
1973
|
+
}
|
|
1974
|
+
const reset = emptyFile();
|
|
1975
|
+
const serialized = serializeJournalFile(reset);
|
|
1976
|
+
const serializedBytes = assertCapacity(reset, serialized);
|
|
1977
|
+
try {
|
|
1978
|
+
writeJournalFile(path, serialized, onPublicationBoundary);
|
|
1979
|
+
}
|
|
1980
|
+
catch (publicationError) {
|
|
1981
|
+
const segmentsRestored = renameTelegramPathWithRetry(segmentQuarantinePath, segmentDirectory);
|
|
1982
|
+
const snapshotRestored = !snapshotExists ||
|
|
1983
|
+
renameTelegramPathWithRetry(snapshotQuarantinePath, path);
|
|
1984
|
+
if (!segmentsRestored || !snapshotRestored) {
|
|
1985
|
+
throw createJournalError("io", path, "reset publication failed after journal evidence was quarantined", publicationError);
|
|
1986
|
+
}
|
|
1987
|
+
throw publicationError;
|
|
1988
|
+
}
|
|
1989
|
+
notifyRecovery({
|
|
1990
|
+
kind: "reset",
|
|
1991
|
+
path,
|
|
1992
|
+
quarantinePath: recoveryDirectory,
|
|
1993
|
+
reason: error.message,
|
|
1994
|
+
});
|
|
1995
|
+
return { file: reset, exists: true, serializedBytes };
|
|
1996
|
+
}
|
|
1997
|
+
};
|
|
1998
|
+
const runJournalTransaction = (operation) => {
|
|
1999
|
+
try {
|
|
2000
|
+
// Acquire before the transaction helper can create parents or staging names.
|
|
2001
|
+
// The config continuation excludes participating writers through consumption.
|
|
2002
|
+
const source = sourceAccess ? acquireTelegramUpdateJournalFamily({
|
|
2003
|
+
...sourceAccess, path, profile, botIdentity: expectedIdentity,
|
|
2004
|
+
}, version) : undefined;
|
|
2005
|
+
const readSource = () => {
|
|
2006
|
+
if (!source)
|
|
2007
|
+
return readCurrent();
|
|
2008
|
+
const file = source.evidence.kind === "present" ? source.evidence.file : emptyFile();
|
|
2009
|
+
return { file, exists: source.evidence.kind === "present",
|
|
2010
|
+
serializedBytes: assertCapacity(file), source };
|
|
2011
|
+
};
|
|
2012
|
+
return withTelegramFileTransaction(`${path}.transaction`, () => operation(readSource));
|
|
2013
|
+
}
|
|
2014
|
+
catch (error) {
|
|
2015
|
+
if (error instanceof TelegramUpdateJournalError)
|
|
2016
|
+
throw error;
|
|
2017
|
+
throw createJournalError("io", path, "mutation failed", error);
|
|
2018
|
+
}
|
|
2019
|
+
};
|
|
2020
|
+
const runMutationCore = (operation) => withSourceSerialization
|
|
2021
|
+
? withSourceSerialization(() => runJournalTransaction(operation))
|
|
2022
|
+
: runJournalTransaction(operation);
|
|
2023
|
+
const runMutation = (operation) => options.withWriterAdmission
|
|
2024
|
+
? options.withWriterAdmission(() => runMutationCore(operation))
|
|
2025
|
+
: runMutationCore(operation);
|
|
2026
|
+
const assertSourceResources = (files, bytes, work, collections) => {
|
|
2027
|
+
if (!sourceAccess)
|
|
2028
|
+
return;
|
|
2029
|
+
const limits = sourceAccess.limits;
|
|
2030
|
+
if (files > limits.maxFiles || bytes > limits.maxBytes || work > limits.maxWork ||
|
|
2031
|
+
collections.some((length) => length > limits.maxEntries)) {
|
|
2032
|
+
throw createJournalError("capacity", path, "publication would exceed source inspection resource limits");
|
|
2033
|
+
}
|
|
2034
|
+
};
|
|
2035
|
+
const inputHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH);
|
|
2036
|
+
const inputBindingHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH);
|
|
2037
|
+
const inputHandoffHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH);
|
|
2038
|
+
const getFileWork = (file) => file.entries.length + (file.operatorDispositions?.length ?? 0);
|
|
2039
|
+
const advanceInputHeadroomState = (state, entries, projectionSlack) => {
|
|
2040
|
+
const revision = (state.file.revision ?? 0) + 1;
|
|
2041
|
+
if (!isSafePositiveInteger(revision))
|
|
2042
|
+
throw createJournalError("capacity", path, "exhausted input custody revisions");
|
|
2043
|
+
return { file: { ...state.file, revision, entries }, projectionSlack };
|
|
2044
|
+
};
|
|
2045
|
+
const replaceInputHeadroomEntry = (file, updateId, replacement) => file.entries.flatMap(entry => entry.updateId === updateId ? (replacement ? [replacement] : []) : [entry]);
|
|
2046
|
+
const createInputHeadroomClaim = (entry, phase) => ({
|
|
2047
|
+
phase,
|
|
2048
|
+
owner: { instanceId: inputHeadroomText, processId: Number.MAX_SAFE_INTEGER,
|
|
2049
|
+
processBirthId: inputHeadroomText, sessionGeneration: Number.MAX_SAFE_INTEGER,
|
|
2050
|
+
acquisitionId: inputHeadroomText, acquiredAtMs: Number.MAX_SAFE_INTEGER,
|
|
2051
|
+
handoffId: inputHandoffHeadroomText },
|
|
2052
|
+
recipientBindingKey: inputBindingHeadroomText,
|
|
2053
|
+
executionUpdate: structuredClone(entry.update),
|
|
2054
|
+
});
|
|
2055
|
+
const largestInputHeadroomEntry = (entries, project) => entries.reduce((selected, entry) => {
|
|
2056
|
+
const bytes = Buffer.byteLength(JSON.stringify(project(entry), null, 2));
|
|
2057
|
+
return !selected || bytes > selected.bytes ? { entry, bytes } : selected;
|
|
2058
|
+
}, undefined)?.entry;
|
|
2059
|
+
const smallestInputHeadroomEntry = (entries) => entries.reduce((selected, entry) => {
|
|
2060
|
+
const bytes = Buffer.byteLength(JSON.stringify(entry, null, 2));
|
|
2061
|
+
return !selected || bytes < selected.bytes ? { entry, bytes } : selected;
|
|
2062
|
+
}, undefined)?.entry;
|
|
2063
|
+
const createInputHeadroomTransitionPlan = (file, entry) => {
|
|
2064
|
+
const initial = { file, projectionSlack: 0 };
|
|
2065
|
+
if (entry.inputClaim?.phase === "running")
|
|
2066
|
+
return [advanceInputHeadroomState(initial, replaceInputHeadroomEntry(file, entry.updateId), TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)];
|
|
2067
|
+
if (entry.state === "pending" && entry.inputClaim?.phase === "ready" && entry.inputClaim.handoff) {
|
|
2068
|
+
const acceptedClaim = { ...entry.inputClaim,
|
|
2069
|
+
owner: { ...entry.inputClaim.handoff.recipientOwner, acquisitionId: inputHeadroomText,
|
|
2070
|
+
acquiredAtMs: Number.MAX_SAFE_INTEGER, handoffId: inputHandoffHeadroomText } };
|
|
2071
|
+
delete acceptedClaim.handoff;
|
|
2072
|
+
const acceptedEntry = { ...entry, inputClaim: acceptedClaim };
|
|
2073
|
+
const acceptedState = advanceInputHeadroomState(initial, replaceInputHeadroomEntry(file, entry.updateId, acceptedEntry), TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES);
|
|
2074
|
+
return [acceptedState, ...createInputHeadroomTransitionPlan(acceptedState.file, acceptedEntry)];
|
|
2075
|
+
}
|
|
2076
|
+
if (entry.state === "pending" && entry.inputClaim?.phase === "ready") {
|
|
2077
|
+
const runningEntry = { ...entry, inputClaim: { ...entry.inputClaim, phase: "running" } };
|
|
2078
|
+
const runningState = advanceInputHeadroomState(initial, replaceInputHeadroomEntry(file, entry.updateId, runningEntry), TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES);
|
|
2079
|
+
return [runningState, advanceInputHeadroomState(runningState, replaceInputHeadroomEntry(runningState.file, entry.updateId), TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)];
|
|
2080
|
+
}
|
|
2081
|
+
if (entry.state === "pending" && entry.preApprovalExcluded === false && !entry.inputClaim) {
|
|
2082
|
+
const readyEntry = { ...entry, inputClaim: createInputHeadroomClaim(entry, "ready") };
|
|
2083
|
+
const readyState = advanceInputHeadroomState(initial, replaceInputHeadroomEntry(file, entry.updateId, readyEntry), TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES);
|
|
2084
|
+
const runningEntry = { ...readyEntry, inputClaim: { ...readyEntry.inputClaim, phase: "running" } };
|
|
2085
|
+
const runningState = advanceInputHeadroomState(readyState, replaceInputHeadroomEntry(readyState.file, entry.updateId, runningEntry), TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES);
|
|
2086
|
+
return [readyState, runningState, advanceInputHeadroomState(runningState, replaceInputHeadroomEntry(runningState.file, entry.updateId), TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)];
|
|
2087
|
+
}
|
|
2088
|
+
return entry.preApprovalExcluded === true ? [advanceInputHeadroomState(initial, replaceInputHeadroomEntry(file, entry.updateId), TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)] : [];
|
|
2089
|
+
};
|
|
2090
|
+
const createInputHeadroomCancelPlan = (file, entry) => {
|
|
2091
|
+
if (!entry.inputClaim?.handoff)
|
|
2092
|
+
return [];
|
|
2093
|
+
const claim = structuredClone(entry.inputClaim);
|
|
2094
|
+
delete claim.handoff;
|
|
2095
|
+
const cancelledEntry = { ...entry, inputClaim: claim };
|
|
2096
|
+
const initial = { file, projectionSlack: 0 };
|
|
2097
|
+
const cancelledState = advanceInputHeadroomState(initial, replaceInputHeadroomEntry(file, entry.updateId, cancelledEntry), TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES);
|
|
2098
|
+
return [cancelledState, ...createInputHeadroomTransitionPlan(cancelledState.file, cancelledEntry)];
|
|
2099
|
+
};
|
|
2100
|
+
const createInputHeadroomReleasePlan = (file, entry, allowOffered = false) => {
|
|
2101
|
+
if (entry.inputClaim?.handoff && !allowOffered)
|
|
2102
|
+
return [];
|
|
2103
|
+
const normal = createInputHeadroomTransitionPlan(file, entry);
|
|
2104
|
+
const readyState = entry.inputClaim?.phase === "ready" ? { file, projectionSlack: 0 } : normal[0];
|
|
2105
|
+
if (!readyState)
|
|
2106
|
+
return [];
|
|
2107
|
+
const readyEntry = readyState.file.entries.find(candidate => candidate.updateId === entry.updateId);
|
|
2108
|
+
if (readyEntry?.inputClaim?.phase !== "ready")
|
|
2109
|
+
return [];
|
|
2110
|
+
const releasedEntry = cloneEntry(readyEntry);
|
|
2111
|
+
delete releasedEntry.inputClaim;
|
|
2112
|
+
const releasedState = advanceInputHeadroomState(readyState, replaceInputHeadroomEntry(readyState.file, entry.updateId, releasedEntry), TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES);
|
|
2113
|
+
const reacquisition = createInputHeadroomTransitionPlan(releasedState.file, releasedEntry);
|
|
2114
|
+
return [...(readyState.file === file ? [] : [readyState]), releasedState, ...reacquisition];
|
|
2115
|
+
};
|
|
2116
|
+
const createInputHeadroomQueueCompletionPlan = (file, entries) => {
|
|
2117
|
+
if (entries.length === 0 || entries.some(entry => entry.state !== "queued" || entry.queueHandoff))
|
|
2118
|
+
return [];
|
|
2119
|
+
const requestedIds = new Set(entries.map(entry => entry.updateId));
|
|
2120
|
+
const initial = { file, projectionSlack: 0 };
|
|
2121
|
+
return [advanceInputHeadroomState(initial, file.entries.filter(entry => !requestedIds.has(entry.updateId)), TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)];
|
|
2122
|
+
};
|
|
2123
|
+
const getInputHeadroomTransitionPlans = (file, updateId) => {
|
|
2124
|
+
if (updateId !== "all") {
|
|
2125
|
+
const requestedIds = new Set(typeof updateId === "number" ? [updateId] : [...updateId]);
|
|
2126
|
+
const requestedEntries = file.entries.filter(entry => requestedIds.has(entry.updateId));
|
|
2127
|
+
const queuedReceiptId = requestedEntries[0]?.queueReceiptId;
|
|
2128
|
+
if (requestedEntries.length === requestedIds.size && queuedReceiptId &&
|
|
2129
|
+
requestedEntries.every(entry => entry.state === "queued" &&
|
|
2130
|
+
entry.queueReceiptId === queuedReceiptId) &&
|
|
2131
|
+
file.entries.filter(entry => entry.queueReceiptId === queuedReceiptId).length === requestedIds.size) {
|
|
2132
|
+
const completion = createInputHeadroomQueueCompletionPlan(file, requestedEntries);
|
|
2133
|
+
return completion.length > 0 ? [completion] : [];
|
|
2134
|
+
}
|
|
2135
|
+
if (typeof updateId !== "number")
|
|
2136
|
+
return [];
|
|
2137
|
+
const entry = requestedEntries[0];
|
|
2138
|
+
const plan = entry ? createInputHeadroomTransitionPlan(file, entry) : [];
|
|
2139
|
+
const cancel = entry ? createInputHeadroomCancelPlan(file, entry) : [];
|
|
2140
|
+
const recover = entry?.inputClaim?.handoff
|
|
2141
|
+
? createInputHeadroomReleasePlan(file, entry, true) : [];
|
|
2142
|
+
return [plan, cancel, recover].filter(candidate => candidate.length > 0);
|
|
2143
|
+
}
|
|
2144
|
+
const running = smallestInputHeadroomEntry(file.entries.filter(entry => entry.inputClaim?.phase === "running"));
|
|
2145
|
+
const offered = largestInputHeadroomEntry(file.entries.filter(entry => entry.state === "pending" && entry.inputClaim?.phase === "ready" && entry.inputClaim.handoff), entry => ({ ...entry, inputClaim: { ...entry.inputClaim, owner: {
|
|
2146
|
+
...entry.inputClaim.handoff.recipientOwner, acquisitionId: inputHeadroomText,
|
|
2147
|
+
acquiredAtMs: Number.MAX_SAFE_INTEGER, handoffId: inputHandoffHeadroomText
|
|
2148
|
+
}, handoff: undefined } }));
|
|
2149
|
+
const ready = largestInputHeadroomEntry(file.entries.filter(entry => entry.state === "pending" && entry.inputClaim?.phase === "ready" && !entry.inputClaim.handoff), entry => ({ ...entry, inputClaim: { ...entry.inputClaim, phase: "running" } }));
|
|
2150
|
+
const unclaimed = largestInputHeadroomEntry(file.entries.filter(entry => entry.state === "pending" && entry.preApprovalExcluded === false && !entry.inputClaim), entry => ({ ...entry, inputClaim: createInputHeadroomClaim(entry, "running") }));
|
|
2151
|
+
const excluded = smallestInputHeadroomEntry(file.entries.filter(entry => entry.preApprovalExcluded === true));
|
|
2152
|
+
const representatives = [running, offered, ready, unclaimed, excluded].filter((entry) => entry !== undefined);
|
|
2153
|
+
const plans = representatives.map(entry => createInputHeadroomTransitionPlan(file, entry));
|
|
2154
|
+
if (offered) {
|
|
2155
|
+
plans.push(createInputHeadroomCancelPlan(file, offered));
|
|
2156
|
+
plans.push(createInputHeadroomReleasePlan(file, offered, true));
|
|
2157
|
+
}
|
|
2158
|
+
for (const entry of [ready, unclaimed]) {
|
|
2159
|
+
if (entry)
|
|
2160
|
+
plans.push(createInputHeadroomReleasePlan(file, entry));
|
|
2161
|
+
}
|
|
2162
|
+
const queuedReceipts = new Map();
|
|
2163
|
+
for (const entry of file.entries) {
|
|
2164
|
+
if (entry.state !== "queued" || !entry.queueReceiptId || entry.queueHandoff)
|
|
2165
|
+
continue;
|
|
2166
|
+
const grouped = queuedReceipts.get(entry.queueReceiptId) ?? [];
|
|
2167
|
+
grouped.push(entry);
|
|
2168
|
+
queuedReceipts.set(entry.queueReceiptId, grouped);
|
|
2169
|
+
}
|
|
2170
|
+
for (const entries of queuedReceipts.values()) {
|
|
2171
|
+
const completion = createInputHeadroomQueueCompletionPlan(file, entries);
|
|
2172
|
+
if (completion.length > 0)
|
|
2173
|
+
plans.push(completion);
|
|
2174
|
+
}
|
|
2175
|
+
return plans;
|
|
2176
|
+
};
|
|
2177
|
+
const createInputHeadroomSegment = (previous, next) => {
|
|
2178
|
+
const previousEntries = new Map(previous.entries.map(entry => [entry.updateId, entry]));
|
|
2179
|
+
const nextEntries = new Map(next.entries.map(entry => [entry.updateId, entry]));
|
|
2180
|
+
return { version: TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
|
|
2181
|
+
revision: next.revision, previousRevision: next.revision - 1,
|
|
2182
|
+
profile: next.profile, botIdentity: next.botIdentity,
|
|
2183
|
+
upsertedEntries: next.entries.filter(entry => !previousEntries.has(entry.updateId) || !isDeepStrictEqual(previousEntries.get(entry.updateId), entry)),
|
|
2184
|
+
removedUpdateIds: previous.entries.filter(entry => !nextEntries.has(entry.updateId)).map(entry => entry.updateId),
|
|
2185
|
+
...(next.acceptedThroughUpdateId !== undefined ? { acceptedThroughUpdateId: next.acceptedThroughUpdateId } : {}) };
|
|
2186
|
+
};
|
|
2187
|
+
const assertInputHeadroomCapacity = (file, extraBytes) => {
|
|
2188
|
+
const bytes = assertCapacity(file);
|
|
2189
|
+
if (bytes > maxBytes - extraBytes) {
|
|
2190
|
+
throw createJournalError("capacity", path, "does not retain logical input transition headroom");
|
|
2191
|
+
}
|
|
2192
|
+
return bytes + extraBytes;
|
|
2193
|
+
};
|
|
2194
|
+
const assertInputProgressHeadroom = (current, publishedFile, updateId, publishedSegment) => {
|
|
2195
|
+
const plans = getInputHeadroomTransitionPlans(publishedFile, updateId);
|
|
2196
|
+
if (plans.length === 0)
|
|
2197
|
+
return;
|
|
2198
|
+
const sourceSegments = current.source?.segments ?? [];
|
|
2199
|
+
let baseRetainedCount = sourceSegments.length;
|
|
2200
|
+
let baseRetainedBytes = sourceSegments.reduce((sum, segment) => sum + segment.bytes, 0);
|
|
2201
|
+
let baseRetainedWork = sourceSegments.reduce((sum, segment) => sum + segment.work, 0);
|
|
2202
|
+
const publishedBytes = assertInputHeadroomCapacity(publishedFile, 0);
|
|
2203
|
+
let baseAccounting = current.source?.evidence.kind === "present"
|
|
2204
|
+
? { ...current.source.evidence.accounting }
|
|
2205
|
+
: { files: 1, bytes: publishedBytes, work: getFileWork(publishedFile) };
|
|
2206
|
+
if (publishedSegment) {
|
|
2207
|
+
const segmentWork = publishedSegment.upsertedEntries.length + publishedSegment.removedUpdateIds.length +
|
|
2208
|
+
(publishedSegment.operatorDispositions?.length ?? 0);
|
|
2209
|
+
const segmentBytes = Buffer.byteLength(`${JSON.stringify(publishedSegment, null, 2)}\n`);
|
|
2210
|
+
baseRetainedCount += 1;
|
|
2211
|
+
baseRetainedBytes += segmentBytes;
|
|
2212
|
+
baseRetainedWork += segmentWork;
|
|
2213
|
+
const segmentFirst = { files: baseAccounting.files + 1, bytes: baseAccounting.bytes + segmentBytes,
|
|
2214
|
+
work: baseAccounting.work + segmentWork + getFileWork(publishedFile) };
|
|
2215
|
+
const compactResidue = { files: 1 + baseRetainedCount,
|
|
2216
|
+
bytes: publishedBytes + baseRetainedBytes, work: getFileWork(publishedFile) + baseRetainedWork };
|
|
2217
|
+
baseAccounting = { files: Math.max(segmentFirst.files, compactResidue.files),
|
|
2218
|
+
bytes: Math.max(segmentFirst.bytes, compactResidue.bytes),
|
|
2219
|
+
work: Math.max(segmentFirst.work, compactResidue.work) };
|
|
2220
|
+
}
|
|
2221
|
+
for (const transitions of plans) {
|
|
2222
|
+
let retainedCount = baseRetainedCount;
|
|
2223
|
+
let retainedBytes = baseRetainedBytes;
|
|
2224
|
+
let retainedWork = baseRetainedWork;
|
|
2225
|
+
let accounting = { ...baseAccounting };
|
|
2226
|
+
let state = { file: publishedFile, projectionSlack: 0 };
|
|
2227
|
+
for (const next of transitions) {
|
|
2228
|
+
const segment = createInputHeadroomSegment(state.file, next.file);
|
|
2229
|
+
const segmentCollections = [segment.upsertedEntries.length, segment.removedUpdateIds.length,
|
|
2230
|
+
segment.operatorDispositions?.length ?? 0, next.file.entries.length,
|
|
2231
|
+
next.file.operatorDispositions?.length ?? 0];
|
|
2232
|
+
const segmentWork = segmentCollections[0] + segmentCollections[1] + segmentCollections[2];
|
|
2233
|
+
const segmentBytes = Buffer.byteLength(`${JSON.stringify(segment, null, 2)}\n`) + next.projectionSlack;
|
|
2234
|
+
const nextBytes = assertInputHeadroomCapacity(next.file, next.projectionSlack);
|
|
2235
|
+
const segmentFirst = { files: accounting.files + 1, bytes: accounting.bytes + segmentBytes,
|
|
2236
|
+
work: accounting.work + segmentWork + getFileWork(next.file) };
|
|
2237
|
+
assertSourceResources(segmentFirst.files, segmentFirst.bytes, segmentFirst.work, segmentCollections);
|
|
2238
|
+
retainedCount += 1;
|
|
2239
|
+
retainedBytes += segmentBytes;
|
|
2240
|
+
retainedWork += segmentWork;
|
|
2241
|
+
const compactResidue = { files: 1 + retainedCount, bytes: nextBytes + retainedBytes,
|
|
2242
|
+
work: getFileWork(next.file) + retainedWork };
|
|
2243
|
+
assertSourceResources(compactResidue.files, compactResidue.bytes, compactResidue.work, segmentCollections);
|
|
2244
|
+
accounting = { files: Math.max(segmentFirst.files, compactResidue.files),
|
|
2245
|
+
bytes: Math.max(segmentFirst.bytes, compactResidue.bytes),
|
|
2246
|
+
work: Math.max(segmentFirst.work, compactResidue.work) };
|
|
2247
|
+
state = next;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
};
|
|
2251
|
+
const publishSourceSegment = (current, file, segment, publicationBoundary, forceCompact) => {
|
|
2252
|
+
const source = current.source;
|
|
2253
|
+
if (source.evidence.kind !== "present")
|
|
2254
|
+
throw createJournalError("invalid", path, "requires existing source evidence");
|
|
2255
|
+
const revisedFile = { ...file, revision: segment.revision };
|
|
2256
|
+
const serialized = serializeJournalFile(revisedFile);
|
|
2257
|
+
const serializedBytes = assertCapacity(revisedFile, serialized);
|
|
2258
|
+
const segmentText = `${JSON.stringify(segment, null, 2)}\n`;
|
|
2259
|
+
const segmentBytes = Buffer.byteLength(segmentText);
|
|
2260
|
+
const collections = [segment.upsertedEntries.length, segment.removedUpdateIds.length,
|
|
2261
|
+
segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0];
|
|
2262
|
+
const segmentWork = collections[0] + collections[1] + collections[2];
|
|
2263
|
+
const fileWork = collections[3] + collections[4];
|
|
2264
|
+
const accounting = source.evidence.accounting;
|
|
2265
|
+
// Both the segment-first state and snapshot-first cleanup residue must fit.
|
|
2266
|
+
assertSourceResources(accounting.files + 1, accounting.bytes + segmentBytes, accounting.work + segmentWork + fileWork, collections);
|
|
2267
|
+
const unapplied = source.segments.filter((item) => Number(item.name.slice(0, 16)) > source.snapshotRevision);
|
|
2268
|
+
const compact = forceCompact ||
|
|
2269
|
+
unapplied.length + 1 >= TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_COUNT ||
|
|
2270
|
+
unapplied.reduce((sum, item) => sum + item.bytes, segmentBytes) >= TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_BYTES;
|
|
2271
|
+
if (compact) {
|
|
2272
|
+
assertSourceResources(accounting.files + 1, serializedBytes + source.segments.reduce((sum, item) => sum + item.bytes, segmentBytes), fileWork + source.segments.reduce((sum, item) => sum + item.work, segmentWork), collections);
|
|
2273
|
+
}
|
|
2274
|
+
const segmentPath = getTelegramUpdateJournalSegmentPath(path, segment.revision);
|
|
2275
|
+
// A crash may retain staging, so keep it outside the strictly enumerated segment directory.
|
|
2276
|
+
writeJournalFile(segmentPath, segmentText, publicationBoundary, path);
|
|
2277
|
+
if (compact) {
|
|
2278
|
+
writeJournalFile(path, serialized, publicationBoundary);
|
|
2279
|
+
// Snapshot scope must not change. Preserve a validated redundant ID witness instead.
|
|
2280
|
+
const witness = file.botIdentity.botId === undefined
|
|
2281
|
+
? source.segments.find((item) => item.botId !== undefined)?.name : undefined;
|
|
2282
|
+
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
2283
|
+
const names = [...source.segments.map((item) => item.name), basename(segmentPath)];
|
|
2284
|
+
for (const name of names) {
|
|
2285
|
+
if (name === witness)
|
|
2286
|
+
continue;
|
|
2287
|
+
try {
|
|
2288
|
+
unlinkSync(join(segmentDirectory, name));
|
|
2289
|
+
}
|
|
2290
|
+
catch {
|
|
2291
|
+
// Every subset of redundant cleanup residue was admitted above.
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
try {
|
|
2295
|
+
rmdirSync(segmentDirectory);
|
|
2296
|
+
}
|
|
2297
|
+
catch { /* Retained witness or cleanup residue. */ }
|
|
2298
|
+
}
|
|
2299
|
+
return { file: revisedFile, serializedBytes };
|
|
2300
|
+
};
|
|
2301
|
+
const publishMutation = (current, entries, contentChanged, operatorDispositions = current.file.operatorDispositions, acceptedThroughUpdateId = current.file.acceptedThroughUpdateId, publicationBoundary = onPublicationBoundary, inputHeadroom = version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION ? "all" : false) => {
|
|
2302
|
+
const botIdentity = current.source ? current.file.botIdentity : mergeBotIdentity(current.file.botIdentity, expectedIdentity);
|
|
2303
|
+
if (!contentChanged &&
|
|
2304
|
+
isDeepStrictEqual(current.file.botIdentity, botIdentity) &&
|
|
2305
|
+
isDeepStrictEqual(current.file.operatorDispositions ?? [], operatorDispositions ?? []) &&
|
|
2306
|
+
current.file.acceptedThroughUpdateId === acceptedThroughUpdateId) {
|
|
2307
|
+
return { file: current.file, serializedBytes: current.serializedBytes };
|
|
2308
|
+
}
|
|
2309
|
+
const file = {
|
|
2310
|
+
version,
|
|
2311
|
+
profile,
|
|
2312
|
+
botIdentity,
|
|
2313
|
+
entries,
|
|
2314
|
+
...(acceptedThroughUpdateId !== undefined
|
|
2315
|
+
? { acceptedThroughUpdateId }
|
|
2316
|
+
: {}),
|
|
2317
|
+
...(operatorDispositions?.length
|
|
2318
|
+
? { operatorDispositions }
|
|
2319
|
+
: {}),
|
|
2320
|
+
};
|
|
2321
|
+
if (withPairingAdmission || current.source)
|
|
2322
|
+
parseJournalFile(file, path, version);
|
|
2323
|
+
const serialized = serializeJournalFile(file);
|
|
2324
|
+
const serializedBytes = assertCapacity(file, serialized);
|
|
2325
|
+
const changed = contentChanged ||
|
|
2326
|
+
current.file.acceptedThroughUpdateId !== acceptedThroughUpdateId ||
|
|
2327
|
+
!isDeepStrictEqual(current.file.botIdentity, file.botIdentity);
|
|
2328
|
+
if (!changed) {
|
|
2329
|
+
return { file, serializedBytes: current.serializedBytes };
|
|
2330
|
+
}
|
|
2331
|
+
if (!current.exists) {
|
|
2332
|
+
if (current.source) {
|
|
2333
|
+
const collections = [file.entries.length, file.operatorDispositions?.length ?? 0];
|
|
2334
|
+
assertSourceResources(1, serializedBytes, collections[0] + collections[1], collections);
|
|
2335
|
+
}
|
|
2336
|
+
if (inputHeadroom !== false)
|
|
2337
|
+
assertInputProgressHeadroom(current, file, inputHeadroom);
|
|
2338
|
+
writeJournalFile(path, serialized, publicationBoundary);
|
|
2339
|
+
return { file, serializedBytes };
|
|
2340
|
+
}
|
|
2341
|
+
const previousEntries = new Map(current.file.entries.map((entry) => [entry.updateId, entry]));
|
|
2342
|
+
if (withPairingAdmission && entries.some((entry) => {
|
|
2343
|
+
const previous = previousEntries.get(entry.updateId);
|
|
2344
|
+
return previous && previous.preApprovalExcluded !== entry.preApprovalExcluded;
|
|
2345
|
+
})) {
|
|
2346
|
+
throw createJournalError("pairing-evidence", path, "changes immutable exclusion evidence");
|
|
2347
|
+
}
|
|
2348
|
+
const nextEntries = new Map(entries.map((entry) => [entry.updateId, entry]));
|
|
2349
|
+
const upsertedEntries = entries.filter((entry) => !previousEntries.has(entry.updateId) ||
|
|
2350
|
+
!isDeepStrictEqual(previousEntries.get(entry.updateId), entry));
|
|
2351
|
+
const removedUpdateIds = current.file.entries
|
|
2352
|
+
.filter((entry) => !nextEntries.has(entry.updateId))
|
|
2353
|
+
.map((entry) => entry.updateId);
|
|
2354
|
+
const revision = (current.file.revision ?? 0) + 1;
|
|
2355
|
+
const segment = {
|
|
2356
|
+
version,
|
|
2357
|
+
revision,
|
|
2358
|
+
previousRevision: revision - 1,
|
|
2359
|
+
profile,
|
|
2360
|
+
botIdentity: file.botIdentity,
|
|
2361
|
+
upsertedEntries,
|
|
2362
|
+
removedUpdateIds,
|
|
2363
|
+
...(acceptedThroughUpdateId !== undefined
|
|
2364
|
+
? { acceptedThroughUpdateId }
|
|
2365
|
+
: {}),
|
|
2366
|
+
...(!isDeepStrictEqual(current.file.operatorDispositions ?? [], operatorDispositions ?? [])
|
|
2367
|
+
? { operatorDispositions: operatorDispositions ?? [] }
|
|
2368
|
+
: {}),
|
|
2369
|
+
};
|
|
2370
|
+
if (current.source) {
|
|
2371
|
+
if (!isSafePositiveInteger(revision))
|
|
2372
|
+
throw createJournalError("capacity", path, "exhausted source revisions");
|
|
2373
|
+
const revisedFile = { ...file, revision };
|
|
2374
|
+
if (inputHeadroom !== false)
|
|
2375
|
+
assertInputProgressHeadroom(current, revisedFile, inputHeadroom, segment);
|
|
2376
|
+
return publishSourceSegment(current, file, segment, publicationBoundary, version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION);
|
|
2377
|
+
}
|
|
2378
|
+
publishTelegramUpdateJournalSegmentUnlocked(path, segment, publicationBoundary);
|
|
2379
|
+
const revisedFile = { ...file, revision };
|
|
2380
|
+
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
2381
|
+
const segmentNames = readdirSync(segmentDirectory).filter((name) => /^\d{16}\.json$/u.test(name));
|
|
2382
|
+
let snapshotRevision = 0;
|
|
2383
|
+
try {
|
|
2384
|
+
snapshotRevision = parseJournalFile(JSON.parse(readFileSync(path, "utf8")), path, version).revision ?? 0;
|
|
2385
|
+
}
|
|
2386
|
+
catch (error) {
|
|
2387
|
+
if (error?.code !== "ENOENT")
|
|
2388
|
+
throw error;
|
|
2389
|
+
}
|
|
2390
|
+
const unappliedSegmentNames = segmentNames.filter((name) => Number(name.slice(0, 16)) > snapshotRevision);
|
|
2391
|
+
const segmentBytes = unappliedSegmentNames.reduce((total, name) => total + statSync(join(segmentDirectory, name)).size, 0);
|
|
2392
|
+
if (unappliedSegmentNames.length >=
|
|
2393
|
+
TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_COUNT ||
|
|
2394
|
+
segmentBytes >= TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_BYTES) {
|
|
2395
|
+
const compacted = serializeJournalFile(revisedFile);
|
|
2396
|
+
const compactedBytes = assertCapacity(revisedFile, compacted);
|
|
2397
|
+
writeJournalFile(path, compacted, publicationBoundary);
|
|
2398
|
+
for (const name of segmentNames) {
|
|
2399
|
+
if (Number(name.slice(0, 16)) <= revision) {
|
|
2400
|
+
try {
|
|
2401
|
+
unlinkSync(join(segmentDirectory, name));
|
|
2402
|
+
}
|
|
2403
|
+
catch {
|
|
2404
|
+
// The published snapshot already owns this revision; redundant
|
|
2405
|
+
// segments are safe and will be ignored on reconstruction.
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
try {
|
|
2410
|
+
rmdirSync(segmentDirectory);
|
|
2411
|
+
}
|
|
2412
|
+
catch {
|
|
2413
|
+
// Interrupted cleanup may leave an empty directory or old segments.
|
|
2414
|
+
}
|
|
2415
|
+
return { file: revisedFile, serializedBytes: compactedBytes };
|
|
2416
|
+
}
|
|
2417
|
+
return { file: revisedFile, serializedBytes };
|
|
2418
|
+
};
|
|
2419
|
+
const journal = {
|
|
2420
|
+
read() {
|
|
2421
|
+
return runMutation((readCurrent) => {
|
|
2422
|
+
const current = readCurrent();
|
|
2423
|
+
return {
|
|
2424
|
+
...cloneFile(current.file),
|
|
2425
|
+
exists: current.exists,
|
|
2426
|
+
serializedBytes: current.serializedBytes,
|
|
2427
|
+
};
|
|
2428
|
+
});
|
|
2429
|
+
},
|
|
2430
|
+
appendBatch(updates, requestedAcceptedThroughUpdateId) {
|
|
2431
|
+
const canonicalUpdates = updates.map((update) => normalizeIncomingJournaledUpdate(update, path));
|
|
2432
|
+
const normalizedUpdates = [];
|
|
2433
|
+
for (const normalized of canonicalUpdates) {
|
|
2434
|
+
const previous = normalizedUpdates.at(-1);
|
|
2435
|
+
if (previous && normalized.update_id < previous.update_id) {
|
|
2436
|
+
throw createJournalError("invalid", path, "received an unordered update batch");
|
|
2437
|
+
}
|
|
2438
|
+
if (previous?.update_id === normalized.update_id) {
|
|
2439
|
+
if (!isDeepStrictEqual(previous, normalized)) {
|
|
2440
|
+
throw createJournalError("conflict", path, `received conflicting update ${normalized.update_id}`);
|
|
2441
|
+
}
|
|
2442
|
+
continue;
|
|
2443
|
+
}
|
|
2444
|
+
normalizedUpdates.push(normalized);
|
|
2445
|
+
}
|
|
2446
|
+
const appendWithEvidence = (preApprovalExcluded) => runJournalTransaction((readCurrent) => {
|
|
2447
|
+
if (withPairingAdmission && (typeof preApprovalExcluded !== "boolean" || requestedAcceptedThroughUpdateId === undefined)) {
|
|
2448
|
+
throw createJournalError("pairing-evidence", path, "requires exclusion evidence and an admission cursor");
|
|
2449
|
+
}
|
|
2450
|
+
if (requestedAcceptedThroughUpdateId !== undefined &&
|
|
2451
|
+
!isSafeNonNegativeInteger(requestedAcceptedThroughUpdateId)) {
|
|
2452
|
+
throw createJournalError("invalid", path, "received an invalid admission cursor");
|
|
2453
|
+
}
|
|
2454
|
+
const current = readCurrent();
|
|
2455
|
+
const entriesById = new Map(current.file.entries.map((entry) => [entry.updateId, entry]));
|
|
2456
|
+
const previousAcceptedThroughUpdateId = current.file.acceptedThroughUpdateId;
|
|
2457
|
+
const discardedUpdateIds = new Set((current.file.operatorDispositions ?? [])
|
|
2458
|
+
.filter((disposition) => disposition.action === "discard")
|
|
2459
|
+
.map((disposition) => disposition.updateId));
|
|
2460
|
+
let admittedAtMs;
|
|
2461
|
+
const addedUpdateIds = [];
|
|
2462
|
+
const duplicateUpdateIds = [];
|
|
2463
|
+
for (const update of normalizedUpdates) {
|
|
2464
|
+
if (discardedUpdateIds.has(update.update_id)) {
|
|
2465
|
+
duplicateUpdateIds.push(update.update_id);
|
|
2466
|
+
continue;
|
|
2467
|
+
}
|
|
2468
|
+
const existing = entriesById.get(update.update_id);
|
|
2469
|
+
if (existing) {
|
|
2470
|
+
if (!isDeepStrictEqual(existing.update, update)) {
|
|
2471
|
+
throw createJournalError("conflict", path, `received conflicting update ${update.update_id}`);
|
|
2472
|
+
}
|
|
2473
|
+
duplicateUpdateIds.push(update.update_id);
|
|
2474
|
+
continue;
|
|
2475
|
+
}
|
|
2476
|
+
if (withPairingAdmission && previousAcceptedThroughUpdateId !== undefined && update.update_id <= previousAcceptedThroughUpdateId) {
|
|
2477
|
+
duplicateUpdateIds.push(update.update_id);
|
|
2478
|
+
continue;
|
|
2479
|
+
}
|
|
2480
|
+
if (admittedAtMs === undefined) {
|
|
2481
|
+
admittedAtMs = getNowMs();
|
|
2482
|
+
if (!isSafeNonNegativeInteger(admittedAtMs)) {
|
|
2483
|
+
throw createJournalError("invalid", path, "received an invalid admission timestamp");
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
const entry = {
|
|
2487
|
+
updateId: update.update_id,
|
|
2488
|
+
update,
|
|
2489
|
+
...(withPairingAdmission ? { preApprovalExcluded: preApprovalExcluded } : {}),
|
|
2490
|
+
admittedAtMs,
|
|
2491
|
+
state: "pending",
|
|
2492
|
+
};
|
|
2493
|
+
entriesById.set(entry.updateId, entry);
|
|
2494
|
+
addedUpdateIds.push(entry.updateId);
|
|
2495
|
+
}
|
|
2496
|
+
const batchLastUpdateId = normalizedUpdates.at(-1)?.update_id;
|
|
2497
|
+
if (requestedAcceptedThroughUpdateId !== undefined &&
|
|
2498
|
+
batchLastUpdateId !== undefined &&
|
|
2499
|
+
requestedAcceptedThroughUpdateId < batchLastUpdateId) {
|
|
2500
|
+
throw createJournalError("invalid", path, "received an admission cursor behind its batch");
|
|
2501
|
+
}
|
|
2502
|
+
if (requestedAcceptedThroughUpdateId !== undefined &&
|
|
2503
|
+
previousAcceptedThroughUpdateId !== undefined &&
|
|
2504
|
+
requestedAcceptedThroughUpdateId < previousAcceptedThroughUpdateId) {
|
|
2505
|
+
throw createJournalError("conflict", path, "received a regressing admission cursor");
|
|
2506
|
+
}
|
|
2507
|
+
const acceptedThroughUpdateId = requestedAcceptedThroughUpdateId ?? previousAcceptedThroughUpdateId;
|
|
2508
|
+
const contentChanged = addedUpdateIds.length > 0;
|
|
2509
|
+
const published = publishMutation(current, contentChanged
|
|
2510
|
+
? Array.from(entriesById.values()).sort((left, right) => left.updateId - right.updateId)
|
|
2511
|
+
: current.file.entries, contentChanged, current.file.operatorDispositions, acceptedThroughUpdateId);
|
|
2512
|
+
return {
|
|
2513
|
+
nonExcludedUpdateIds: normalizedUpdates.flatMap((update) => {
|
|
2514
|
+
const entry = entriesById.get(update.update_id);
|
|
2515
|
+
return entry && entry.preApprovalExcluded !== true ? [entry.updateId] : [];
|
|
2516
|
+
}),
|
|
2517
|
+
addedUpdateIds,
|
|
2518
|
+
duplicateUpdateIds,
|
|
2519
|
+
entryCount: published.file.entries.length,
|
|
2520
|
+
serializedBytes: published.serializedBytes,
|
|
2521
|
+
};
|
|
2522
|
+
});
|
|
2523
|
+
const append = () => {
|
|
2524
|
+
if (withPairedAdmission) {
|
|
2525
|
+
const result = withPairedAdmission(normalizedUpdates, () => appendWithEvidence());
|
|
2526
|
+
if (!result.admitted)
|
|
2527
|
+
throw createJournalError("sender-denied", path, "refused paired-only sender admission");
|
|
2528
|
+
return result.value;
|
|
2529
|
+
}
|
|
2530
|
+
if (withPairingAdmission)
|
|
2531
|
+
return withPairingAdmission(appendWithEvidence);
|
|
2532
|
+
return withSourceSerialization
|
|
2533
|
+
? withSourceSerialization(() => appendWithEvidence())
|
|
2534
|
+
: appendWithEvidence();
|
|
2535
|
+
};
|
|
2536
|
+
const admittedAppend = () => {
|
|
2537
|
+
if (!workspaceAdmission)
|
|
2538
|
+
return append();
|
|
2539
|
+
const operationHash = createHash("sha256").update(path).update("\0");
|
|
2540
|
+
for (const update of canonicalUpdates) {
|
|
2541
|
+
operationHash.update(String(update.update_id)).update("\0");
|
|
2542
|
+
}
|
|
2543
|
+
operationHash.update(String(requestedAcceptedThroughUpdateId ?? "none"));
|
|
2544
|
+
return runWithTelegramWorkspaceAdmissions({
|
|
2545
|
+
ledger: workspaceAdmission,
|
|
2546
|
+
operationId: `journal:${operationHash.digest("hex")}`,
|
|
2547
|
+
operationKind: "journal.append",
|
|
2548
|
+
scopes: getTelegramUpdateJournalAdmissionScopes(canonicalUpdates),
|
|
2549
|
+
operation: append,
|
|
2550
|
+
});
|
|
2551
|
+
};
|
|
2552
|
+
return options.withWriterAdmission
|
|
2553
|
+
? options.withWriterAdmission(admittedAppend) : admittedAppend();
|
|
2554
|
+
},
|
|
2555
|
+
markQueued(receipt) {
|
|
2556
|
+
return runMutation((readCurrent) => {
|
|
2557
|
+
if ((receipt.queueKind !== "prompt" &&
|
|
2558
|
+
receipt.queueKind !== "control") ||
|
|
2559
|
+
!isNonEmptyString(receipt.receiptId) ||
|
|
2560
|
+
!Array.isArray(receipt.sourceUpdateIds) ||
|
|
2561
|
+
receipt.sourceUpdateIds.length === 0) {
|
|
2562
|
+
throw createJournalError("invalid", path, "received an invalid queue receipt");
|
|
2563
|
+
}
|
|
2564
|
+
const requestedOwner = validateJournalQueueOwnerIdentity(receipt.owner, path);
|
|
2565
|
+
if (queueRuntimeIdentity &&
|
|
2566
|
+
(requestedOwner.instanceId !== queueRuntimeIdentity.instanceId ||
|
|
2567
|
+
requestedOwner.processId !== queueRuntimeIdentity.processId ||
|
|
2568
|
+
requestedOwner.processBirthId !==
|
|
2569
|
+
queueRuntimeIdentity.processBirthId)) {
|
|
2570
|
+
throw createJournalError("conflict", path, "cannot acquire a queue receipt for another runtime process generation");
|
|
2571
|
+
}
|
|
2572
|
+
const requestedIds = new Set();
|
|
2573
|
+
for (const updateId of receipt.sourceUpdateIds) {
|
|
2574
|
+
if (!isSafeNonNegativeInteger(updateId) ||
|
|
2575
|
+
requestedIds.has(updateId)) {
|
|
2576
|
+
throw createJournalError("invalid", path, "received invalid or duplicate queue receipt update ids");
|
|
2577
|
+
}
|
|
2578
|
+
requestedIds.add(updateId);
|
|
2579
|
+
}
|
|
2580
|
+
const current = readCurrent();
|
|
2581
|
+
const existingReceiptEntries = [];
|
|
2582
|
+
const entriesById = new Map();
|
|
2583
|
+
for (const entry of current.file.entries) {
|
|
2584
|
+
entriesById.set(entry.updateId, entry);
|
|
2585
|
+
if (entry.queueReceiptId === receipt.receiptId) {
|
|
2586
|
+
existingReceiptEntries.push(entry);
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
const existingReceiptIds = new Set(existingReceiptEntries.map((entry) => entry.updateId));
|
|
2590
|
+
if (existingReceiptIds.size > 0 &&
|
|
2591
|
+
(existingReceiptIds.size !== requestedIds.size ||
|
|
2592
|
+
[...existingReceiptIds].some((updateId) => !requestedIds.has(updateId)))) {
|
|
2593
|
+
throw createJournalError("conflict", path, `has a conflicting queue receipt ${receipt.receiptId}`);
|
|
2594
|
+
}
|
|
2595
|
+
let queueOwner = existingReceiptEntries[0]?.queueOwner;
|
|
2596
|
+
if (existingReceiptEntries.length === 0) {
|
|
2597
|
+
const acquiredAtMs = getNowMs();
|
|
2598
|
+
if (!isSafeNonNegativeInteger(acquiredAtMs)) {
|
|
2599
|
+
throw createJournalError("invalid", path, "received an invalid queue acquisition timestamp");
|
|
2600
|
+
}
|
|
2601
|
+
queueOwner = {
|
|
2602
|
+
...requestedOwner,
|
|
2603
|
+
acquisitionId: randomUUID(),
|
|
2604
|
+
acquiredAtMs,
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
const queuedUpdateIds = [];
|
|
2608
|
+
const duplicateUpdateIds = [];
|
|
2609
|
+
for (const updateId of receipt.sourceUpdateIds) {
|
|
2610
|
+
const entry = entriesById.get(updateId);
|
|
2611
|
+
if (!entry) {
|
|
2612
|
+
throw createJournalError("conflict", path, `cannot queue missing update ${updateId}`);
|
|
2613
|
+
}
|
|
2614
|
+
if (entry.preApprovalExcluded) {
|
|
2615
|
+
throw createJournalError("pairing-evidence", path, `cannot queue excluded update ${updateId}`);
|
|
2616
|
+
}
|
|
2617
|
+
if (entry.state === "queued") {
|
|
2618
|
+
if (entry.queueKind !== receipt.queueKind ||
|
|
2619
|
+
entry.queueReceiptId !== receipt.receiptId) {
|
|
2620
|
+
throw createJournalError("conflict", path, `update ${updateId} belongs to another queue receipt`);
|
|
2621
|
+
}
|
|
2622
|
+
duplicateUpdateIds.push(updateId);
|
|
2623
|
+
continue;
|
|
2624
|
+
}
|
|
2625
|
+
if (entry.state === "failed") {
|
|
2626
|
+
throw createJournalError("conflict", path, `cannot queue failed update ${updateId}`);
|
|
2627
|
+
}
|
|
2628
|
+
entriesById.set(updateId, {
|
|
2629
|
+
updateId: entry.updateId,
|
|
2630
|
+
update: entry.update,
|
|
2631
|
+
admittedAtMs: entry.admittedAtMs,
|
|
2632
|
+
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
2633
|
+
state: "queued",
|
|
2634
|
+
queueKind: receipt.queueKind,
|
|
2635
|
+
queueReceiptId: receipt.receiptId,
|
|
2636
|
+
queueOwner: cloneJournalQueueOwner(queueOwner),
|
|
2637
|
+
});
|
|
2638
|
+
queuedUpdateIds.push(updateId);
|
|
2639
|
+
}
|
|
2640
|
+
const contentChanged = queuedUpdateIds.length > 0;
|
|
2641
|
+
const published = publishMutation(current, contentChanged
|
|
2642
|
+
? current.file.entries.map((entry) => entriesById.get(entry.updateId))
|
|
2643
|
+
: current.file.entries, contentChanged);
|
|
2644
|
+
return {
|
|
2645
|
+
queuedUpdateIds,
|
|
2646
|
+
duplicateUpdateIds,
|
|
2647
|
+
...(queueOwner
|
|
2648
|
+
? { queueOwner: cloneJournalQueueOwner(queueOwner) }
|
|
2649
|
+
: {}),
|
|
2650
|
+
entryCount: published.file.entries.length,
|
|
2651
|
+
serializedBytes: published.serializedBytes,
|
|
2652
|
+
};
|
|
2653
|
+
});
|
|
2654
|
+
},
|
|
2655
|
+
markExecutionFailure(input) {
|
|
2656
|
+
return runMutation((readCurrent) => {
|
|
2657
|
+
if (!isSafeNonNegativeInteger(input.updateId) ||
|
|
2658
|
+
!isSafeNonNegativeInteger(input.expectedAttemptCount) ||
|
|
2659
|
+
!isSafeNonNegativeInteger(input.failedAtMs) ||
|
|
2660
|
+
!isBoundedString(input.failureClass, TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH) ||
|
|
2661
|
+
!isBoundedString(input.summary, TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH) ||
|
|
2662
|
+
(input.disposition !== "retry-wait" &&
|
|
2663
|
+
input.disposition !== "failed")) {
|
|
2664
|
+
throw createJournalError("invalid", path, "received invalid execution failure metadata");
|
|
2665
|
+
}
|
|
2666
|
+
if (input.disposition === "retry-wait" &&
|
|
2667
|
+
(!isSafeNonNegativeInteger(input.nextRetryAtMs) ||
|
|
2668
|
+
input.nextRetryAtMs < input.failedAtMs ||
|
|
2669
|
+
input.terminalReason !== undefined)) {
|
|
2670
|
+
throw createJournalError("invalid", path, "received invalid retry-wait disposition");
|
|
2671
|
+
}
|
|
2672
|
+
if (input.disposition === "failed" &&
|
|
2673
|
+
(!isBoundedString(input.terminalReason, TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH) ||
|
|
2674
|
+
input.nextRetryAtMs !== undefined)) {
|
|
2675
|
+
throw createJournalError("invalid", path, "received invalid terminal failure disposition");
|
|
2676
|
+
}
|
|
2677
|
+
const current = readCurrent();
|
|
2678
|
+
const entry = current.file.entries.find((candidate) => candidate.updateId === input.updateId);
|
|
2679
|
+
if (!entry) {
|
|
2680
|
+
throw createJournalError("conflict", path, `cannot fail missing update ${input.updateId}`);
|
|
2681
|
+
}
|
|
2682
|
+
if (entry.state !== "pending" && entry.state !== "retry-wait") {
|
|
2683
|
+
throw createJournalError("conflict", path, `cannot fail ${entry.state} update ${input.updateId}`);
|
|
2684
|
+
}
|
|
2685
|
+
const previousAttemptCount = entry.failure?.attemptCount ?? 0;
|
|
2686
|
+
if (previousAttemptCount !== input.expectedAttemptCount) {
|
|
2687
|
+
throw createJournalError("conflict", path, `update ${input.updateId} execution attempt changed`);
|
|
2688
|
+
}
|
|
2689
|
+
const failure = {
|
|
2690
|
+
attemptCount: previousAttemptCount + 1,
|
|
2691
|
+
failedAtMs: input.failedAtMs,
|
|
2692
|
+
failureClass: input.failureClass,
|
|
2693
|
+
summary: input.summary,
|
|
2694
|
+
};
|
|
2695
|
+
const terminalFailureId = input.disposition === "failed"
|
|
2696
|
+
? createTelegramUpdateTerminalFailureId({
|
|
2697
|
+
updateId: entry.updateId,
|
|
2698
|
+
attemptCount: failure.attemptCount,
|
|
2699
|
+
failedAtMs: failure.failedAtMs,
|
|
2700
|
+
failureClass: failure.failureClass,
|
|
2701
|
+
terminalAtMs: input.failedAtMs,
|
|
2702
|
+
terminalReason: input.terminalReason,
|
|
2703
|
+
})
|
|
2704
|
+
: undefined;
|
|
2705
|
+
const nextEntry = {
|
|
2706
|
+
updateId: entry.updateId,
|
|
2707
|
+
update: entry.update,
|
|
2708
|
+
admittedAtMs: entry.admittedAtMs,
|
|
2709
|
+
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
2710
|
+
state: input.disposition,
|
|
2711
|
+
failure,
|
|
2712
|
+
...(input.disposition === "retry-wait"
|
|
2713
|
+
? { nextRetryAtMs: input.nextRetryAtMs }
|
|
2714
|
+
: {
|
|
2715
|
+
terminalAtMs: input.failedAtMs,
|
|
2716
|
+
terminalReason: input.terminalReason,
|
|
2717
|
+
terminalFailureId: terminalFailureId,
|
|
2718
|
+
}),
|
|
2719
|
+
};
|
|
2720
|
+
const published = publishMutation(current, current.file.entries.map((candidate) => candidate.updateId === input.updateId ? nextEntry : candidate), true);
|
|
2721
|
+
return {
|
|
2722
|
+
entry: cloneEntry(nextEntry),
|
|
2723
|
+
entryCount: published.file.entries.length,
|
|
2724
|
+
serializedBytes: published.serializedBytes,
|
|
2725
|
+
};
|
|
2726
|
+
});
|
|
2727
|
+
},
|
|
2728
|
+
applyOperatorDisposition(input) {
|
|
2729
|
+
return runMutation((readCurrent) => {
|
|
2730
|
+
if (!isSafeNonNegativeInteger(input.updateId) ||
|
|
2731
|
+
!isBoundedString(input.failureId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
2732
|
+
(input.action !== "retry" && input.action !== "discard")) {
|
|
2733
|
+
throw createJournalError("invalid", path, "received an invalid operator disposition");
|
|
2734
|
+
}
|
|
2735
|
+
const current = readCurrent();
|
|
2736
|
+
const existingDisposition = current.file.operatorDispositions?.find((candidate) => candidate.failureId === input.failureId);
|
|
2737
|
+
if (existingDisposition) {
|
|
2738
|
+
if ("dispositionKind" in existingDisposition)
|
|
2739
|
+
throw createJournalError("conflict", path, `terminal failure ${input.failureId} collides with legacy custody authority`);
|
|
2740
|
+
if (existingDisposition.updateId !== input.updateId ||
|
|
2741
|
+
existingDisposition.action !== input.action) {
|
|
2742
|
+
throw createJournalError("conflict", path, `terminal failure ${input.failureId} already has another operator disposition`);
|
|
2743
|
+
}
|
|
2744
|
+
return {
|
|
2745
|
+
disposition: { ...existingDisposition },
|
|
2746
|
+
duplicate: true,
|
|
2747
|
+
entryCount: current.file.entries.length,
|
|
2748
|
+
serializedBytes: current.serializedBytes,
|
|
2749
|
+
};
|
|
2750
|
+
}
|
|
2751
|
+
const entry = current.file.entries.find((candidate) => candidate.updateId === input.updateId);
|
|
2752
|
+
if (entry?.state !== "failed" ||
|
|
2753
|
+
!entry.failure ||
|
|
2754
|
+
entry.terminalAtMs === undefined ||
|
|
2755
|
+
!entry.terminalReason ||
|
|
2756
|
+
entry.terminalFailureId !== input.failureId) {
|
|
2757
|
+
throw createJournalError("conflict", path, `cannot apply a stale disposition to terminal failure ${input.failureId}`);
|
|
2758
|
+
}
|
|
2759
|
+
const nowMs = getNowMs();
|
|
2760
|
+
if (!isSafeNonNegativeInteger(nowMs)) {
|
|
2761
|
+
throw createJournalError("invalid", path, "received an invalid operator disposition timestamp");
|
|
2762
|
+
}
|
|
2763
|
+
const disposition = {
|
|
2764
|
+
failureId: entry.terminalFailureId,
|
|
2765
|
+
updateId: entry.updateId,
|
|
2766
|
+
action: input.action,
|
|
2767
|
+
committedAtMs: Math.max(nowMs, entry.terminalAtMs),
|
|
2768
|
+
attemptCount: entry.failure.attemptCount,
|
|
2769
|
+
failureClass: entry.failure.failureClass,
|
|
2770
|
+
terminalAtMs: entry.terminalAtMs,
|
|
2771
|
+
terminalReason: entry.terminalReason,
|
|
2772
|
+
};
|
|
2773
|
+
const nextEntries = input.action === "retry"
|
|
2774
|
+
? current.file.entries.map((candidate) => candidate.updateId === entry.updateId
|
|
2775
|
+
? {
|
|
2776
|
+
updateId: entry.updateId,
|
|
2777
|
+
update: entry.update,
|
|
2778
|
+
admittedAtMs: entry.admittedAtMs,
|
|
2779
|
+
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
2780
|
+
state: "retry-wait",
|
|
2781
|
+
failure: entry.failure,
|
|
2782
|
+
nextRetryAtMs: disposition.committedAtMs,
|
|
2783
|
+
}
|
|
2784
|
+
: candidate)
|
|
2785
|
+
: current.file.entries.filter((candidate) => candidate.updateId !== entry.updateId);
|
|
2786
|
+
const published = publishMutation(current, nextEntries, true, [...(current.file.operatorDispositions ?? []), disposition]);
|
|
2787
|
+
return {
|
|
2788
|
+
disposition: { ...disposition },
|
|
2789
|
+
duplicate: false,
|
|
2790
|
+
entryCount: published.file.entries.length,
|
|
2791
|
+
serializedBytes: published.serializedBytes,
|
|
2792
|
+
};
|
|
2793
|
+
});
|
|
2794
|
+
},
|
|
2795
|
+
applyLegacyCustodyDisposition(authority) {
|
|
2796
|
+
return runMutation((readCurrent) => {
|
|
2797
|
+
const current = readCurrent();
|
|
2798
|
+
if (current.file.version !== TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION)
|
|
2799
|
+
throw createJournalError("conflict", path, "legacy custody disposition requires a v3 journal");
|
|
2800
|
+
const existing = current.file.operatorDispositions?.find(candidate => candidate.failureId === authority.dispositionId);
|
|
2801
|
+
if (existing) {
|
|
2802
|
+
if (!("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody")
|
|
2803
|
+
throw createJournalError("conflict", path, `legacy custody disposition ${authority.dispositionId} already has another authority`);
|
|
2804
|
+
const normalizedDuplicate = normalizeTelegramUpdateJournalLegacyCustodyDispositionAuthority(authority, { updateId: existing.updateId, state: "retry-wait", attemptCount: 1,
|
|
2805
|
+
failedAtMs: 0, failureClass: "retained-audit", summary: "retained-audit",
|
|
2806
|
+
evidenceSha256: existing.evidenceSha256 });
|
|
2807
|
+
if (!normalizedDuplicate || existing.action !== normalizedDuplicate.action ||
|
|
2808
|
+
existing.operatorAuthorityId !== normalizedDuplicate.operatorAuthorityId ||
|
|
2809
|
+
existing.authorizedAtMs !== normalizedDuplicate.authorizedAtMs)
|
|
2810
|
+
throw createJournalError("conflict", path, `legacy custody disposition ${authority.dispositionId} already has another authority`);
|
|
2811
|
+
let authorized = false;
|
|
2812
|
+
try {
|
|
2813
|
+
authorized = options.authorizeLegacyCustodyDisposition?.(normalizedDuplicate) === true;
|
|
2814
|
+
}
|
|
2815
|
+
catch {
|
|
2816
|
+
authorized = false;
|
|
2817
|
+
}
|
|
2818
|
+
if (!authorized)
|
|
2819
|
+
throw createJournalError("conflict", path, "legacy custody disposition is unauthorized");
|
|
2820
|
+
return { disposition: { ...existing }, duplicate: true,
|
|
2821
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
2822
|
+
}
|
|
2823
|
+
const entry = current.file.entries.find(candidate => candidate.updateId === authority.updateId);
|
|
2824
|
+
const evidence = entry && createTelegramUpdateJournalLegacyCustodyEvidence(entry);
|
|
2825
|
+
if (!evidence)
|
|
2826
|
+
throw createJournalError("conflict", path, `cannot dispose non-quarantined update ${authority.updateId}`);
|
|
2827
|
+
const normalized = normalizeTelegramUpdateJournalLegacyCustodyDispositionAuthority(authority, evidence);
|
|
2828
|
+
if (!normalized)
|
|
2829
|
+
throw createJournalError("conflict", path, `legacy custody evidence changed for update ${authority.updateId}`);
|
|
2830
|
+
let authorized = false;
|
|
2831
|
+
try {
|
|
2832
|
+
authorized = options.authorizeLegacyCustodyDisposition?.(normalized) === true;
|
|
2833
|
+
}
|
|
2834
|
+
catch {
|
|
2835
|
+
authorized = false;
|
|
2836
|
+
}
|
|
2837
|
+
if (!authorized)
|
|
2838
|
+
throw createJournalError("conflict", path, "legacy custody disposition is unauthorized");
|
|
2839
|
+
const nowMs = getNowMs();
|
|
2840
|
+
if (!isSafeNonNegativeInteger(nowMs))
|
|
2841
|
+
throw createJournalError("invalid", path, "received an invalid legacy custody disposition timestamp");
|
|
2842
|
+
const disposition = {
|
|
2843
|
+
dispositionKind: "legacy-custody", failureId: normalized.dispositionId,
|
|
2844
|
+
updateId: normalized.updateId, action: normalized.action,
|
|
2845
|
+
committedAtMs: Math.max(nowMs, normalized.authorizedAtMs),
|
|
2846
|
+
evidenceSha256: normalized.evidenceSha256,
|
|
2847
|
+
operatorAuthorityId: normalized.operatorAuthorityId,
|
|
2848
|
+
authorizedAtMs: normalized.authorizedAtMs
|
|
2849
|
+
};
|
|
2850
|
+
const nextEntries = normalized.action === "discard"
|
|
2851
|
+
? current.file.entries.filter(candidate => candidate.updateId !== entry.updateId)
|
|
2852
|
+
: current.file.entries.map(candidate => candidate.updateId === entry.updateId
|
|
2853
|
+
? { updateId: entry.updateId, update: entry.update,
|
|
2854
|
+
admittedAtMs: entry.admittedAtMs,
|
|
2855
|
+
...(entry.preApprovalExcluded === undefined ? {} :
|
|
2856
|
+
{ preApprovalExcluded: entry.preApprovalExcluded }), state: "pending" }
|
|
2857
|
+
: candidate);
|
|
2858
|
+
const published = publishMutation(current, nextEntries, true, [...(current.file.operatorDispositions ?? []), disposition]);
|
|
2859
|
+
return { disposition: { ...disposition }, duplicate: false,
|
|
2860
|
+
entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
2861
|
+
});
|
|
2862
|
+
},
|
|
2863
|
+
offerQueuedHandoff(input) {
|
|
2864
|
+
return runMutation((readCurrent) => {
|
|
2865
|
+
const { expectedOwner, recipientOwner, requestedIds, handoffId } = validateQueueHandoffInput(input, "offer");
|
|
2866
|
+
if (isTelegramUpdateJournalQueueOwnerProcess(expectedOwner, recipientOwner)) {
|
|
2867
|
+
throw createJournalError("conflict", path, `cannot hand queue receipt ${input.receiptId} to the same runtime process`);
|
|
2868
|
+
}
|
|
2869
|
+
const current = readCurrent();
|
|
2870
|
+
const receiptEntries = getExactQueuedReceiptEntries(current, input, requestedIds);
|
|
2871
|
+
if (receiptEntries.some((entry) => !entry.queueOwner ||
|
|
2872
|
+
!areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, expectedOwner))) {
|
|
2873
|
+
throw createJournalError("conflict", path, `cannot offer stale queue receipt ${input.receiptId}`);
|
|
2874
|
+
}
|
|
2875
|
+
const existingHandoff = receiptEntries[0]?.queueHandoff;
|
|
2876
|
+
if (existingHandoff) {
|
|
2877
|
+
if (existingHandoff.handoffId !== handoffId ||
|
|
2878
|
+
!isDeepStrictEqual(existingHandoff.recipientOwner, recipientOwner)) {
|
|
2879
|
+
throw createJournalError("conflict", path, `queue receipt ${input.receiptId} already has another handoff offer`);
|
|
2880
|
+
}
|
|
2881
|
+
return {
|
|
2882
|
+
handoff: cloneJournalQueueHandoff(existingHandoff),
|
|
2883
|
+
previousOwner: cloneJournalQueueOwner(expectedOwner),
|
|
2884
|
+
offeredUpdateIds: [...requestedIds].sort((a, b) => a - b),
|
|
2885
|
+
duplicate: true,
|
|
2886
|
+
entryCount: current.file.entries.length,
|
|
2887
|
+
serializedBytes: current.serializedBytes,
|
|
2888
|
+
};
|
|
2889
|
+
}
|
|
2890
|
+
const offeredAtMs = getNowMs();
|
|
2891
|
+
if (!isSafeNonNegativeInteger(offeredAtMs)) {
|
|
2892
|
+
throw createJournalError("invalid", path, "received an invalid queue handoff offer timestamp");
|
|
2893
|
+
}
|
|
2894
|
+
const handoff = {
|
|
2895
|
+
handoffId,
|
|
2896
|
+
offeredAtMs,
|
|
2897
|
+
recipientOwner,
|
|
2898
|
+
};
|
|
2899
|
+
const published = publishMutation(current, current.file.entries.map((entry) => requestedIds.has(entry.updateId)
|
|
2900
|
+
? { ...entry, queueHandoff: cloneJournalQueueHandoff(handoff) }
|
|
2901
|
+
: entry), true);
|
|
2902
|
+
return {
|
|
2903
|
+
handoff: cloneJournalQueueHandoff(handoff),
|
|
2904
|
+
previousOwner: cloneJournalQueueOwner(expectedOwner),
|
|
2905
|
+
offeredUpdateIds: [...requestedIds].sort((a, b) => a - b),
|
|
2906
|
+
duplicate: false,
|
|
2907
|
+
entryCount: published.file.entries.length,
|
|
2908
|
+
serializedBytes: published.serializedBytes,
|
|
2909
|
+
};
|
|
2910
|
+
});
|
|
2911
|
+
},
|
|
2912
|
+
acceptQueuedHandoff(input) {
|
|
2913
|
+
return runMutation((readCurrent) => {
|
|
2914
|
+
const { expectedOwner, recipientOwner, requestedIds, handoffId } = validateQueueHandoffInput(input, "accept");
|
|
2915
|
+
const current = readCurrent();
|
|
2916
|
+
const receiptEntries = getExactQueuedReceiptEntries(current, input, requestedIds);
|
|
2917
|
+
const existingOwner = receiptEntries[0]?.queueOwner;
|
|
2918
|
+
if (existingOwner &&
|
|
2919
|
+
existingOwner.handoffId === handoffId &&
|
|
2920
|
+
isTelegramUpdateJournalQueueOwnerProcess(existingOwner, recipientOwner)) {
|
|
2921
|
+
if (receiptEntries.some((entry) => !entry.queueOwner ||
|
|
2922
|
+
!areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, existingOwner) ||
|
|
2923
|
+
entry.queueHandoff !== undefined)) {
|
|
2924
|
+
throw createJournalError("conflict", path, `queue receipt ${input.receiptId} has inconsistent accepted handoff authority`);
|
|
2925
|
+
}
|
|
2926
|
+
return {
|
|
2927
|
+
handoffId,
|
|
2928
|
+
queueOwner: cloneJournalQueueOwner(existingOwner),
|
|
2929
|
+
acceptedUpdateIds: [...requestedIds].sort((a, b) => a - b),
|
|
2930
|
+
duplicate: true,
|
|
2931
|
+
entryCount: current.file.entries.length,
|
|
2932
|
+
serializedBytes: current.serializedBytes,
|
|
2933
|
+
};
|
|
2934
|
+
}
|
|
2935
|
+
if (receiptEntries.some((entry) => !entry.queueOwner ||
|
|
2936
|
+
!areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, expectedOwner) ||
|
|
2937
|
+
entry.queueHandoff?.handoffId !== handoffId ||
|
|
2938
|
+
!isDeepStrictEqual(entry.queueHandoff.recipientOwner, recipientOwner))) {
|
|
2939
|
+
throw createJournalError("conflict", path, `cannot accept stale or unauthenticated queue handoff ${input.receiptId}`);
|
|
2940
|
+
}
|
|
2941
|
+
const acquiredAtMs = getNowMs();
|
|
2942
|
+
if (!isSafeNonNegativeInteger(acquiredAtMs)) {
|
|
2943
|
+
throw createJournalError("invalid", path, "received an invalid queue handoff acquisition timestamp");
|
|
2944
|
+
}
|
|
2945
|
+
const queueOwner = {
|
|
2946
|
+
...recipientOwner,
|
|
2947
|
+
acquisitionId: randomUUID(),
|
|
2948
|
+
acquiredAtMs,
|
|
2949
|
+
handoffId,
|
|
2950
|
+
};
|
|
2951
|
+
const published = publishMutation(current, current.file.entries.map((entry) => requestedIds.has(entry.updateId)
|
|
2952
|
+
? {
|
|
2953
|
+
updateId: entry.updateId,
|
|
2954
|
+
update: entry.update,
|
|
2955
|
+
admittedAtMs: entry.admittedAtMs,
|
|
2956
|
+
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
2957
|
+
state: "queued",
|
|
2958
|
+
queueKind: input.queueKind,
|
|
2959
|
+
queueReceiptId: input.receiptId,
|
|
2960
|
+
queueOwner: cloneJournalQueueOwner(queueOwner),
|
|
2961
|
+
...(entry.inputProvenance
|
|
2962
|
+
? { inputProvenance: structuredClone(entry.inputProvenance) } : {}),
|
|
2963
|
+
}
|
|
2964
|
+
: entry), true);
|
|
2965
|
+
return {
|
|
2966
|
+
handoffId,
|
|
2967
|
+
previousOwner: cloneJournalQueueOwner(expectedOwner),
|
|
2968
|
+
queueOwner: cloneJournalQueueOwner(queueOwner),
|
|
2969
|
+
acceptedUpdateIds: [...requestedIds].sort((a, b) => a - b),
|
|
2970
|
+
duplicate: false,
|
|
2971
|
+
entryCount: published.file.entries.length,
|
|
2972
|
+
serializedBytes: published.serializedBytes,
|
|
2973
|
+
};
|
|
2974
|
+
});
|
|
2975
|
+
},
|
|
2976
|
+
cancelQueuedHandoff(input) {
|
|
2977
|
+
return runMutation((readCurrent) => {
|
|
2978
|
+
const { expectedOwner, recipientOwner, requestedIds, handoffId } = validateQueueHandoffInput(input, "cancel");
|
|
2979
|
+
const current = readCurrent();
|
|
2980
|
+
const receiptEntries = getExactQueuedReceiptEntries(current, input, requestedIds);
|
|
2981
|
+
if (receiptEntries.some((entry) => !entry.queueOwner ||
|
|
2982
|
+
!areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, expectedOwner))) {
|
|
2983
|
+
throw createJournalError("conflict", path, `cannot cancel stale queue handoff ${input.receiptId}`);
|
|
2984
|
+
}
|
|
2985
|
+
const existingHandoff = receiptEntries[0]?.queueHandoff;
|
|
2986
|
+
if (!existingHandoff) {
|
|
2987
|
+
throw createJournalError("conflict", path, `cannot cancel missing queue handoff offer ${input.receiptId}`);
|
|
2988
|
+
}
|
|
2989
|
+
if (existingHandoff.handoffId !== handoffId ||
|
|
2990
|
+
!isDeepStrictEqual(existingHandoff.recipientOwner, recipientOwner)) {
|
|
2991
|
+
throw createJournalError("conflict", path, `cannot cancel another queue handoff offer ${input.receiptId}`);
|
|
2992
|
+
}
|
|
2993
|
+
const published = publishMutation(current, current.file.entries.map((entry) => {
|
|
2994
|
+
if (!requestedIds.has(entry.updateId))
|
|
2995
|
+
return entry;
|
|
2996
|
+
const { queueHandoff: _queueHandoff, ...retained } = entry;
|
|
2997
|
+
return retained;
|
|
2998
|
+
}), true);
|
|
2999
|
+
return {
|
|
3000
|
+
handoffId,
|
|
3001
|
+
previousOwner: cloneJournalQueueOwner(expectedOwner),
|
|
3002
|
+
cancelledUpdateIds: [...requestedIds].sort((a, b) => a - b),
|
|
3003
|
+
entryCount: published.file.entries.length,
|
|
3004
|
+
serializedBytes: published.serializedBytes,
|
|
3005
|
+
};
|
|
3006
|
+
});
|
|
3007
|
+
},
|
|
3008
|
+
completeQueued(receipts) {
|
|
3009
|
+
return runMutation((readCurrent) => {
|
|
3010
|
+
if (!Array.isArray(receipts) || receipts.length === 0) {
|
|
3011
|
+
throw createJournalError("invalid", path, "received no queued receipts to complete");
|
|
3012
|
+
}
|
|
3013
|
+
const current = readCurrent();
|
|
3014
|
+
const receiptIds = new Set();
|
|
3015
|
+
const requestedUpdateIds = new Set();
|
|
3016
|
+
for (const receipt of receipts) {
|
|
3017
|
+
if ((receipt.queueKind !== "prompt" &&
|
|
3018
|
+
receipt.queueKind !== "control") ||
|
|
3019
|
+
!isNonEmptyString(receipt.receiptId) ||
|
|
3020
|
+
receiptIds.has(receipt.receiptId) ||
|
|
3021
|
+
!Array.isArray(receipt.sourceUpdateIds) ||
|
|
3022
|
+
receipt.sourceUpdateIds.length === 0) {
|
|
3023
|
+
throw createJournalError("invalid", path, "received an invalid queued completion receipt");
|
|
3024
|
+
}
|
|
3025
|
+
receiptIds.add(receipt.receiptId);
|
|
3026
|
+
const queueOwner = validateJournalQueueOwner(receipt.queueOwner, path);
|
|
3027
|
+
if (queueRuntimeIdentity &&
|
|
3028
|
+
(queueOwner.instanceId !== queueRuntimeIdentity.instanceId ||
|
|
3029
|
+
queueOwner.processId !== queueRuntimeIdentity.processId ||
|
|
3030
|
+
queueOwner.processBirthId !==
|
|
3031
|
+
queueRuntimeIdentity.processBirthId)) {
|
|
3032
|
+
throw createJournalError("conflict", path, `cannot complete foreign queue receipt ${receipt.receiptId}`);
|
|
3033
|
+
}
|
|
3034
|
+
const sourceUpdateIds = new Set();
|
|
3035
|
+
for (const updateId of receipt.sourceUpdateIds) {
|
|
3036
|
+
if (!isSafeNonNegativeInteger(updateId) ||
|
|
3037
|
+
sourceUpdateIds.has(updateId) ||
|
|
3038
|
+
requestedUpdateIds.has(updateId)) {
|
|
3039
|
+
throw createJournalError("invalid", path, "received overlapping queued completion update ids");
|
|
3040
|
+
}
|
|
3041
|
+
sourceUpdateIds.add(updateId);
|
|
3042
|
+
requestedUpdateIds.add(updateId);
|
|
3043
|
+
}
|
|
3044
|
+
const persistedReceiptEntries = current.file.entries.filter((entry) => entry.queueReceiptId === receipt.receiptId);
|
|
3045
|
+
if (persistedReceiptEntries.length !== sourceUpdateIds.size ||
|
|
3046
|
+
persistedReceiptEntries.some((entry) => !sourceUpdateIds.has(entry.updateId) ||
|
|
3047
|
+
entry.state !== "queued" ||
|
|
3048
|
+
entry.queueKind !== receipt.queueKind ||
|
|
3049
|
+
!entry.queueOwner ||
|
|
3050
|
+
entry.queueHandoff !== undefined ||
|
|
3051
|
+
!areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, queueOwner))) {
|
|
3052
|
+
throw createJournalError("conflict", path, `cannot complete stale or foreign queue receipt ${receipt.receiptId}`);
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
const removedUpdateIds = current.file.entries
|
|
3056
|
+
.filter((entry) => requestedUpdateIds.has(entry.updateId))
|
|
3057
|
+
.map((entry) => entry.updateId);
|
|
3058
|
+
if (removedUpdateIds.length !== requestedUpdateIds.size) {
|
|
3059
|
+
throw createJournalError("conflict", path, "queued completion did not resolve every source update");
|
|
3060
|
+
}
|
|
3061
|
+
const published = publishMutation(current, current.file.entries.filter((entry) => !requestedUpdateIds.has(entry.updateId)), true);
|
|
3062
|
+
return {
|
|
3063
|
+
removedUpdateIds,
|
|
3064
|
+
entryCount: published.file.entries.length,
|
|
3065
|
+
serializedBytes: published.serializedBytes,
|
|
3066
|
+
};
|
|
3067
|
+
});
|
|
3068
|
+
},
|
|
3069
|
+
discardQueued(input) {
|
|
3070
|
+
return runMutation((readCurrent) => {
|
|
3071
|
+
if ((input.queueKind !== "prompt" &&
|
|
3072
|
+
input.queueKind !== "control") ||
|
|
3073
|
+
!isNonEmptyString(input.receiptId) ||
|
|
3074
|
+
!Array.isArray(input.sourceUpdateIds) ||
|
|
3075
|
+
input.sourceUpdateIds.length === 0) {
|
|
3076
|
+
throw createJournalError("invalid", path, "received an invalid queue discard");
|
|
3077
|
+
}
|
|
3078
|
+
const expectedOwner = validateJournalQueueOwner(input.expectedOwner, path);
|
|
3079
|
+
if (queueRuntimeIdentity &&
|
|
3080
|
+
(expectedOwner.instanceId !== queueRuntimeIdentity.instanceId ||
|
|
3081
|
+
expectedOwner.processId !== queueRuntimeIdentity.processId ||
|
|
3082
|
+
expectedOwner.processBirthId !==
|
|
3083
|
+
queueRuntimeIdentity.processBirthId)) {
|
|
3084
|
+
throw createJournalError("conflict", path, `cannot discard foreign queue receipt ${input.receiptId}`);
|
|
3085
|
+
}
|
|
3086
|
+
const requestedIds = new Set();
|
|
3087
|
+
for (const updateId of input.sourceUpdateIds) {
|
|
3088
|
+
if (!isSafeNonNegativeInteger(updateId) ||
|
|
3089
|
+
requestedIds.has(updateId)) {
|
|
3090
|
+
throw createJournalError("invalid", path, "received invalid queue discard update ids");
|
|
3091
|
+
}
|
|
3092
|
+
requestedIds.add(updateId);
|
|
3093
|
+
}
|
|
3094
|
+
const current = readCurrent();
|
|
3095
|
+
const receiptEntries = current.file.entries.filter((entry) => entry.queueReceiptId === input.receiptId);
|
|
3096
|
+
if (receiptEntries.length !== requestedIds.size ||
|
|
3097
|
+
receiptEntries.some((entry) => !requestedIds.has(entry.updateId) ||
|
|
3098
|
+
entry.state !== "queued" ||
|
|
3099
|
+
entry.queueKind !== input.queueKind ||
|
|
3100
|
+
!entry.queueOwner ||
|
|
3101
|
+
entry.queueHandoff !== undefined ||
|
|
3102
|
+
!areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, expectedOwner))) {
|
|
3103
|
+
throw createJournalError("conflict", path, `cannot discard stale queue receipt ${input.receiptId}`);
|
|
3104
|
+
}
|
|
3105
|
+
const removedUpdateIds = [...requestedIds].sort((a, b) => a - b);
|
|
3106
|
+
const published = publishMutation(current, current.file.entries.filter((entry) => !requestedIds.has(entry.updateId)), true);
|
|
3107
|
+
return {
|
|
3108
|
+
previousOwner: cloneJournalQueueOwner(expectedOwner),
|
|
3109
|
+
removedUpdateIds,
|
|
3110
|
+
entryCount: published.file.entries.length,
|
|
3111
|
+
serializedBytes: published.serializedBytes,
|
|
3112
|
+
};
|
|
3113
|
+
});
|
|
3114
|
+
},
|
|
3115
|
+
recoverDeadQueueOwner(input) {
|
|
3116
|
+
return runMutation((readCurrent) => {
|
|
3117
|
+
if (!getQueueProcessLiveness) {
|
|
3118
|
+
throw createJournalError("conflict", path, "cannot recover queue authority without a process-liveness proof");
|
|
3119
|
+
}
|
|
3120
|
+
const deadOwner = validateJournalQueueOwner(input.deadOwner, path);
|
|
3121
|
+
let ownerLiveness;
|
|
3122
|
+
try {
|
|
3123
|
+
ownerLiveness = getQueueProcessLiveness({
|
|
3124
|
+
processId: deadOwner.processId,
|
|
3125
|
+
processBirthId: deadOwner.processBirthId,
|
|
3126
|
+
});
|
|
3127
|
+
}
|
|
3128
|
+
catch (error) {
|
|
3129
|
+
throw createJournalError("io", path, "could not prove queued owner liveness", error);
|
|
3130
|
+
}
|
|
3131
|
+
const recoveryOwner = validateJournalQueueOwnerIdentity(input.recoveryOwner, path);
|
|
3132
|
+
if (queueRuntimeIdentity &&
|
|
3133
|
+
(recoveryOwner.instanceId !== queueRuntimeIdentity.instanceId ||
|
|
3134
|
+
recoveryOwner.processId !== queueRuntimeIdentity.processId ||
|
|
3135
|
+
recoveryOwner.processBirthId !==
|
|
3136
|
+
queueRuntimeIdentity.processBirthId)) {
|
|
3137
|
+
throw createJournalError("conflict", path, `cannot recover queue receipt ${input.receiptId} for another runtime`);
|
|
3138
|
+
}
|
|
3139
|
+
const requestedIds = new Set();
|
|
3140
|
+
for (const updateId of input.sourceUpdateIds) {
|
|
3141
|
+
if (!isSafeNonNegativeInteger(updateId) ||
|
|
3142
|
+
requestedIds.has(updateId)) {
|
|
3143
|
+
throw createJournalError("invalid", path, "received invalid queue recovery update ids");
|
|
3144
|
+
}
|
|
3145
|
+
requestedIds.add(updateId);
|
|
3146
|
+
}
|
|
3147
|
+
const current = readCurrent();
|
|
3148
|
+
const receiptEntries = current.file.entries.filter((entry) => entry.queueReceiptId === input.receiptId);
|
|
3149
|
+
if ((input.queueKind !== "prompt" &&
|
|
3150
|
+
input.queueKind !== "control") ||
|
|
3151
|
+
!isNonEmptyString(input.receiptId) ||
|
|
3152
|
+
requestedIds.size === 0 ||
|
|
3153
|
+
receiptEntries.length !== requestedIds.size ||
|
|
3154
|
+
receiptEntries.some((entry) => !requestedIds.has(entry.updateId) ||
|
|
3155
|
+
entry.state !== "queued" ||
|
|
3156
|
+
entry.queueKind !== input.queueKind ||
|
|
3157
|
+
!entry.queueOwner ||
|
|
3158
|
+
entry.queueHandoff !== undefined ||
|
|
3159
|
+
!areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, deadOwner))) {
|
|
3160
|
+
throw createJournalError("conflict", path, `cannot recover stale queue receipt ${input.receiptId}`);
|
|
3161
|
+
}
|
|
3162
|
+
if (ownerLiveness !== "dead") {
|
|
3163
|
+
return {
|
|
3164
|
+
status: ownerLiveness === "alive"
|
|
3165
|
+
? "owner-alive"
|
|
3166
|
+
: "owner-unverifiable",
|
|
3167
|
+
previousOwner: cloneJournalQueueOwner(deadOwner),
|
|
3168
|
+
recoveredUpdateIds: [],
|
|
3169
|
+
entryCount: current.file.entries.length,
|
|
3170
|
+
serializedBytes: current.serializedBytes,
|
|
3171
|
+
};
|
|
3172
|
+
}
|
|
3173
|
+
const recoveredUpdateIds = [...requestedIds].sort((a, b) => a - b);
|
|
3174
|
+
const published = publishMutation(current, current.file.entries.filter((entry) => !requestedIds.has(entry.updateId)), true);
|
|
3175
|
+
return {
|
|
3176
|
+
status: "recovered",
|
|
3177
|
+
previousOwner: cloneJournalQueueOwner(deadOwner),
|
|
3178
|
+
recoveredUpdateIds,
|
|
3179
|
+
entryCount: published.file.entries.length,
|
|
3180
|
+
serializedBytes: published.serializedBytes,
|
|
3181
|
+
};
|
|
3182
|
+
});
|
|
3183
|
+
},
|
|
3184
|
+
removeCompleted(updateIds) {
|
|
3185
|
+
return runMutation((readCurrent) => {
|
|
3186
|
+
const current = readCurrent();
|
|
3187
|
+
const requestedIds = new Set();
|
|
3188
|
+
for (const updateId of updateIds) {
|
|
3189
|
+
if (!isSafeNonNegativeInteger(updateId)) {
|
|
3190
|
+
throw createJournalError("invalid", path, "received an invalid removal update id");
|
|
3191
|
+
}
|
|
3192
|
+
requestedIds.add(updateId);
|
|
3193
|
+
}
|
|
3194
|
+
const requestedEntries = current.file.entries.filter((entry) => requestedIds.has(entry.updateId));
|
|
3195
|
+
const protectedEntry = requestedEntries.find((entry) => entry.state === "failed" || entry.state === "queued");
|
|
3196
|
+
if (protectedEntry) {
|
|
3197
|
+
throw createJournalError("conflict", path, protectedEntry.state === "failed"
|
|
3198
|
+
? `cannot complete terminal update ${protectedEntry.updateId} without an operator disposition`
|
|
3199
|
+
: `cannot complete queued update ${protectedEntry.updateId} without its exact owner receipt`);
|
|
3200
|
+
}
|
|
3201
|
+
const removedUpdateIds = requestedEntries.map((entry) => entry.updateId);
|
|
3202
|
+
const published = publishMutation(current, current.file.entries.filter((entry) => !requestedIds.has(entry.updateId)), removedUpdateIds.length > 0);
|
|
3203
|
+
return {
|
|
3204
|
+
removedUpdateIds,
|
|
3205
|
+
entryCount: published.file.entries.length,
|
|
3206
|
+
serializedBytes: published.serializedBytes,
|
|
3207
|
+
};
|
|
3208
|
+
});
|
|
3209
|
+
},
|
|
3210
|
+
};
|
|
3211
|
+
if (!getInputContext)
|
|
3212
|
+
return { journal };
|
|
3213
|
+
// Strict source acquisition already requires this exact stored receipt scope.
|
|
3214
|
+
const bindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
3215
|
+
const createInputSourceReference = (updateId) => ({
|
|
3216
|
+
journalBindingKey: bindingKey,
|
|
3217
|
+
tokenSha256: expectedIdentity.tokenSha256,
|
|
3218
|
+
updateId,
|
|
3219
|
+
});
|
|
3220
|
+
const createInputReceipt = (updateId, owner) => ({
|
|
3221
|
+
...createInputSourceReference(updateId),
|
|
3222
|
+
owner: cloneJournalQueueOwner(owner),
|
|
3223
|
+
});
|
|
3224
|
+
const assertInputProcess = (identity) => {
|
|
3225
|
+
if (!queueRuntimeIdentity ||
|
|
3226
|
+
identity.instanceId !== queueRuntimeIdentity.instanceId ||
|
|
3227
|
+
identity.processId !== queueRuntimeIdentity.processId ||
|
|
3228
|
+
identity.processBirthId !== queueRuntimeIdentity.processBirthId) {
|
|
3229
|
+
throw createJournalError("conflict", path, "input custody belongs to another runtime");
|
|
3230
|
+
}
|
|
3231
|
+
};
|
|
3232
|
+
const inputOwnerMatchesIdentity = (owner, identity) => isTelegramUpdateJournalQueueOwnerProcess(owner, identity) &&
|
|
3233
|
+
owner.sessionGeneration === identity.sessionGeneration;
|
|
3234
|
+
const currentInputContext = () => {
|
|
3235
|
+
const observed = getInputContext();
|
|
3236
|
+
if (observed === undefined)
|
|
3237
|
+
throw createJournalError("conflict", path, "input execution context is unavailable");
|
|
3238
|
+
const owner = validateJournalQueueOwnerIdentity(observed.owner, path);
|
|
3239
|
+
const recipientBindingKey = observed.recipientBindingKey;
|
|
3240
|
+
if (!isBoundedString(recipientBindingKey, TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH) ||
|
|
3241
|
+
!recipientBindingKey.trim()) {
|
|
3242
|
+
throw createJournalError("invalid", path, "input execution binding is invalid");
|
|
3243
|
+
}
|
|
3244
|
+
assertInputProcess(owner);
|
|
3245
|
+
return { owner, recipientBindingKey };
|
|
3246
|
+
};
|
|
3247
|
+
const runInputAdmission = (kind, operation) => workspaceAdmission ? runWithTelegramWorkspaceAdmissions({
|
|
3248
|
+
ledger: workspaceAdmission, operationId: `input:${randomUUID()}`, operationKind: `journal.input.${kind}`,
|
|
3249
|
+
scopes: [{ kind: "profile" }], operation,
|
|
3250
|
+
}) : operation();
|
|
3251
|
+
const runInputMutation = (kind, operation) => runInputAdmission(kind, () => runMutation(operation));
|
|
3252
|
+
const publishInputMutation = (current, entries, updateId, context) => publishMutation(current, entries, true, current.file.operatorDispositions, current.file.acceptedThroughUpdateId, (boundary, publicationPath) => {
|
|
3253
|
+
onPublicationBoundary?.(boundary, publicationPath);
|
|
3254
|
+
if (!isDeepStrictEqual(currentInputContext(), context)) {
|
|
3255
|
+
throw createJournalError("conflict", path, "input execution context changed before publication");
|
|
3256
|
+
}
|
|
3257
|
+
}, updateId);
|
|
3258
|
+
const publishInputSettlement = (current, entries, contentChanged) => publishMutation(current, entries, contentChanged, current.file.operatorDispositions, current.file.acceptedThroughUpdateId, onPublicationBoundary, false);
|
|
3259
|
+
const normalizeInputSourceReference = (value, operation) => {
|
|
3260
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["journalBindingKey", "tokenSha256", "updateId"])) {
|
|
3261
|
+
throw createJournalError("invalid", path, `received invalid input handoff ${operation} source`);
|
|
3262
|
+
}
|
|
3263
|
+
const journalBindingKey = value.journalBindingKey;
|
|
3264
|
+
const updateId = value.updateId;
|
|
3265
|
+
if (journalBindingKey !== bindingKey || value.tokenSha256 !== expectedIdentity.tokenSha256 ||
|
|
3266
|
+
!isSafeNonNegativeInteger(updateId)) {
|
|
3267
|
+
throw createJournalError("conflict", path, "input receipt does not match its source identity");
|
|
3268
|
+
}
|
|
3269
|
+
return { journalBindingKey, tokenSha256: expectedIdentity.tokenSha256, updateId };
|
|
3270
|
+
};
|
|
3271
|
+
const normalizeInputReceipt = (value, requireCurrentProcess = true) => {
|
|
3272
|
+
if (!isRecord(value) ||
|
|
3273
|
+
!hasOnlyKeys(value, ["journalBindingKey", "tokenSha256", "updateId", "owner"])) {
|
|
3274
|
+
throw createJournalError("invalid", path, "received an invalid input receipt");
|
|
3275
|
+
}
|
|
3276
|
+
const source = normalizeInputSourceReference({ journalBindingKey: value.journalBindingKey,
|
|
3277
|
+
tokenSha256: value.tokenSha256, updateId: value.updateId }, "receipt");
|
|
3278
|
+
const owner = validateJournalQueueOwner(value.owner, path);
|
|
3279
|
+
if (requireCurrentProcess)
|
|
3280
|
+
assertInputProcess(owner);
|
|
3281
|
+
return { ...source, owner };
|
|
3282
|
+
};
|
|
3283
|
+
const normalizeInputHandoffOffer = (value) => {
|
|
3284
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["receipt", "recipientOwner", "handoffToken"]) ||
|
|
3285
|
+
!isBoundedString(value.handoffToken, TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH) ||
|
|
3286
|
+
value.handoffToken.length < TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH) {
|
|
3287
|
+
throw createJournalError("invalid", path, "received invalid input handoff offer");
|
|
3288
|
+
}
|
|
3289
|
+
const receipt = normalizeInputReceipt(value.receipt);
|
|
3290
|
+
const recipientOwner = validateJournalQueueOwnerIdentity(value.recipientOwner, path);
|
|
3291
|
+
if (isTelegramUpdateJournalQueueOwnerProcess(receipt.owner, recipientOwner)) {
|
|
3292
|
+
throw createJournalError("conflict", path, "cannot transfer input custody to the same runtime process");
|
|
3293
|
+
}
|
|
3294
|
+
return { receipt, recipientOwner, handoffToken: value.handoffToken };
|
|
3295
|
+
};
|
|
3296
|
+
const normalizeInputHandoffId = (value, operation) => {
|
|
3297
|
+
if (!isTelegramInputHandoffId(value)) {
|
|
3298
|
+
throw createJournalError("invalid", path, `received invalid input handoff ${operation}`);
|
|
3299
|
+
}
|
|
3300
|
+
return value;
|
|
3301
|
+
};
|
|
3302
|
+
const normalizeInputHandoffAccept = (value) => {
|
|
3303
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["source", "recipientOwner", "handoffId"])) {
|
|
3304
|
+
throw createJournalError("invalid", path, "received invalid input handoff accept");
|
|
3305
|
+
}
|
|
3306
|
+
const source = normalizeInputSourceReference(value.source, "accept");
|
|
3307
|
+
const recipientOwner = validateJournalQueueOwnerIdentity(value.recipientOwner, path);
|
|
3308
|
+
assertInputProcess(recipientOwner);
|
|
3309
|
+
return { source, recipientOwner, handoffId: normalizeInputHandoffId(value.handoffId, "accept") };
|
|
3310
|
+
};
|
|
3311
|
+
const normalizeInputHandoffCancel = (value) => {
|
|
3312
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["receipt", "recipientOwner", "handoffId"])) {
|
|
3313
|
+
throw createJournalError("invalid", path, "received invalid input handoff cancel");
|
|
3314
|
+
}
|
|
3315
|
+
return { receipt: normalizeInputReceipt(value.receipt),
|
|
3316
|
+
recipientOwner: validateJournalQueueOwnerIdentity(value.recipientOwner, path),
|
|
3317
|
+
handoffId: normalizeInputHandoffId(value.handoffId, "cancel") };
|
|
3318
|
+
};
|
|
3319
|
+
const normalizeInputQueue = (value) => {
|
|
3320
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["queueKind", "receiptId", "receipts"]) ||
|
|
3321
|
+
(value.queueKind !== "prompt" && value.queueKind !== "control") ||
|
|
3322
|
+
!isBoundedString(value.receiptId, TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH) ||
|
|
3323
|
+
!value.receiptId.trim() || !Array.isArray(value.receipts) || value.receipts.length === 0) {
|
|
3324
|
+
throw createJournalError("invalid", path, "received invalid input queue transition");
|
|
3325
|
+
}
|
|
3326
|
+
const receipts = value.receipts.map(receipt => normalizeInputReceipt(receipt)).sort((left, right) => left.updateId - right.updateId);
|
|
3327
|
+
if (receipts.some((receipt, index) => index > 0 && receipt.updateId === receipts[index - 1].updateId)) {
|
|
3328
|
+
throw createJournalError("invalid", path, "received duplicate input queue transition receipts");
|
|
3329
|
+
}
|
|
3330
|
+
return { queueKind: value.queueKind, receiptId: value.receiptId, receipts };
|
|
3331
|
+
};
|
|
3332
|
+
const ownedInput = (current, receipt) => {
|
|
3333
|
+
const entry = current.file.entries.find(candidate => candidate.updateId === receipt.updateId);
|
|
3334
|
+
if (!entry?.inputClaim || entry.state !== "pending" ||
|
|
3335
|
+
!areTelegramUpdateJournalQueueOwnersEqual(entry.inputClaim.owner, receipt.owner)) {
|
|
3336
|
+
throw createJournalError("conflict", path, "input receipt lost its exact acquisition or phase");
|
|
3337
|
+
}
|
|
3338
|
+
return entry;
|
|
3339
|
+
};
|
|
3340
|
+
const getReadyInputRelease = (current, receipt, allowOffered = false) => {
|
|
3341
|
+
const entry = current.file.entries.find(candidate => candidate.updateId === receipt.updateId);
|
|
3342
|
+
if (!entry || entry.state !== "pending" || entry.preApprovalExcluded !== false) {
|
|
3343
|
+
throw createJournalError("conflict", path, "input is unavailable for ready-claim release");
|
|
3344
|
+
}
|
|
3345
|
+
if (!entry.inputClaim)
|
|
3346
|
+
return { entry, unclaimed: true };
|
|
3347
|
+
if (!areTelegramUpdateJournalQueueOwnersEqual(entry.inputClaim.owner, receipt.owner) ||
|
|
3348
|
+
entry.inputClaim.phase !== "ready" || (!allowOffered && entry.inputClaim.handoff !== undefined)) {
|
|
3349
|
+
throw createJournalError("conflict", path, "cannot release stale, running, or offered input authority");
|
|
3350
|
+
}
|
|
3351
|
+
return { entry, unclaimed: false };
|
|
3352
|
+
};
|
|
3353
|
+
const publishReadyInputRelease = (current, entry) => {
|
|
3354
|
+
const released = cloneEntry(entry);
|
|
3355
|
+
delete released.inputClaim;
|
|
3356
|
+
return publishMutation(current, current.file.entries.map(candidate => candidate.updateId === entry.updateId ? released : candidate), true, current.file.operatorDispositions, current.file.acceptedThroughUpdateId, onPublicationBoundary, entry.updateId);
|
|
3357
|
+
};
|
|
3358
|
+
const input = {
|
|
3359
|
+
read: journal.read,
|
|
3360
|
+
appendBatch: journal.appendBatch,
|
|
3361
|
+
listLegacyCustodyCandidates() {
|
|
3362
|
+
const sourceAccess = options.sourceAccess;
|
|
3363
|
+
const evidence = readTelegramUpdateJournalSource({
|
|
3364
|
+
directory: sourceAccess.directory, path, profile,
|
|
3365
|
+
botIdentity: expectedIdentity, limits: sourceAccess.limits,
|
|
3366
|
+
version: TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
|
|
3367
|
+
});
|
|
3368
|
+
return evidence.kind === "present"
|
|
3369
|
+
? listTelegramUpdateJournalLegacyCustodyCandidates(evidence.file) : [];
|
|
3370
|
+
},
|
|
3371
|
+
applyLegacyCustodyDisposition(value) {
|
|
3372
|
+
return runInputAdmission("legacy-custody-disposition", () => journal.applyLegacyCustodyDisposition(value));
|
|
3373
|
+
},
|
|
3374
|
+
completeQueued(receipts) {
|
|
3375
|
+
return runInputAdmission("complete-queued", () => journal.completeQueued(receipts));
|
|
3376
|
+
},
|
|
3377
|
+
discardQueued(value) {
|
|
3378
|
+
return runInputAdmission("discard-queued", () => journal.discardQueued(value));
|
|
3379
|
+
},
|
|
3380
|
+
recoverDeadQueueOwner(value) {
|
|
3381
|
+
return runInputAdmission("recover-queued", () => journal.recoverDeadQueueOwner(value));
|
|
3382
|
+
},
|
|
3383
|
+
offerQueuedHandoff(value) {
|
|
3384
|
+
return runInputAdmission("offer-queued-handoff", () => journal.offerQueuedHandoff(value));
|
|
3385
|
+
},
|
|
3386
|
+
acceptQueuedHandoff(value) {
|
|
3387
|
+
return runInputAdmission("accept-queued-handoff", () => journal.acceptQueuedHandoff(value));
|
|
3388
|
+
},
|
|
3389
|
+
cancelQueuedHandoff(value) {
|
|
3390
|
+
return runInputAdmission("cancel-queued-handoff", () => journal.cancelQueuedHandoff(value));
|
|
3391
|
+
},
|
|
3392
|
+
removeExcluded(updateIds) {
|
|
3393
|
+
const requestedIds = new Set();
|
|
3394
|
+
for (const updateId of updateIds) {
|
|
3395
|
+
if (!isSafeNonNegativeInteger(updateId)) {
|
|
3396
|
+
throw createJournalError("invalid", path, "received an invalid exclusion removal update id");
|
|
3397
|
+
}
|
|
3398
|
+
requestedIds.add(updateId);
|
|
3399
|
+
}
|
|
3400
|
+
return runInputMutation("remove-excluded", read => {
|
|
3401
|
+
const current = read();
|
|
3402
|
+
if (!current.exists || [...requestedIds].some(id => id > (current.file.acceptedThroughUpdateId ?? -1))) {
|
|
3403
|
+
throw createJournalError("conflict", path, "exclusion removal lacks retained source admission evidence");
|
|
3404
|
+
}
|
|
3405
|
+
const selected = current.file.entries.filter(entry => requestedIds.has(entry.updateId));
|
|
3406
|
+
if (selected.some(entry => entry.preApprovalExcluded !== true)) {
|
|
3407
|
+
throw createJournalError("conflict", path, "cannot remove input without immutable exclusion evidence");
|
|
3408
|
+
}
|
|
3409
|
+
// Strict v3 decoding already excludes raw claims and queued authority on vetoed entries.
|
|
3410
|
+
const removedUpdateIds = selected.map(entry => entry.updateId);
|
|
3411
|
+
const published = publishInputSettlement(current, current.file.entries.filter(entry => !requestedIds.has(entry.updateId)), removedUpdateIds.length > 0);
|
|
3412
|
+
return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
3413
|
+
});
|
|
3414
|
+
},
|
|
3415
|
+
releaseInput(value) {
|
|
3416
|
+
const receipt = normalizeInputReceipt(value);
|
|
3417
|
+
return runInputMutation("release", read => {
|
|
3418
|
+
const current = read();
|
|
3419
|
+
const release = getReadyInputRelease(current, receipt);
|
|
3420
|
+
if (release.unclaimed)
|
|
3421
|
+
return { released: false,
|
|
3422
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
3423
|
+
const published = publishReadyInputRelease(current, release.entry);
|
|
3424
|
+
return { released: true, entryCount: published.file.entries.length,
|
|
3425
|
+
serializedBytes: published.serializedBytes };
|
|
3426
|
+
});
|
|
3427
|
+
},
|
|
3428
|
+
recoverReadyInput(value) {
|
|
3429
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["receipt", "recoveryOwner"])) {
|
|
3430
|
+
throw createJournalError("invalid", path, "received invalid ready-input recovery authority");
|
|
3431
|
+
}
|
|
3432
|
+
const receipt = normalizeInputReceipt(value.receipt, false);
|
|
3433
|
+
const recoveryOwner = validateJournalQueueOwnerIdentity(value.recoveryOwner, path);
|
|
3434
|
+
assertInputProcess(recoveryOwner);
|
|
3435
|
+
return runInputMutation("recover-ready", read => {
|
|
3436
|
+
const current = read();
|
|
3437
|
+
const release = getReadyInputRelease(current, receipt, true);
|
|
3438
|
+
if (release.unclaimed)
|
|
3439
|
+
return { status: "unclaimed",
|
|
3440
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
3441
|
+
let liveness;
|
|
3442
|
+
try {
|
|
3443
|
+
liveness = getQueueProcessLiveness({ processId: receipt.owner.processId,
|
|
3444
|
+
processBirthId: receipt.owner.processBirthId });
|
|
3445
|
+
}
|
|
3446
|
+
catch (error) {
|
|
3447
|
+
throw createJournalError("io", path, "could not prove ready input owner liveness", error);
|
|
3448
|
+
}
|
|
3449
|
+
if (liveness !== "dead")
|
|
3450
|
+
return { status: liveness === "alive" ? "owner-alive" : "owner-unverifiable",
|
|
3451
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
3452
|
+
const published = publishReadyInputRelease(current, release.entry);
|
|
3453
|
+
return { status: "recovered", entryCount: published.file.entries.length,
|
|
3454
|
+
serializedBytes: published.serializedBytes };
|
|
3455
|
+
});
|
|
3456
|
+
},
|
|
3457
|
+
offerInputHandoff(value) {
|
|
3458
|
+
const { receipt, recipientOwner, handoffToken } = normalizeInputHandoffOffer(value);
|
|
3459
|
+
return runInputMutation("offer", read => {
|
|
3460
|
+
const context = currentInputContext();
|
|
3461
|
+
const current = read();
|
|
3462
|
+
const entry = ownedInput(current, receipt);
|
|
3463
|
+
const claim = entry.inputClaim;
|
|
3464
|
+
if (claim.phase !== "ready" || !inputOwnerMatchesIdentity(receipt.owner, context.owner) ||
|
|
3465
|
+
claim.recipientBindingKey !== context.recipientBindingKey) {
|
|
3466
|
+
throw createJournalError("conflict", path, "input handoff donor authority is stale or running");
|
|
3467
|
+
}
|
|
3468
|
+
if (claim.handoff) {
|
|
3469
|
+
if (!isDeepStrictEqual(claim.handoff.recipientOwner, recipientOwner)) {
|
|
3470
|
+
throw createJournalError("conflict", path, "input already has another handoff offer");
|
|
3471
|
+
}
|
|
3472
|
+
return { source: createInputSourceReference(receipt.updateId),
|
|
3473
|
+
handoff: cloneJournalQueueHandoff(claim.handoff),
|
|
3474
|
+
previousOwner: cloneJournalQueueOwner(receipt.owner), duplicate: true,
|
|
3475
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
3476
|
+
}
|
|
3477
|
+
const handoffId = createTelegramInputHandoffId({ handoffToken, journalBindingKey: bindingKey,
|
|
3478
|
+
updateId: receipt.updateId, donorOwner: receipt.owner, recipientOwner,
|
|
3479
|
+
recipientBindingKey: claim.recipientBindingKey });
|
|
3480
|
+
const offeredAtMs = getNowMs();
|
|
3481
|
+
if (!isSafeNonNegativeInteger(offeredAtMs)) {
|
|
3482
|
+
throw createJournalError("invalid", path, "received an invalid input handoff offer timestamp");
|
|
3483
|
+
}
|
|
3484
|
+
const handoff = { handoffId, offeredAtMs,
|
|
3485
|
+
recipientOwner: { ...recipientOwner } };
|
|
3486
|
+
const offeredClaim = { ...claim, handoff };
|
|
3487
|
+
const published = publishInputMutation(current, current.file.entries.map(candidate => candidate.updateId === receipt.updateId ? { ...candidate, inputClaim: offeredClaim } : candidate), receipt.updateId, context);
|
|
3488
|
+
return { source: createInputSourceReference(receipt.updateId),
|
|
3489
|
+
handoff: cloneJournalQueueHandoff(handoff),
|
|
3490
|
+
previousOwner: cloneJournalQueueOwner(receipt.owner), duplicate: false,
|
|
3491
|
+
entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
3492
|
+
});
|
|
3493
|
+
},
|
|
3494
|
+
acceptInputHandoff(value) {
|
|
3495
|
+
const { source, recipientOwner, handoffId } = normalizeInputHandoffAccept(value);
|
|
3496
|
+
return runInputMutation("accept", read => {
|
|
3497
|
+
const context = currentInputContext();
|
|
3498
|
+
const current = read();
|
|
3499
|
+
const entry = current.file.entries.find(candidate => candidate.updateId === source.updateId);
|
|
3500
|
+
const claim = entry?.inputClaim;
|
|
3501
|
+
if (!entry || entry.state !== "pending" || entry.preApprovalExcluded !== false || !claim ||
|
|
3502
|
+
!isDeepStrictEqual(context.owner, recipientOwner) ||
|
|
3503
|
+
context.recipientBindingKey !== claim.recipientBindingKey) {
|
|
3504
|
+
throw createJournalError("conflict", path, "input handoff recipient authority is unavailable or changed");
|
|
3505
|
+
}
|
|
3506
|
+
if (claim.owner.handoffId === handoffId && inputOwnerMatchesIdentity(claim.owner, recipientOwner)) {
|
|
3507
|
+
return { handoffId, receipt: createInputReceipt(source.updateId, claim.owner), duplicate: true,
|
|
3508
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
3509
|
+
}
|
|
3510
|
+
if (claim.phase !== "ready" || claim.handoff?.handoffId !== handoffId ||
|
|
3511
|
+
!isDeepStrictEqual(claim.handoff.recipientOwner, recipientOwner)) {
|
|
3512
|
+
throw createJournalError("conflict", path, "cannot accept stale or unauthenticated input handoff");
|
|
3513
|
+
}
|
|
3514
|
+
const acquiredAtMs = getNowMs();
|
|
3515
|
+
if (!isSafeNonNegativeInteger(acquiredAtMs)) {
|
|
3516
|
+
throw createJournalError("invalid", path, "received an invalid input handoff acquisition timestamp");
|
|
3517
|
+
}
|
|
3518
|
+
const previousOwner = cloneJournalQueueOwner(claim.owner);
|
|
3519
|
+
const owner = { ...recipientOwner,
|
|
3520
|
+
acquisitionId: randomUUID(), acquiredAtMs, handoffId };
|
|
3521
|
+
const acceptedClaim = structuredClone(claim);
|
|
3522
|
+
acceptedClaim.owner = owner;
|
|
3523
|
+
delete acceptedClaim.handoff;
|
|
3524
|
+
const publishedEntries = current.file.entries.map(candidate => candidate.updateId === source.updateId ? { ...candidate, inputClaim: acceptedClaim } : candidate);
|
|
3525
|
+
const published = publishInputMutation(current, publishedEntries, source.updateId, context);
|
|
3526
|
+
return { handoffId, previousOwner, receipt: createInputReceipt(source.updateId, owner), duplicate: false,
|
|
3527
|
+
entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
3528
|
+
});
|
|
3529
|
+
},
|
|
3530
|
+
cancelInputHandoff(value) {
|
|
3531
|
+
const { receipt, recipientOwner, handoffId } = normalizeInputHandoffCancel(value);
|
|
3532
|
+
return runInputMutation("cancel", read => {
|
|
3533
|
+
const context = currentInputContext();
|
|
3534
|
+
const current = read();
|
|
3535
|
+
const entry = ownedInput(current, receipt);
|
|
3536
|
+
const claim = entry.inputClaim;
|
|
3537
|
+
if (claim.phase !== "ready" || !inputOwnerMatchesIdentity(receipt.owner, context.owner) ||
|
|
3538
|
+
claim.recipientBindingKey !== context.recipientBindingKey) {
|
|
3539
|
+
throw createJournalError("conflict", path, "input handoff donor authority is stale or running");
|
|
3540
|
+
}
|
|
3541
|
+
if (!claim.handoff)
|
|
3542
|
+
return { handoffId, previousOwner: cloneJournalQueueOwner(receipt.owner),
|
|
3543
|
+
cancelled: false, entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
3544
|
+
if (claim.handoff.handoffId !== handoffId ||
|
|
3545
|
+
!isDeepStrictEqual(claim.handoff.recipientOwner, recipientOwner)) {
|
|
3546
|
+
throw createJournalError("conflict", path, "cannot cancel another input handoff offer");
|
|
3547
|
+
}
|
|
3548
|
+
const cancelledClaim = structuredClone(claim);
|
|
3549
|
+
delete cancelledClaim.handoff;
|
|
3550
|
+
const publishedEntries = current.file.entries.map(candidate => candidate.updateId === receipt.updateId ? { ...candidate, inputClaim: cancelledClaim } : candidate);
|
|
3551
|
+
const published = publishInputMutation(current, publishedEntries, receipt.updateId, context);
|
|
3552
|
+
return { handoffId, previousOwner: cloneJournalQueueOwner(receipt.owner), cancelled: true,
|
|
3553
|
+
entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
3554
|
+
});
|
|
3555
|
+
},
|
|
3556
|
+
queueInputs(value) {
|
|
3557
|
+
const { queueKind, receiptId, receipts } = normalizeInputQueue(value);
|
|
3558
|
+
return runInputMutation("queue", read => {
|
|
3559
|
+
const context = currentInputContext();
|
|
3560
|
+
if (receipts.some(receipt => !inputOwnerMatchesIdentity(receipt.owner, context.owner))) {
|
|
3561
|
+
throw createJournalError("conflict", path, "input queue transition belongs to another session");
|
|
3562
|
+
}
|
|
3563
|
+
const current = read();
|
|
3564
|
+
const receiptsById = new Map(receipts.map(receipt => [receipt.updateId, receipt]));
|
|
3565
|
+
const requestedIds = receipts.map(receipt => receipt.updateId);
|
|
3566
|
+
const existingReceiptEntries = current.file.entries.filter(entry => entry.queueReceiptId === receiptId);
|
|
3567
|
+
if (existingReceiptEntries.length > 0) {
|
|
3568
|
+
const queueOwner = existingReceiptEntries[0]?.queueOwner;
|
|
3569
|
+
if (existingReceiptEntries.length !== receipts.length || !queueOwner ||
|
|
3570
|
+
!inputOwnerMatchesIdentity(queueOwner, context.owner) ||
|
|
3571
|
+
existingReceiptEntries.some(entry => {
|
|
3572
|
+
const receipt = receiptsById.get(entry.updateId);
|
|
3573
|
+
return !receipt || entry.state !== "queued" || entry.queueKind !== queueKind ||
|
|
3574
|
+
entry.queueHandoff !== undefined || !entry.inputProvenance ||
|
|
3575
|
+
!areTelegramUpdateJournalQueueOwnersEqual(entry.inputProvenance.owner, receipt.owner) ||
|
|
3576
|
+
entry.inputProvenance.recipientBindingKey !== context.recipientBindingKey;
|
|
3577
|
+
})) {
|
|
3578
|
+
throw createJournalError("conflict", path, "input queue transition conflicts with queued authority");
|
|
3579
|
+
}
|
|
3580
|
+
return { queued: false, queueReceipt: { queueKind, receiptId,
|
|
3581
|
+
sourceUpdateIds: requestedIds, queueOwner: cloneJournalQueueOwner(queueOwner) },
|
|
3582
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
3583
|
+
}
|
|
3584
|
+
for (const receipt of receipts) {
|
|
3585
|
+
const entry = current.file.entries.find(candidate => candidate.updateId === receipt.updateId);
|
|
3586
|
+
if (!entry || entry.state !== "pending" || entry.preApprovalExcluded !== false ||
|
|
3587
|
+
!entry.inputClaim || entry.inputClaim.phase !== "running" || entry.inputClaim.handoff ||
|
|
3588
|
+
!areTelegramUpdateJournalQueueOwnersEqual(entry.inputClaim.owner, receipt.owner) ||
|
|
3589
|
+
entry.inputClaim.recipientBindingKey !== context.recipientBindingKey) {
|
|
3590
|
+
throw createJournalError("conflict", path, "input queue transition lost exact running authority");
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
const acquiredAtMs = getNowMs();
|
|
3594
|
+
if (!isSafeNonNegativeInteger(acquiredAtMs)) {
|
|
3595
|
+
throw createJournalError("invalid", path, "received an invalid input queue acquisition timestamp");
|
|
3596
|
+
}
|
|
3597
|
+
const queueOwner = { ...context.owner,
|
|
3598
|
+
acquisitionId: randomUUID(), acquiredAtMs };
|
|
3599
|
+
const queuedEntries = current.file.entries.map(entry => {
|
|
3600
|
+
const receipt = receiptsById.get(entry.updateId);
|
|
3601
|
+
if (!receipt)
|
|
3602
|
+
return entry;
|
|
3603
|
+
const claim = entry.inputClaim;
|
|
3604
|
+
return { updateId: entry.updateId, update: entry.update,
|
|
3605
|
+
admittedAtMs: entry.admittedAtMs, preApprovalExcluded: false, state: "queued",
|
|
3606
|
+
queueKind, queueReceiptId: receiptId, queueOwner: cloneJournalQueueOwner(queueOwner),
|
|
3607
|
+
inputProvenance: { owner: cloneJournalQueueOwner(claim.owner),
|
|
3608
|
+
recipientBindingKey: claim.recipientBindingKey,
|
|
3609
|
+
...(claim.executionUpdate
|
|
3610
|
+
? { executionUpdate: structuredClone(claim.executionUpdate) } : {}) } };
|
|
3611
|
+
});
|
|
3612
|
+
const published = publishInputMutation(current, queuedEntries, requestedIds, context);
|
|
3613
|
+
return { queued: true, queueReceipt: { queueKind, receiptId,
|
|
3614
|
+
sourceUpdateIds: requestedIds, queueOwner: cloneJournalQueueOwner(queueOwner) },
|
|
3615
|
+
entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
3616
|
+
});
|
|
3617
|
+
},
|
|
3618
|
+
acquireInput(value) {
|
|
3619
|
+
const updateId = value.updateId;
|
|
3620
|
+
const recipientBindingKey = value.recipientBindingKey;
|
|
3621
|
+
const executionUpdate = value.executionUpdate === undefined ? undefined : normalizeIncomingJournaledUpdate(value.executionUpdate, path);
|
|
3622
|
+
if (!isSafeNonNegativeInteger(updateId) ||
|
|
3623
|
+
!isBoundedString(recipientBindingKey, TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH) ||
|
|
3624
|
+
!recipientBindingKey.trim() || (executionUpdate && executionUpdate.update_id !== updateId)) {
|
|
3625
|
+
throw createJournalError("invalid", path, "received invalid input acquisition identity");
|
|
3626
|
+
}
|
|
3627
|
+
return runInputMutation("acquire", read => {
|
|
3628
|
+
const context = currentInputContext();
|
|
3629
|
+
const identity = context.owner;
|
|
3630
|
+
if (recipientBindingKey !== context.recipientBindingKey) {
|
|
3631
|
+
throw createJournalError("conflict", path, "input acquisition targets another execution binding");
|
|
3632
|
+
}
|
|
3633
|
+
const current = read();
|
|
3634
|
+
const entry = current.file.entries.find(candidate => candidate.updateId === updateId);
|
|
3635
|
+
if (!entry || entry.state !== "pending" || entry.preApprovalExcluded !== false) {
|
|
3636
|
+
throw createJournalError("conflict", path, "input is not eligible for acquisition");
|
|
3637
|
+
}
|
|
3638
|
+
const projected = executionUpdate ?? entry.update;
|
|
3639
|
+
const existing = entry.inputClaim;
|
|
3640
|
+
if (existing) {
|
|
3641
|
+
if (!isTelegramUpdateJournalQueueOwnerProcess(existing.owner, identity) ||
|
|
3642
|
+
existing.owner.sessionGeneration !== identity.sessionGeneration || existing.recipientBindingKey !== recipientBindingKey ||
|
|
3643
|
+
!isDeepStrictEqual(existing.executionUpdate ?? entry.update, projected)) {
|
|
3644
|
+
throw createJournalError("conflict", path, "input already has another owner or execution projection");
|
|
3645
|
+
}
|
|
3646
|
+
return { acquired: false, receipt: createInputReceipt(updateId, existing.owner) };
|
|
3647
|
+
}
|
|
3648
|
+
const claim = { phase: "ready", recipientBindingKey,
|
|
3649
|
+
owner: { ...identity, acquisitionId: randomUUID(), acquiredAtMs: getNowMs() },
|
|
3650
|
+
...(!isDeepStrictEqual(projected, entry.update) ? { executionUpdate: projected } : {}) };
|
|
3651
|
+
const claimedEntries = current.file.entries.map(candidate => candidate.updateId === updateId ? { ...candidate, inputClaim: claim } : candidate);
|
|
3652
|
+
const reservedEntry = { ...entry, inputClaim: createInputHeadroomClaim(entry, "ready") };
|
|
3653
|
+
const reservedEntries = replaceInputHeadroomEntry(current.file, updateId, reservedEntry);
|
|
3654
|
+
const actualBytes = Buffer.byteLength(serializeJournalFile({ ...current.file, entries: claimedEntries }));
|
|
3655
|
+
const reservedBytes = Buffer.byteLength(serializeJournalFile({ ...current.file, entries: reservedEntries })) +
|
|
3656
|
+
TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES;
|
|
3657
|
+
if (actualBytes > reservedBytes) {
|
|
3658
|
+
throw createJournalError("capacity", path, "execution projection exceeds reserved input headroom");
|
|
3659
|
+
}
|
|
3660
|
+
publishInputMutation(current, claimedEntries, updateId, context);
|
|
3661
|
+
return { acquired: true, receipt: createInputReceipt(updateId, claim.owner) };
|
|
3662
|
+
});
|
|
3663
|
+
},
|
|
3664
|
+
startInput(value) {
|
|
3665
|
+
const receipt = normalizeInputReceipt(value);
|
|
3666
|
+
return runInputMutation("start", read => {
|
|
3667
|
+
const context = currentInputContext();
|
|
3668
|
+
const current = read();
|
|
3669
|
+
const entry = ownedInput(current, receipt);
|
|
3670
|
+
const claim = entry.inputClaim;
|
|
3671
|
+
if (context.owner.sessionGeneration !== receipt.owner.sessionGeneration ||
|
|
3672
|
+
context.recipientBindingKey !== claim.recipientBindingKey) {
|
|
3673
|
+
throw createJournalError("conflict", path, "input belongs to another execution session or binding");
|
|
3674
|
+
}
|
|
3675
|
+
if (claim.phase === "running")
|
|
3676
|
+
return { started: false };
|
|
3677
|
+
if (claim.handoff)
|
|
3678
|
+
throw createJournalError("conflict", path, "input handoff freezes donor execution");
|
|
3679
|
+
publishInputMutation(current, current.file.entries.map(candidate => candidate.updateId === receipt.updateId
|
|
3680
|
+
? { ...candidate, inputClaim: { ...claim, phase: "running" } } : candidate), receipt.updateId, context);
|
|
3681
|
+
return { started: true, update: structuredClone(claim.executionUpdate ?? entry.update) };
|
|
3682
|
+
});
|
|
3683
|
+
},
|
|
3684
|
+
completeInput(value) {
|
|
3685
|
+
const receipt = normalizeInputReceipt(value);
|
|
3686
|
+
return runInputMutation("complete", read => {
|
|
3687
|
+
const current = read();
|
|
3688
|
+
const exists = current.file.entries.some(entry => entry.updateId === receipt.updateId);
|
|
3689
|
+
if (!exists && current.exists && receipt.updateId <= (current.file.acceptedThroughUpdateId ?? -1)) {
|
|
3690
|
+
return { removedUpdateIds: [], entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
3691
|
+
}
|
|
3692
|
+
const entry = ownedInput(current, receipt);
|
|
3693
|
+
if (entry.inputClaim.phase !== "running")
|
|
3694
|
+
throw createJournalError("conflict", path, "input has not started");
|
|
3695
|
+
const published = publishInputSettlement(current, current.file.entries.filter(candidate => candidate.updateId !== receipt.updateId), true);
|
|
3696
|
+
return { removedUpdateIds: [receipt.updateId], entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
3697
|
+
});
|
|
3698
|
+
},
|
|
3699
|
+
};
|
|
3700
|
+
return { journal, input };
|
|
3701
|
+
}
|