@llblab/pi-kit 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +4 -4
- package/node_modules/@llblab/pi-actors/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
- package/node_modules/@llblab/pi-actors/README.md +1 -1
- package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +3 -0
- package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +14 -1
- package/node_modules/@llblab/pi-actors/dist/lib/command-templates.js +45 -3
- package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +1 -1
- package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +16 -3
- package/node_modules/@llblab/pi-actors/dist/lib/observability.js +92 -7
- package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +0 -1
- package/node_modules/@llblab/pi-actors/dist/lib/pi.js +15 -24
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.d.ts +17 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.js +44 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +4 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +102 -4
- package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +58 -39
- package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +14 -6
- package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +1 -1
- package/node_modules/@llblab/pi-actors/docs/async-runs.md +1 -1
- package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +18 -23
- package/node_modules/@llblab/pi-actors/lib/async-runs.ts +18 -1
- package/node_modules/@llblab/pi-actors/lib/command-templates.ts +41 -3
- package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +1 -1
- package/node_modules/@llblab/pi-actors/lib/observability.ts +119 -5
- package/node_modules/@llblab/pi-actors/lib/pi.ts +15 -28
- package/node_modules/@llblab/pi-actors/lib/run-delivery-lineage.ts +68 -0
- package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +120 -4
- package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +69 -44
- package/node_modules/@llblab/pi-actors/lib/runtime.ts +17 -6
- package/node_modules/@llblab/pi-actors/package.json +1 -1
- package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +1 -1
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +33 -12
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +125 -2
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +87 -45
- package/node_modules/@llblab/pi-state-flow/README.md +203 -107
- package/node_modules/@llblab/pi-state-flow/docs/README.md +4 -0
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +188 -0
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +42 -0
- package/node_modules/@llblab/pi-state-flow/index.ts +164 -2
- package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +138 -0
- package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +273 -0
- package/node_modules/@llblab/pi-state-flow/lib/config.ts +48 -0
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +18 -5
- package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +268 -0
- package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +117 -0
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +562 -0
- package/node_modules/@llblab/pi-state-flow/lib/episode.ts +24 -12
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +606 -70
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +666 -0
- package/node_modules/@llblab/pi-state-flow/lib/history.ts +95 -0
- package/node_modules/@llblab/pi-state-flow/lib/json.ts +24 -0
- package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +141 -0
- package/node_modules/@llblab/pi-state-flow/lib/memory.ts +52 -0
- package/node_modules/@llblab/pi-state-flow/lib/migration.ts +88 -0
- package/node_modules/@llblab/pi-state-flow/lib/publication.ts +296 -0
- package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +23 -7
- package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +79 -0
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +264 -0
- package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -0
- package/node_modules/@llblab/pi-state-flow/lib/skills.ts +99 -7
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +305 -48
- package/node_modules/@llblab/pi-state-flow/lib/state.ts +74 -7
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +125 -6
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +196 -0
- package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +233 -0
- package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +70 -24
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +254 -29
- package/node_modules/@llblab/pi-state-flow/package.json +8 -2
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +128 -0
- package/node_modules/@llblab/pi-telegram/AGENTS.md +14 -9
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +23 -5
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
- package/node_modules/@llblab/pi-telegram/README.md +13 -9
- package/node_modules/@llblab/pi-telegram/docs/README.md +1 -0
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +227 -23
- package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +70 -19
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +6 -6
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +13 -6
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +3 -1
- package/node_modules/@llblab/pi-telegram/index.ts +4 -1418
- package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +6 -3
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +46 -1
- package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +32 -19
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +600 -135
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +962 -55
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +355 -26
- package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +718 -0
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +237 -11
- package/node_modules/@llblab/pi-telegram/lib/config.ts +242 -26
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +1851 -0
- package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +20 -2
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +2184 -126
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +44 -2
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +152 -13
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +97 -10
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -0
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +85 -17
- package/node_modules/@llblab/pi-telegram/lib/preview.ts +17 -0
- package/node_modules/@llblab/pi-telegram/lib/prompts.ts +6 -2
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +118 -26
- package/node_modules/@llblab/pi-telegram/lib/rendering.ts +4 -1
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +21 -2
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +344 -112
- package/node_modules/@llblab/pi-telegram/lib/setup.ts +44 -4
- package/node_modules/@llblab/pi-telegram/lib/status.ts +51 -4
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +308 -39
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +353 -22
- package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +664 -0
- package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +226 -0
- package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +118 -0
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +1686 -129
- package/node_modules/@llblab/pi-telegram/lib/turns.ts +7 -0
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +1319 -97
- package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +1643 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +968 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +84 -0
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
- package/node_modules/@llblab/pi-telegram/scripts/measure-bus.mjs +83 -0
- package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +101 -0
- package/node_modules/@llblab/{skills → pi-telegram/skills}/show-me/SKILL.md +28 -6
- package/node_modules/@llblab/pi-telegram/skills/show-me/references/telegram-surfaces.md +43 -0
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +1 -1
- package/node_modules/@llblab/skills/package.json +2 -3
- package/package.json +6 -5
- /package/node_modules/@llblab/pi-telegram/lib/{logs.ts → logging.ts} +0 -0
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
10
10
|
import {
|
|
11
11
|
chmodSync,
|
|
12
|
+
constants,
|
|
13
|
+
closeSync,
|
|
14
|
+
fstatSync,
|
|
15
|
+
lstatSync,
|
|
16
|
+
openSync,
|
|
17
|
+
opendirSync,
|
|
18
|
+
readSync,
|
|
19
|
+
realpathSync,
|
|
20
|
+
type BigIntStats,
|
|
12
21
|
mkdirSync,
|
|
13
22
|
readFileSync,
|
|
14
23
|
readdirSync,
|
|
@@ -16,8 +25,9 @@ import {
|
|
|
16
25
|
statSync,
|
|
17
26
|
unlinkSync,
|
|
18
27
|
writeFileSync,
|
|
28
|
+
type Dirent,
|
|
19
29
|
} from "node:fs";
|
|
20
|
-
import { basename, dirname, join } from "node:path";
|
|
30
|
+
import { basename, dirname, join, resolve, relative, isAbsolute, sep } from "node:path";
|
|
21
31
|
import { isDeepStrictEqual } from "node:util";
|
|
22
32
|
|
|
23
33
|
import {
|
|
@@ -28,18 +38,83 @@ import {
|
|
|
28
38
|
getTelegramProcessLiveness,
|
|
29
39
|
type TelegramProcessLiveness,
|
|
30
40
|
} from "./bus.ts";
|
|
31
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
getTelegramProfilePathSuffix,
|
|
43
|
+
TELEGRAM_DEFAULT_PROFILE_NAME,
|
|
44
|
+
} from "./paths.ts";
|
|
45
|
+
import {
|
|
46
|
+
runWithTelegramWorkspaceAdmissions,
|
|
47
|
+
type TelegramWorkspaceAdmissionLedger,
|
|
48
|
+
type TelegramWorkspaceAdmissionScope,
|
|
49
|
+
} from "./workspace-admission.ts";
|
|
32
50
|
|
|
33
51
|
export const TELEGRAM_UPDATE_JOURNAL_VERSION = 1 as const;
|
|
52
|
+
export const TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION = 2 as const;
|
|
53
|
+
export const TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION = 3 as const;
|
|
54
|
+
// Receipt and binding identities survive storage-schema upgrades.
|
|
55
|
+
const TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION = 1 as const;
|
|
34
56
|
export const TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES = 10_000;
|
|
35
57
|
export const TELEGRAM_UPDATE_JOURNAL_MAX_BYTES = 32 * 1024 * 1024;
|
|
36
58
|
export const TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH = 128;
|
|
37
59
|
export const TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH = 256;
|
|
60
|
+
export const TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH = 256;
|
|
61
|
+
const TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES = 4 * 1024;
|
|
62
|
+
const TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES = 64;
|
|
38
63
|
export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH = 128;
|
|
39
64
|
export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH = 32;
|
|
40
65
|
export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH = 256;
|
|
41
66
|
export const TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH = 128;
|
|
42
67
|
export const TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH = 512;
|
|
68
|
+
|
|
69
|
+
export interface TelegramFollowerJournalDiscovery {
|
|
70
|
+
paths: string[];
|
|
71
|
+
complete: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function escapeTelegramJournalPathPattern(value: string): string {
|
|
75
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read-only discovery for canonical follower journal snapshots and segment roots. */
|
|
79
|
+
export function discoverTelegramFollowerJournalPaths(input: {
|
|
80
|
+
directory: string;
|
|
81
|
+
profileName?: string;
|
|
82
|
+
}): TelegramFollowerJournalDiscovery {
|
|
83
|
+
const suffix = escapeTelegramJournalPathPattern(
|
|
84
|
+
getTelegramProfilePathSuffix(input.profileName),
|
|
85
|
+
);
|
|
86
|
+
const pattern = new RegExp(
|
|
87
|
+
`^(follower-inbox-[a-f0-9]{16}${suffix}\\.json)(?:\\.segments)?$`,
|
|
88
|
+
"u",
|
|
89
|
+
);
|
|
90
|
+
let entries: Dirent[];
|
|
91
|
+
try {
|
|
92
|
+
entries = readdirSync(input.directory, { withFileTypes: true });
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT"
|
|
95
|
+
? { paths: [], complete: true }
|
|
96
|
+
: { paths: [], complete: false };
|
|
97
|
+
}
|
|
98
|
+
const paths = new Set<string>();
|
|
99
|
+
let complete = true;
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
const match = pattern.exec(entry.name);
|
|
102
|
+
if (!match) continue;
|
|
103
|
+
const candidatePath = join(input.directory, entry.name);
|
|
104
|
+
const expectsDirectory = entry.name.endsWith(".segments");
|
|
105
|
+
try {
|
|
106
|
+
const stat = statSync(candidatePath);
|
|
107
|
+
if (expectsDirectory ? !stat.isDirectory() : !stat.isFile()) {
|
|
108
|
+
complete = false;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
paths.add(join(input.directory, match[1]!));
|
|
112
|
+
} catch {
|
|
113
|
+
complete = false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { paths: Array.from(paths).sort(), complete };
|
|
117
|
+
}
|
|
43
118
|
export const TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH = 256;
|
|
44
119
|
export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_COUNT = 256;
|
|
45
120
|
export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_BYTES = 4 * 1024 * 1024;
|
|
@@ -50,7 +125,9 @@ export type TelegramUpdateJournalErrorCode =
|
|
|
50
125
|
| "identity-mismatch"
|
|
51
126
|
| "invalid"
|
|
52
127
|
| "io"
|
|
53
|
-
| "unsupported-version"
|
|
128
|
+
| "unsupported-version"
|
|
129
|
+
| "pairing-evidence"
|
|
130
|
+
| "sender-denied";
|
|
54
131
|
|
|
55
132
|
export class TelegramUpdateJournalError extends Error {
|
|
56
133
|
readonly code: TelegramUpdateJournalErrorCode;
|
|
@@ -81,6 +158,89 @@ export interface TelegramUpdateJournalInput {
|
|
|
81
158
|
export type TelegramJournaledUpdate = TelegramUpdateJournalInput &
|
|
82
159
|
Record<string, unknown>;
|
|
83
160
|
|
|
161
|
+
const TELEGRAM_UPDATE_CHAT_CARRIERS = new Set([
|
|
162
|
+
"message",
|
|
163
|
+
"edited_message",
|
|
164
|
+
"channel_post",
|
|
165
|
+
"edited_channel_post",
|
|
166
|
+
"business_message",
|
|
167
|
+
"edited_business_message",
|
|
168
|
+
"message_reaction",
|
|
169
|
+
"message_reaction_count",
|
|
170
|
+
"my_chat_member",
|
|
171
|
+
"chat_member",
|
|
172
|
+
"chat_join_request",
|
|
173
|
+
"chat_boost",
|
|
174
|
+
"removed_chat_boost",
|
|
175
|
+
"deleted_business_messages",
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
function getUpdateCarrierScope(
|
|
179
|
+
value: unknown,
|
|
180
|
+
): TelegramWorkspaceAdmissionScope | undefined {
|
|
181
|
+
if (!isRecord(value) || !isRecord(value.chat)) return undefined;
|
|
182
|
+
const chatId = value.chat.id;
|
|
183
|
+
if (!Number.isSafeInteger(chatId) || chatId === 0) return undefined;
|
|
184
|
+
const threadId = value.message_thread_id;
|
|
185
|
+
if (threadId === undefined) return { kind: "chat", chatId: chatId as number };
|
|
186
|
+
if (!Number.isSafeInteger(threadId) || (threadId as number) <= 0) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
kind: "target",
|
|
191
|
+
target: { chatId: chatId as number, threadId: threadId as number },
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getUpdateAdmissionScope(
|
|
196
|
+
update: TelegramUpdateJournalInput & Record<string, unknown>,
|
|
197
|
+
): TelegramWorkspaceAdmissionScope {
|
|
198
|
+
const payloadKeys = Object.keys(update).filter((key) => key !== "update_id");
|
|
199
|
+
if (payloadKeys.length !== 1) return { kind: "profile" };
|
|
200
|
+
const payloadKey = payloadKeys[0];
|
|
201
|
+
if (TELEGRAM_UPDATE_CHAT_CARRIERS.has(payloadKey)) {
|
|
202
|
+
return getUpdateCarrierScope(update[payloadKey]) ?? { kind: "profile" };
|
|
203
|
+
}
|
|
204
|
+
if (payloadKey === "callback_query") {
|
|
205
|
+
const query = update.callback_query;
|
|
206
|
+
return isRecord(query)
|
|
207
|
+
? getUpdateCarrierScope(query.message) ?? { kind: "profile" }
|
|
208
|
+
: { kind: "profile" };
|
|
209
|
+
}
|
|
210
|
+
return { kind: "profile" };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getWorkspaceAdmissionScopeKey(
|
|
214
|
+
scope: TelegramWorkspaceAdmissionScope,
|
|
215
|
+
): string {
|
|
216
|
+
if (scope.kind === "profile") return "profile";
|
|
217
|
+
if (scope.kind === "chat") return `chat:${scope.chatId}`;
|
|
218
|
+
return `target:${scope.target.chatId}:${scope.target.threadId}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function getTelegramUpdateJournalAdmissionScopes(
|
|
222
|
+
updates: readonly (TelegramUpdateJournalInput & Record<string, unknown>)[],
|
|
223
|
+
): TelegramWorkspaceAdmissionScope[] {
|
|
224
|
+
if (updates.length === 0) return [{ kind: "profile" }];
|
|
225
|
+
const scopes = updates.map(getUpdateAdmissionScope);
|
|
226
|
+
if (scopes.some((scope) => scope.kind === "profile")) {
|
|
227
|
+
return [{ kind: "profile" }];
|
|
228
|
+
}
|
|
229
|
+
const chatIds = new Set(
|
|
230
|
+
scopes
|
|
231
|
+
.filter((scope): scope is { kind: "chat"; chatId: number } => scope.kind === "chat")
|
|
232
|
+
.map((scope) => scope.chatId),
|
|
233
|
+
);
|
|
234
|
+
const unique = new Map<string, TelegramWorkspaceAdmissionScope>();
|
|
235
|
+
for (const scope of scopes) {
|
|
236
|
+
if (scope.kind === "target" && chatIds.has(scope.target.chatId)) continue;
|
|
237
|
+
unique.set(getWorkspaceAdmissionScopeKey(scope), scope);
|
|
238
|
+
}
|
|
239
|
+
return Array.from(unique.entries())
|
|
240
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
241
|
+
.map(([, scope]) => scope);
|
|
242
|
+
}
|
|
243
|
+
|
|
84
244
|
export type TelegramUpdateJournalEntryState =
|
|
85
245
|
| "pending"
|
|
86
246
|
| "retry-wait"
|
|
@@ -116,6 +276,26 @@ export interface TelegramUpdateJournalQueueHandoff {
|
|
|
116
276
|
recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
117
277
|
}
|
|
118
278
|
|
|
279
|
+
export type TelegramUpdateJournalInputHandoff = TelegramUpdateJournalQueueHandoff;
|
|
280
|
+
|
|
281
|
+
/** V3 evidence; decoding it grants neither live execution nor Pi queue authority. */
|
|
282
|
+
export interface TelegramUpdateJournalInputClaim {
|
|
283
|
+
phase: "ready" | "running";
|
|
284
|
+
owner: TelegramUpdateJournalQueueOwner;
|
|
285
|
+
recipientBindingKey: string;
|
|
286
|
+
/** Present freezes ready donor execution until exact acceptance or cancellation. */
|
|
287
|
+
handoff?: TelegramUpdateJournalInputHandoff;
|
|
288
|
+
/** Absent means the original update; present preserves an exact routed projection. */
|
|
289
|
+
executionUpdate?: TelegramJournaledUpdate;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Immutable transition evidence, never concurrent raw-input execution authority. */
|
|
293
|
+
export interface TelegramUpdateJournalInputProvenance {
|
|
294
|
+
owner: TelegramUpdateJournalQueueOwner;
|
|
295
|
+
recipientBindingKey: string;
|
|
296
|
+
executionUpdate?: TelegramJournaledUpdate;
|
|
297
|
+
}
|
|
298
|
+
|
|
119
299
|
export interface TelegramUpdateJournalFailure {
|
|
120
300
|
attemptCount: number;
|
|
121
301
|
failedAtMs: number;
|
|
@@ -127,7 +307,83 @@ export type TelegramUpdateJournalOperatorDispositionAction =
|
|
|
127
307
|
| "retry"
|
|
128
308
|
| "discard";
|
|
129
309
|
|
|
130
|
-
export interface
|
|
310
|
+
export interface TelegramUpdateJournalLegacyCustodyEvidence {
|
|
311
|
+
updateId: number;
|
|
312
|
+
state: "retry-wait" | "failed";
|
|
313
|
+
attemptCount: number;
|
|
314
|
+
failedAtMs: number;
|
|
315
|
+
failureClass: string;
|
|
316
|
+
summary: string;
|
|
317
|
+
nextRetryAtMs?: number;
|
|
318
|
+
terminalAtMs?: number;
|
|
319
|
+
terminalReason?: string;
|
|
320
|
+
terminalFailureId?: string;
|
|
321
|
+
evidenceSha256: string;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface TelegramUpdateJournalLegacyCustodyCandidate {
|
|
325
|
+
updateId: number;
|
|
326
|
+
state: "retry-wait" | "failed";
|
|
327
|
+
attemptCount: number;
|
|
328
|
+
failureClass: string;
|
|
329
|
+
evidenceSha256: string;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function listTelegramUpdateJournalLegacyCustodyCandidates(
|
|
333
|
+
snapshot: Pick<TelegramUpdateJournalSnapshot, "entries">,
|
|
334
|
+
): TelegramUpdateJournalLegacyCustodyCandidate[] {
|
|
335
|
+
return snapshot.entries.flatMap(entry => {
|
|
336
|
+
const evidence = createTelegramUpdateJournalLegacyCustodyEvidence(entry);
|
|
337
|
+
return evidence ? [{ updateId: evidence.updateId, state: evidence.state,
|
|
338
|
+
attemptCount: evidence.attemptCount, failureClass: evidence.failureClass,
|
|
339
|
+
evidenceSha256: evidence.evidenceSha256 }] : [];
|
|
340
|
+
}).sort((left, right) => left.updateId - right.updateId);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export interface TelegramUpdateJournalLegacyCustodyDispositionAuthority {
|
|
344
|
+
version: 1;
|
|
345
|
+
dispositionId: string;
|
|
346
|
+
updateId: number;
|
|
347
|
+
evidenceSha256: string;
|
|
348
|
+
action: "requeue-v3" | "discard";
|
|
349
|
+
operatorAuthorityId: string;
|
|
350
|
+
authorizedAtMs: number;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function createTelegramUpdateJournalLegacyCustodyEvidence(
|
|
354
|
+
entry: TelegramUpdateJournalEntry,
|
|
355
|
+
): TelegramUpdateJournalLegacyCustodyEvidence | undefined {
|
|
356
|
+
if ((entry.state !== "retry-wait" && entry.state !== "failed") || !entry.failure ||
|
|
357
|
+
entry.inputClaim || entry.inputProvenance) return undefined;
|
|
358
|
+
const base = { updateId: entry.updateId, state: entry.state,
|
|
359
|
+
attemptCount: entry.failure.attemptCount, failedAtMs: entry.failure.failedAtMs,
|
|
360
|
+
failureClass: entry.failure.failureClass, summary: entry.failure.summary,
|
|
361
|
+
...(entry.nextRetryAtMs === undefined ? {} : { nextRetryAtMs: entry.nextRetryAtMs }),
|
|
362
|
+
...(entry.terminalAtMs === undefined ? {} : { terminalAtMs: entry.terminalAtMs }),
|
|
363
|
+
...(entry.terminalReason === undefined ? {} : { terminalReason: entry.terminalReason }),
|
|
364
|
+
...(entry.terminalFailureId === undefined ? {} : { terminalFailureId: entry.terminalFailureId }) };
|
|
365
|
+
return { ...base, evidenceSha256: createHash("sha256").update(JSON.stringify(base)).digest("hex") };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function normalizeTelegramUpdateJournalLegacyCustodyDispositionAuthority(
|
|
369
|
+
value: unknown,
|
|
370
|
+
expected: TelegramUpdateJournalLegacyCustodyEvidence,
|
|
371
|
+
): TelegramUpdateJournalLegacyCustodyDispositionAuthority | undefined {
|
|
372
|
+
if (!isRecord(value)) return undefined;
|
|
373
|
+
const keys = ["version", "dispositionId", "updateId", "evidenceSha256", "action",
|
|
374
|
+
"operatorAuthorityId", "authorizedAtMs"];
|
|
375
|
+
if (Object.keys(value).some(key => !keys.includes(key)) || value.version !== 1 ||
|
|
376
|
+
!isBoundedString(value.dispositionId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
377
|
+
value.updateId !== expected.updateId || value.evidenceSha256 !== expected.evidenceSha256 ||
|
|
378
|
+
(value.action !== "requeue-v3" && value.action !== "discard") ||
|
|
379
|
+
!isBoundedString(value.operatorAuthorityId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
380
|
+
!isSafeNonNegativeInteger(value.authorizedAtMs)) return undefined;
|
|
381
|
+
return { version: 1, dispositionId: value.dispositionId, updateId: expected.updateId,
|
|
382
|
+
evidenceSha256: expected.evidenceSha256, action: value.action,
|
|
383
|
+
operatorAuthorityId: value.operatorAuthorityId, authorizedAtMs: value.authorizedAtMs };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export interface TelegramUpdateJournalTerminalOperatorDisposition {
|
|
131
387
|
failureId: string;
|
|
132
388
|
updateId: number;
|
|
133
389
|
action: TelegramUpdateJournalOperatorDispositionAction;
|
|
@@ -138,15 +394,34 @@ export interface TelegramUpdateJournalOperatorDisposition {
|
|
|
138
394
|
terminalReason: string;
|
|
139
395
|
}
|
|
140
396
|
|
|
397
|
+
export interface TelegramUpdateJournalLegacyCustodyDisposition {
|
|
398
|
+
dispositionKind: "legacy-custody";
|
|
399
|
+
failureId: string;
|
|
400
|
+
updateId: number;
|
|
401
|
+
action: "requeue-v3" | "discard";
|
|
402
|
+
committedAtMs: number;
|
|
403
|
+
evidenceSha256: string;
|
|
404
|
+
operatorAuthorityId: string;
|
|
405
|
+
authorizedAtMs: number;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export type TelegramUpdateJournalOperatorDisposition =
|
|
409
|
+
| TelegramUpdateJournalTerminalOperatorDisposition
|
|
410
|
+
| TelegramUpdateJournalLegacyCustodyDisposition;
|
|
411
|
+
|
|
141
412
|
export interface TelegramUpdateJournalEntry {
|
|
142
413
|
updateId: number;
|
|
143
414
|
update: TelegramJournaledUpdate;
|
|
415
|
+
/** Mandatory in v2/v3; immutable veto, never sender authorization. Absent only in legacy v1. */
|
|
416
|
+
preApprovalExcluded?: boolean;
|
|
144
417
|
admittedAtMs: number;
|
|
145
418
|
state: TelegramUpdateJournalEntryState;
|
|
146
419
|
queueKind?: TelegramUpdateJournalQueueKind;
|
|
147
420
|
queueReceiptId?: string;
|
|
148
421
|
queueOwner?: TelegramUpdateJournalQueueOwner;
|
|
149
422
|
queueHandoff?: TelegramUpdateJournalQueueHandoff;
|
|
423
|
+
inputClaim?: TelegramUpdateJournalInputClaim;
|
|
424
|
+
inputProvenance?: TelegramUpdateJournalInputProvenance;
|
|
150
425
|
failure?: TelegramUpdateJournalFailure;
|
|
151
426
|
nextRetryAtMs?: number;
|
|
152
427
|
terminalAtMs?: number;
|
|
@@ -155,7 +430,7 @@ export interface TelegramUpdateJournalEntry {
|
|
|
155
430
|
}
|
|
156
431
|
|
|
157
432
|
export interface TelegramUpdateJournalFile {
|
|
158
|
-
version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION;
|
|
433
|
+
version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION | typeof TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION | typeof TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
|
|
159
434
|
revision?: number;
|
|
160
435
|
acceptedThroughUpdateId?: number;
|
|
161
436
|
profile: string;
|
|
@@ -171,6 +446,8 @@ export interface TelegramUpdateJournalSnapshot
|
|
|
171
446
|
}
|
|
172
447
|
|
|
173
448
|
export interface TelegramUpdateJournalAppendResult {
|
|
449
|
+
/** Retained batch sources without the immutable veto; not sender authorization. */
|
|
450
|
+
nonExcludedUpdateIds: number[];
|
|
174
451
|
addedUpdateIds: number[];
|
|
175
452
|
duplicateUpdateIds: number[];
|
|
176
453
|
entryCount: number;
|
|
@@ -302,8 +579,15 @@ export interface TelegramUpdateJournalOperatorDispositionInput {
|
|
|
302
579
|
action: TelegramUpdateJournalOperatorDispositionAction;
|
|
303
580
|
}
|
|
304
581
|
|
|
582
|
+
export interface TelegramUpdateJournalLegacyCustodyDispositionResult {
|
|
583
|
+
disposition: TelegramUpdateJournalLegacyCustodyDisposition;
|
|
584
|
+
duplicate: boolean;
|
|
585
|
+
entryCount: number;
|
|
586
|
+
serializedBytes: number;
|
|
587
|
+
}
|
|
588
|
+
|
|
305
589
|
export interface TelegramUpdateJournalOperatorDispositionResult {
|
|
306
|
-
disposition:
|
|
590
|
+
disposition: TelegramUpdateJournalTerminalOperatorDisposition;
|
|
307
591
|
duplicate: boolean;
|
|
308
592
|
entryCount: number;
|
|
309
593
|
serializedBytes: number;
|
|
@@ -324,6 +608,9 @@ export interface TelegramUpdateJournalStore {
|
|
|
324
608
|
applyOperatorDisposition(
|
|
325
609
|
input: TelegramUpdateJournalOperatorDispositionInput,
|
|
326
610
|
): TelegramUpdateJournalOperatorDispositionResult;
|
|
611
|
+
applyLegacyCustodyDisposition(
|
|
612
|
+
authority: TelegramUpdateJournalLegacyCustodyDispositionAuthority,
|
|
613
|
+
): TelegramUpdateJournalLegacyCustodyDispositionResult;
|
|
327
614
|
offerQueuedHandoff(
|
|
328
615
|
input: TelegramUpdateJournalQueueHandoffInput,
|
|
329
616
|
): TelegramUpdateJournalQueueHandoffOfferResult;
|
|
@@ -371,20 +658,183 @@ export interface TelegramUpdateJournalStoreOptions {
|
|
|
371
658
|
getQueueProcessLiveness?: (
|
|
372
659
|
owner: TelegramUpdateJournalQueueProcessIdentity,
|
|
373
660
|
) => TelegramProcessLiveness;
|
|
661
|
+
/** Optional outer writer fence. Must authorize before source serialization/journal locking and must not perform journal I/O. */
|
|
662
|
+
withWriterAdmission?: <T>(operation: () => T) => T;
|
|
663
|
+
/** Explicit operator authority for quarantined legacy retry/failure disposition. Production omission disables mutation. */
|
|
664
|
+
authorizeLegacyCustodyDisposition?: (
|
|
665
|
+
authority: TelegramUpdateJournalLegacyCustodyDispositionAuthority,
|
|
666
|
+
) => boolean;
|
|
667
|
+
/** Lock-only synchronous serialization, not source authorization or schema selection. Use the same config resource as admission hooks. */
|
|
668
|
+
withSourceSerialization?: <T>(operation: () => T) => T;
|
|
669
|
+
/** Opt-in strict consumption. Caller binds all gates to the same config resource and excludes other writers. */
|
|
670
|
+
sourceAccess?: {
|
|
671
|
+
directory: string;
|
|
672
|
+
limits: { maxFiles: number; maxBytes: number; maxEntries: number; maxWork: number };
|
|
673
|
+
};
|
|
674
|
+
/** Opt-in v2 for cursor-ordered polling admission only. Must hold config authority through synchronous publish. */
|
|
675
|
+
withPairingAdmission?: <T>(publish: (preApprovalExcluded: boolean) => T) => T;
|
|
676
|
+
/** Paired-only v1 gate over canonical inputs. Runs inside Workspace admission, before journal locking. */
|
|
677
|
+
withPairedAdmission?: <T>(
|
|
678
|
+
updates: readonly TelegramJournaledUpdate[],
|
|
679
|
+
publish: () => T,
|
|
680
|
+
) => { admitted: false } | { admitted: true; value: T };
|
|
681
|
+
workspaceAdmission?: Pick<
|
|
682
|
+
TelegramWorkspaceAdmissionLedger,
|
|
683
|
+
"acquireAdmission" | "releaseAdmission"
|
|
684
|
+
>;
|
|
374
685
|
onPublicationBoundary?: (
|
|
375
686
|
boundary: TelegramUpdateJournalPublicationBoundary,
|
|
376
687
|
publicationPath: string,
|
|
377
688
|
) => void;
|
|
378
689
|
}
|
|
379
690
|
|
|
691
|
+
export interface TelegramInputJournalSourceReference {
|
|
692
|
+
journalBindingKey: string;
|
|
693
|
+
tokenSha256: string;
|
|
694
|
+
updateId: number;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export interface TelegramInputJournalReceipt extends TelegramInputJournalSourceReference {
|
|
698
|
+
owner: TelegramUpdateJournalQueueOwner;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export interface TelegramInputJournalReleaseResult {
|
|
702
|
+
released: boolean;
|
|
703
|
+
entryCount: number;
|
|
704
|
+
serializedBytes: number;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export interface TelegramInputJournalRecoveryInput {
|
|
708
|
+
receipt: TelegramInputJournalReceipt;
|
|
709
|
+
recoveryOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export interface TelegramInputJournalRecoveryResult {
|
|
713
|
+
status: "owner-alive" | "owner-unverifiable" | "unclaimed" | "recovered";
|
|
714
|
+
entryCount: number;
|
|
715
|
+
serializedBytes: number;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export interface TelegramInputJournalHandoffOfferInput {
|
|
719
|
+
receipt: TelegramInputJournalReceipt;
|
|
720
|
+
recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
721
|
+
handoffToken: string;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export interface TelegramInputJournalHandoffAcceptInput {
|
|
725
|
+
source: TelegramInputJournalSourceReference;
|
|
726
|
+
recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
727
|
+
handoffId: string;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export interface TelegramInputJournalHandoffCancelInput {
|
|
731
|
+
receipt: TelegramInputJournalReceipt;
|
|
732
|
+
recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
733
|
+
handoffId: string;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export interface TelegramInputJournalQueueInput {
|
|
737
|
+
queueKind: TelegramUpdateJournalQueueKind;
|
|
738
|
+
receiptId: string;
|
|
739
|
+
receipts: readonly TelegramInputJournalReceipt[];
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export interface TelegramInputJournalQueueResult {
|
|
743
|
+
queued: boolean;
|
|
744
|
+
queueReceipt: TelegramUpdateJournalQueuedCompletion;
|
|
745
|
+
entryCount: number;
|
|
746
|
+
serializedBytes: number;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export interface TelegramInputJournalHandoffOfferResult {
|
|
750
|
+
source: TelegramInputJournalSourceReference;
|
|
751
|
+
handoff: TelegramUpdateJournalInputHandoff;
|
|
752
|
+
previousOwner: TelegramUpdateJournalQueueOwner;
|
|
753
|
+
duplicate: boolean;
|
|
754
|
+
entryCount: number;
|
|
755
|
+
serializedBytes: number;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
export interface TelegramInputJournalHandoffAcceptResult {
|
|
759
|
+
handoffId: string;
|
|
760
|
+
previousOwner?: TelegramUpdateJournalQueueOwner;
|
|
761
|
+
receipt: TelegramInputJournalReceipt;
|
|
762
|
+
duplicate: boolean;
|
|
763
|
+
entryCount: number;
|
|
764
|
+
serializedBytes: number;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export interface TelegramInputJournalHandoffCancelResult {
|
|
768
|
+
handoffId: string;
|
|
769
|
+
previousOwner: TelegramUpdateJournalQueueOwner;
|
|
770
|
+
cancelled: boolean;
|
|
771
|
+
entryCount: number;
|
|
772
|
+
serializedBytes: number;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
export interface TelegramInputJournalStore {
|
|
776
|
+
read: TelegramUpdateJournalStore["read"];
|
|
777
|
+
appendBatch: TelegramUpdateJournalStore["appendBatch"];
|
|
778
|
+
listLegacyCustodyCandidates(): TelegramUpdateJournalLegacyCustodyCandidate[];
|
|
779
|
+
applyLegacyCustodyDisposition: TelegramUpdateJournalStore["applyLegacyCustodyDisposition"];
|
|
780
|
+
/** Removes vetoed input only; an absent ID inside the retained cursor is a no-op, not completion evidence. */
|
|
781
|
+
removeExcluded(updateIds: readonly number[]): TelegramUpdateJournalRemoveResult;
|
|
782
|
+
acquireInput(input: {
|
|
783
|
+
updateId: number;
|
|
784
|
+
recipientBindingKey: string;
|
|
785
|
+
executionUpdate?: TelegramJournaledUpdate;
|
|
786
|
+
}): { acquired: boolean; receipt: TelegramInputJournalReceipt };
|
|
787
|
+
/** Returns this process's exact ready authority to the same unclaimed input. */
|
|
788
|
+
releaseInput(receipt: TelegramInputJournalReceipt): TelegramInputJournalReleaseResult;
|
|
789
|
+
/** Releases exact ready authority only after process-birth liveness proves its owner dead. */
|
|
790
|
+
recoverReadyInput(input: TelegramInputJournalRecoveryInput): TelegramInputJournalRecoveryResult;
|
|
791
|
+
/** Freezes exact ready donor authority around one persisted recipient offer. */
|
|
792
|
+
offerInputHandoff(input: TelegramInputJournalHandoffOfferInput): TelegramInputJournalHandoffOfferResult;
|
|
793
|
+
/** Replaces the offered donor with one exact ready recipient acquisition. */
|
|
794
|
+
acceptInputHandoff(input: TelegramInputJournalHandoffAcceptInput): TelegramInputJournalHandoffAcceptResult;
|
|
795
|
+
/** Unfreezes only the exact unaccepted donor offer. */
|
|
796
|
+
cancelInputHandoff(input: TelegramInputJournalHandoffCancelInput): TelegramInputJournalHandoffCancelResult;
|
|
797
|
+
/** Atomically replaces exact running raw acquisitions with one grouped Pi queue receipt. */
|
|
798
|
+
queueInputs(input: TelegramInputJournalQueueInput): TelegramInputJournalQueueResult;
|
|
799
|
+
completeQueued: TelegramUpdateJournalStore["completeQueued"];
|
|
800
|
+
discardQueued: TelegramUpdateJournalStore["discardQueued"];
|
|
801
|
+
recoverDeadQueueOwner: TelegramUpdateJournalStore["recoverDeadQueueOwner"];
|
|
802
|
+
offerQueuedHandoff: TelegramUpdateJournalStore["offerQueuedHandoff"];
|
|
803
|
+
acceptQueuedHandoff: TelegramUpdateJournalStore["acceptQueuedHandoff"];
|
|
804
|
+
cancelQueuedHandoff: TelegramUpdateJournalStore["cancelQueuedHandoff"];
|
|
805
|
+
/** One durable start transition, not proof that an external effect ran. Publication errors may be commit-unknown. */
|
|
806
|
+
startInput(receipt: TelegramInputJournalReceipt):
|
|
807
|
+
{ started: false } | { started: true; update: TelegramJournaledUpdate };
|
|
808
|
+
completeInput(receipt: TelegramInputJournalReceipt): TelegramUpdateJournalRemoveResult;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export interface TelegramInputJournalContext {
|
|
812
|
+
owner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
813
|
+
recipientBindingKey: string;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
export type TelegramInputJournalStoreOptions = Omit<TelegramUpdateJournalStoreOptions, "withPairedAdmission"> &
|
|
817
|
+
Required<Pick<TelegramUpdateJournalStoreOptions,
|
|
818
|
+
"sourceAccess" | "withSourceSerialization" | "withPairingAdmission" | "queueRuntimeIdentity">> & {
|
|
819
|
+
/** Bound originating profile/token/session/recipient context, not transport role; undefined revokes acquisition/start/transfer. */
|
|
820
|
+
getInputContext: () => TelegramInputJournalContext | undefined;
|
|
821
|
+
};
|
|
822
|
+
|
|
380
823
|
interface ReadTelegramUpdateJournalResult {
|
|
381
824
|
file: TelegramUpdateJournalFile;
|
|
382
825
|
exists: boolean;
|
|
383
826
|
serializedBytes: number;
|
|
827
|
+
source?: JournalSourceAcquisition;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
interface JournalSourceAcquisition {
|
|
831
|
+
evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily>;
|
|
832
|
+
snapshotRevision: number;
|
|
833
|
+
segments: { name: string; bytes: number; work: number; botId?: number }[];
|
|
384
834
|
}
|
|
385
835
|
|
|
386
836
|
export interface TelegramUpdateJournalSegment {
|
|
387
|
-
version:
|
|
837
|
+
version: TelegramUpdateJournalFile["version"];
|
|
388
838
|
revision: number;
|
|
389
839
|
previousRevision: number;
|
|
390
840
|
acceptedThroughUpdateId?: number;
|
|
@@ -653,9 +1103,34 @@ function createTelegramUpdateQueueHandoffId(input: {
|
|
|
653
1103
|
.slice(0, 32)}`;
|
|
654
1104
|
}
|
|
655
1105
|
|
|
656
|
-
function
|
|
1106
|
+
function isTelegramInputHandoffId(value: unknown): value is string {
|
|
1107
|
+
return isBoundedString(value, TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH) &&
|
|
1108
|
+
/^input-handoff-[a-f0-9]{32}$/u.test(value);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function createTelegramInputHandoffId(input: {
|
|
1112
|
+
handoffToken: string;
|
|
1113
|
+
journalBindingKey: string;
|
|
1114
|
+
updateId: number;
|
|
1115
|
+
donorOwner: TelegramUpdateJournalQueueOwner;
|
|
1116
|
+
recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
1117
|
+
recipientBindingKey: string;
|
|
1118
|
+
}): string {
|
|
1119
|
+
return `input-handoff-${createHash("sha256").update(JSON.stringify({
|
|
1120
|
+
version: 1,
|
|
1121
|
+
token: input.handoffToken,
|
|
1122
|
+
source: input.journalBindingKey,
|
|
1123
|
+
updateId: input.updateId,
|
|
1124
|
+
donorOwner: input.donorOwner,
|
|
1125
|
+
recipientOwner: input.recipientOwner,
|
|
1126
|
+
recipientBindingKey: input.recipientBindingKey,
|
|
1127
|
+
})).digest("hex").slice(0, 32)}`;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function validateJournalHandoff(
|
|
657
1131
|
value: unknown,
|
|
658
1132
|
path: string,
|
|
1133
|
+
kind: "queue" | "input",
|
|
659
1134
|
): TelegramUpdateJournalQueueHandoff {
|
|
660
1135
|
if (
|
|
661
1136
|
!isRecord(value) ||
|
|
@@ -669,7 +1144,7 @@ function validateJournalQueueHandoff(
|
|
|
669
1144
|
throw createJournalError(
|
|
670
1145
|
"invalid",
|
|
671
1146
|
path,
|
|
672
|
-
|
|
1147
|
+
`contains invalid ${kind} handoff metadata`,
|
|
673
1148
|
);
|
|
674
1149
|
}
|
|
675
1150
|
return {
|
|
@@ -746,59 +1221,107 @@ function validateJournalOperatorDisposition(
|
|
|
746
1221
|
value: unknown,
|
|
747
1222
|
path: string,
|
|
748
1223
|
): TelegramUpdateJournalOperatorDisposition {
|
|
749
|
-
if (
|
|
750
|
-
!
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
"action"
|
|
755
|
-
|
|
756
|
-
"
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
"
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
value.
|
|
763
|
-
|
|
764
|
-
|
|
1224
|
+
if (isRecord(value) && value.dispositionKind === "legacy-custody") {
|
|
1225
|
+
if (!hasOnlyKeys(value, ["dispositionKind", "failureId", "updateId", "action",
|
|
1226
|
+
"committedAtMs", "evidenceSha256", "operatorAuthorityId", "authorizedAtMs"]) ||
|
|
1227
|
+
!isBoundedString(value.failureId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
1228
|
+
!isSafeNonNegativeInteger(value.updateId) ||
|
|
1229
|
+
(value.action !== "requeue-v3" && value.action !== "discard") ||
|
|
1230
|
+
!isSafeNonNegativeInteger(value.committedAtMs) ||
|
|
1231
|
+
typeof value.evidenceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.evidenceSha256) ||
|
|
1232
|
+
!isBoundedString(value.operatorAuthorityId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
1233
|
+
!isSafeNonNegativeInteger(value.authorizedAtMs) || value.committedAtMs < value.authorizedAtMs)
|
|
1234
|
+
throw createJournalError("invalid", path,
|
|
1235
|
+
"contains invalid legacy custody disposition metadata");
|
|
1236
|
+
return { dispositionKind: "legacy-custody", failureId: value.failureId,
|
|
1237
|
+
updateId: value.updateId, action: value.action, committedAtMs: value.committedAtMs,
|
|
1238
|
+
evidenceSha256: value.evidenceSha256, operatorAuthorityId: value.operatorAuthorityId,
|
|
1239
|
+
authorizedAtMs: value.authorizedAtMs };
|
|
1240
|
+
}
|
|
1241
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["failureId", "updateId", "action",
|
|
1242
|
+
"committedAtMs", "attemptCount", "failureClass", "terminalAtMs", "terminalReason"]) ||
|
|
1243
|
+
!isBoundedString(value.failureId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
765
1244
|
!isSafeNonNegativeInteger(value.updateId) ||
|
|
766
1245
|
(value.action !== "retry" && value.action !== "discard") ||
|
|
767
|
-
!isSafeNonNegativeInteger(value.committedAtMs) ||
|
|
768
|
-
!
|
|
769
|
-
!
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
value.
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1246
|
+
!isSafeNonNegativeInteger(value.committedAtMs) || !isSafePositiveInteger(value.attemptCount) ||
|
|
1247
|
+
!isBoundedString(value.failureClass, TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH) ||
|
|
1248
|
+
!isSafeNonNegativeInteger(value.terminalAtMs) || value.committedAtMs < value.terminalAtMs ||
|
|
1249
|
+
!isBoundedString(value.terminalReason, TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH))
|
|
1250
|
+
throw createJournalError("invalid", path, "contains invalid operator disposition metadata");
|
|
1251
|
+
return { failureId: value.failureId, updateId: value.updateId, action: value.action,
|
|
1252
|
+
committedAtMs: value.committedAtMs, attemptCount: value.attemptCount,
|
|
1253
|
+
failureClass: value.failureClass, terminalAtMs: value.terminalAtMs,
|
|
1254
|
+
terminalReason: value.terminalReason };
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function journalRequiresExclusion(version: TelegramUpdateJournalFile["version"]): boolean {
|
|
1258
|
+
return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION ||
|
|
1259
|
+
version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function validateJournalInputClaim(
|
|
1263
|
+
value: unknown, path: string, updateId: number,
|
|
1264
|
+
): TelegramUpdateJournalInputClaim {
|
|
1265
|
+
if (!isRecord(value) ||
|
|
1266
|
+
!hasOnlyKeys(value, ["phase", "owner", "recipientBindingKey", "handoff", "executionUpdate"]) ||
|
|
1267
|
+
(value.phase !== "ready" && value.phase !== "running") ||
|
|
1268
|
+
!isBoundedString(value.recipientBindingKey, TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH) ||
|
|
1269
|
+
!value.recipientBindingKey.trim()) {
|
|
1270
|
+
throw createJournalError("invalid", path, "contains invalid input claim metadata");
|
|
1271
|
+
}
|
|
1272
|
+
const owner = validateJournalQueueOwner(value.owner, path);
|
|
1273
|
+
const handoff = value.handoff === undefined ? undefined : validateJournalHandoff(value.handoff, path, "input");
|
|
1274
|
+
if ((owner.handoffId !== undefined && !isTelegramInputHandoffId(owner.handoffId)) ||
|
|
1275
|
+
(handoff && (!isTelegramInputHandoffId(handoff.handoffId) || value.phase !== "ready" ||
|
|
1276
|
+
isTelegramUpdateJournalQueueOwnerProcess(owner, handoff.recipientOwner)))) {
|
|
1277
|
+
throw createJournalError("invalid", path, "contains conflicting input handoff metadata");
|
|
1278
|
+
}
|
|
1279
|
+
const executionUpdate = value.executionUpdate === undefined
|
|
1280
|
+
? undefined : validateJournaledUpdate(value.executionUpdate, path);
|
|
1281
|
+
if (executionUpdate && executionUpdate.update_id !== updateId) {
|
|
1282
|
+
throw createJournalError("invalid", path, "contains an input claim/update id mismatch");
|
|
785
1283
|
}
|
|
786
1284
|
return {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
failureClass: value.failureClass,
|
|
793
|
-
terminalAtMs: value.terminalAtMs,
|
|
794
|
-
terminalReason: value.terminalReason,
|
|
1285
|
+
phase: value.phase,
|
|
1286
|
+
owner,
|
|
1287
|
+
recipientBindingKey: value.recipientBindingKey,
|
|
1288
|
+
...(handoff ? { handoff } : {}),
|
|
1289
|
+
...(executionUpdate ? { executionUpdate } : {}),
|
|
795
1290
|
};
|
|
796
1291
|
}
|
|
797
1292
|
|
|
1293
|
+
function validateJournalInputProvenance(
|
|
1294
|
+
value: unknown, path: string, updateId: number,
|
|
1295
|
+
): TelegramUpdateJournalInputProvenance {
|
|
1296
|
+
if (!isRecord(value) ||
|
|
1297
|
+
!hasOnlyKeys(value, ["owner", "recipientBindingKey", "executionUpdate"]) ||
|
|
1298
|
+
!isBoundedString(value.recipientBindingKey, TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH) ||
|
|
1299
|
+
!value.recipientBindingKey.trim()) {
|
|
1300
|
+
throw createJournalError("invalid", path, "contains invalid input queue provenance");
|
|
1301
|
+
}
|
|
1302
|
+
const owner = validateJournalQueueOwner(value.owner, path);
|
|
1303
|
+
if (owner.handoffId !== undefined && !isTelegramInputHandoffId(owner.handoffId)) {
|
|
1304
|
+
throw createJournalError("invalid", path, "contains invalid input queue provenance");
|
|
1305
|
+
}
|
|
1306
|
+
const executionUpdate = value.executionUpdate === undefined
|
|
1307
|
+
? undefined : validateJournaledUpdate(value.executionUpdate, path);
|
|
1308
|
+
if (executionUpdate && executionUpdate.update_id !== updateId) {
|
|
1309
|
+
throw createJournalError("invalid", path, "contains an input provenance/update id mismatch");
|
|
1310
|
+
}
|
|
1311
|
+
return { owner, recipientBindingKey: value.recipientBindingKey,
|
|
1312
|
+
...(executionUpdate ? { executionUpdate } : {}) };
|
|
1313
|
+
}
|
|
1314
|
+
|
|
798
1315
|
function validateJournalEntry(
|
|
799
1316
|
value: unknown,
|
|
800
1317
|
path: string,
|
|
1318
|
+
version: TelegramUpdateJournalFile["version"],
|
|
801
1319
|
): TelegramUpdateJournalEntry {
|
|
1320
|
+
if (journalRequiresExclusion(version) &&
|
|
1321
|
+
(!isRecord(value) || typeof value.preApprovalExcluded !== "boolean" ||
|
|
1322
|
+
(value.preApprovalExcluded && value.state === "queued"))) {
|
|
1323
|
+
throw createJournalError("pairing-evidence", path, "contains missing, malformed, or queued exclusion evidence");
|
|
1324
|
+
}
|
|
802
1325
|
if (
|
|
803
1326
|
!isRecord(value) ||
|
|
804
1327
|
!hasOnlyKeys(value, [
|
|
@@ -815,6 +1338,9 @@ function validateJournalEntry(
|
|
|
815
1338
|
"terminalAtMs",
|
|
816
1339
|
"terminalReason",
|
|
817
1340
|
"terminalFailureId",
|
|
1341
|
+
...(journalRequiresExclusion(version) ? ["preApprovalExcluded"] : []),
|
|
1342
|
+
...(version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION
|
|
1343
|
+
? ["inputClaim", "inputProvenance"] : []),
|
|
818
1344
|
]) ||
|
|
819
1345
|
!isSafeNonNegativeInteger(value.updateId) ||
|
|
820
1346
|
!isSafeNonNegativeInteger(value.admittedAtMs) ||
|
|
@@ -872,7 +1398,7 @@ function validateJournalEntry(
|
|
|
872
1398
|
: undefined;
|
|
873
1399
|
const queueHandoff =
|
|
874
1400
|
value.state === "queued" && value.queueHandoff !== undefined
|
|
875
|
-
?
|
|
1401
|
+
? validateJournalHandoff(value.queueHandoff, path, "queue")
|
|
876
1402
|
: undefined;
|
|
877
1403
|
if (queueHandoff && !queueOwner) {
|
|
878
1404
|
throw createJournalError(
|
|
@@ -881,6 +1407,19 @@ function validateJournalEntry(
|
|
|
881
1407
|
"contains a queue handoff without donor authority",
|
|
882
1408
|
);
|
|
883
1409
|
}
|
|
1410
|
+
if (value.preApprovalExcluded === true &&
|
|
1411
|
+
(value.inputClaim !== undefined || value.inputProvenance !== undefined)) {
|
|
1412
|
+
throw createJournalError("pairing-evidence", path, "contains claimed exclusion evidence");
|
|
1413
|
+
}
|
|
1414
|
+
const inputClaim = value.inputClaim === undefined
|
|
1415
|
+
? undefined : validateJournalInputClaim(value.inputClaim, path, value.updateId);
|
|
1416
|
+
const inputProvenance = value.inputProvenance === undefined
|
|
1417
|
+
? undefined : validateJournalInputProvenance(value.inputProvenance, path, value.updateId);
|
|
1418
|
+
if ((inputClaim && (value.state === "queued" ||
|
|
1419
|
+
(inputClaim.phase === "running" && value.state !== "pending"))) ||
|
|
1420
|
+
(inputProvenance && (value.state !== "queued" || inputClaim))) {
|
|
1421
|
+
throw createJournalError("invalid", path, "contains conflicting input claim state");
|
|
1422
|
+
}
|
|
884
1423
|
let failure: TelegramUpdateJournalFailure | undefined;
|
|
885
1424
|
if (value.state === "retry-wait" || value.state === "failed") {
|
|
886
1425
|
if (hasQueueMetadata) {
|
|
@@ -930,6 +1469,10 @@ function validateJournalEntry(
|
|
|
930
1469
|
return {
|
|
931
1470
|
updateId: value.updateId,
|
|
932
1471
|
update,
|
|
1472
|
+
...(journalRequiresExclusion(version)
|
|
1473
|
+
? { preApprovalExcluded: value.preApprovalExcluded as boolean } : {}),
|
|
1474
|
+
...(inputClaim ? { inputClaim } : {}),
|
|
1475
|
+
...(inputProvenance ? { inputProvenance } : {}),
|
|
933
1476
|
admittedAtMs: value.admittedAtMs,
|
|
934
1477
|
state: value.state,
|
|
935
1478
|
...(queueKind === "prompt" || queueKind === "control"
|
|
@@ -969,10 +1512,26 @@ function validateJournalEntry(
|
|
|
969
1512
|
};
|
|
970
1513
|
}
|
|
971
1514
|
|
|
1515
|
+
function assertSupportedJournalVersion(
|
|
1516
|
+
value: unknown, path: string,
|
|
1517
|
+
version: TelegramUpdateJournalFile["version"] = TELEGRAM_UPDATE_JOURNAL_VERSION,
|
|
1518
|
+
): void {
|
|
1519
|
+
if (isRecord(value) && Number.isSafeInteger(value.version) &&
|
|
1520
|
+
value.version !== version) {
|
|
1521
|
+
throw createJournalError("unsupported-version", path, `uses unsupported version ${String(value.version)}`);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
972
1525
|
function parseJournalFile(
|
|
973
1526
|
value: unknown,
|
|
974
1527
|
path: string,
|
|
1528
|
+
version: TelegramUpdateJournalFile["version"] = TELEGRAM_UPDATE_JOURNAL_VERSION,
|
|
975
1529
|
): TelegramUpdateJournalFile {
|
|
1530
|
+
assertSupportedJournalVersion(value, path, version);
|
|
1531
|
+
if (journalRequiresExclusion(version) &&
|
|
1532
|
+
(!isRecord(value) || !isSafeNonNegativeInteger(value.acceptedThroughUpdateId))) {
|
|
1533
|
+
throw createJournalError("pairing-evidence", path, "is missing its exclusion-mode admission cursor");
|
|
1534
|
+
}
|
|
976
1535
|
if (
|
|
977
1536
|
!isRecord(value) ||
|
|
978
1537
|
!hasOnlyKeys(value, [
|
|
@@ -996,15 +1555,8 @@ function parseJournalFile(
|
|
|
996
1555
|
) {
|
|
997
1556
|
throw createJournalError("invalid", path, "has a malformed schema");
|
|
998
1557
|
}
|
|
999
|
-
if (value.version !== TELEGRAM_UPDATE_JOURNAL_VERSION) {
|
|
1000
|
-
throw createJournalError(
|
|
1001
|
-
"unsupported-version",
|
|
1002
|
-
path,
|
|
1003
|
-
`uses unsupported version ${String(value.version)}`,
|
|
1004
|
-
);
|
|
1005
|
-
}
|
|
1006
1558
|
const entries = value.entries.map((entry) =>
|
|
1007
|
-
validateJournalEntry(entry, path),
|
|
1559
|
+
validateJournalEntry(entry, path, version),
|
|
1008
1560
|
);
|
|
1009
1561
|
for (let index = 1; index < entries.length; index += 1) {
|
|
1010
1562
|
if (entries[index]!.updateId <= entries[index - 1]!.updateId) {
|
|
@@ -1109,7 +1661,7 @@ function parseJournalFile(
|
|
|
1109
1661
|
dispositionFailureIds.add(disposition.failureId);
|
|
1110
1662
|
}
|
|
1111
1663
|
return {
|
|
1112
|
-
version
|
|
1664
|
+
version,
|
|
1113
1665
|
...(value.revision !== undefined
|
|
1114
1666
|
? { revision: value.revision as number }
|
|
1115
1667
|
: {}),
|
|
@@ -1126,7 +1678,13 @@ function parseJournalFile(
|
|
|
1126
1678
|
function parseJournalSegment(
|
|
1127
1679
|
value: unknown,
|
|
1128
1680
|
path: string,
|
|
1681
|
+
version: TelegramUpdateJournalFile["version"] = TELEGRAM_UPDATE_JOURNAL_VERSION,
|
|
1129
1682
|
): TelegramUpdateJournalSegment {
|
|
1683
|
+
assertSupportedJournalVersion(value, path, version);
|
|
1684
|
+
if (journalRequiresExclusion(version) &&
|
|
1685
|
+
(!isRecord(value) || !isSafeNonNegativeInteger(value.acceptedThroughUpdateId))) {
|
|
1686
|
+
throw createJournalError("pairing-evidence", path, "is missing its exclusion-mode admission cursor");
|
|
1687
|
+
}
|
|
1130
1688
|
if (
|
|
1131
1689
|
!isRecord(value) ||
|
|
1132
1690
|
!hasOnlyKeys(value, [
|
|
@@ -1140,7 +1698,7 @@ function parseJournalSegment(
|
|
|
1140
1698
|
"removedUpdateIds",
|
|
1141
1699
|
"operatorDispositions",
|
|
1142
1700
|
]) ||
|
|
1143
|
-
value.version !==
|
|
1701
|
+
value.version !== version ||
|
|
1144
1702
|
!isSafePositiveInteger(value.revision) ||
|
|
1145
1703
|
!isSafeNonNegativeInteger(value.previousRevision) ||
|
|
1146
1704
|
(value.acceptedThroughUpdateId !== undefined &&
|
|
@@ -1154,7 +1712,7 @@ function parseJournalSegment(
|
|
|
1154
1712
|
throw createJournalError("invalid", path, "has a malformed segment schema");
|
|
1155
1713
|
}
|
|
1156
1714
|
const upsertedEntries = value.upsertedEntries.map((entry) =>
|
|
1157
|
-
validateJournalEntry(entry, path),
|
|
1715
|
+
validateJournalEntry(entry, path, version),
|
|
1158
1716
|
);
|
|
1159
1717
|
const upsertedIds = new Set<number>();
|
|
1160
1718
|
for (const entry of upsertedEntries) {
|
|
@@ -1167,7 +1725,7 @@ function parseJournalSegment(
|
|
|
1167
1725
|
upsertedEntries.length > 0 &&
|
|
1168
1726
|
value.acceptedThroughUpdateId !== undefined &&
|
|
1169
1727
|
value.acceptedThroughUpdateId <
|
|
1170
|
-
|
|
1728
|
+
upsertedEntries.reduce((maximum, entry) => Math.max(maximum, entry.updateId), 0)
|
|
1171
1729
|
) {
|
|
1172
1730
|
throw createJournalError(
|
|
1173
1731
|
"invalid",
|
|
@@ -1194,7 +1752,7 @@ function parseJournalSegment(
|
|
|
1194
1752
|
validateJournalOperatorDisposition(disposition, path),
|
|
1195
1753
|
);
|
|
1196
1754
|
return {
|
|
1197
|
-
version
|
|
1755
|
+
version,
|
|
1198
1756
|
revision: value.revision,
|
|
1199
1757
|
previousRevision: value.previousRevision,
|
|
1200
1758
|
...(value.acceptedThroughUpdateId !== undefined
|
|
@@ -1210,10 +1768,365 @@ function parseJournalSegment(
|
|
|
1210
1768
|
};
|
|
1211
1769
|
}
|
|
1212
1770
|
|
|
1771
|
+
/**
|
|
1772
|
+
* Isolated evidence only: caller must serialize/quiesce writers before inspection
|
|
1773
|
+
* and consumption. Metadata checks detect observable changes, not hostile same-user
|
|
1774
|
+
* swaps or whole-profile completeness. No locks, recovery, or publication occurs.
|
|
1775
|
+
* maxFiles counts snapshot + every enumerated segment entry (one overflow witness).
|
|
1776
|
+
* maxBytes aggregates all retained bytes. maxEntries bounds each raw collection and
|
|
1777
|
+
* reconstructed collection; maxWork charges each raw/revalidated collection element.
|
|
1778
|
+
* JSON allocation is bounded by maxBytes before decoding; collections before codecs.
|
|
1779
|
+
*/
|
|
1780
|
+
export function inspectTelegramInputCustodySourceStatus(
|
|
1781
|
+
input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0],
|
|
1782
|
+
): "absent" | "v3" | "legacy" | "unsupported" | "ambiguous" {
|
|
1783
|
+
try {
|
|
1784
|
+
const evidence = inspectTelegramUpdateJournalFamily(input);
|
|
1785
|
+
if (evidence.kind === "absent") return "absent";
|
|
1786
|
+
if (evidence.file.version === 3) return "v3";
|
|
1787
|
+
if (evidence.file.version === 1 || evidence.file.version === 2) return "legacy";
|
|
1788
|
+
return "unsupported";
|
|
1789
|
+
} catch {
|
|
1790
|
+
return "ambiguous";
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
export function inspectTelegramUpdateJournalFamily(input: {
|
|
1795
|
+
directory: string;
|
|
1796
|
+
path: string;
|
|
1797
|
+
profile: string;
|
|
1798
|
+
botIdentity: TelegramUpdateJournalBotIdentity;
|
|
1799
|
+
limits: { maxFiles: number; maxBytes: number; maxEntries: number; maxWork: number };
|
|
1800
|
+
}): { kind: "absent" } | {
|
|
1801
|
+
kind: "present";
|
|
1802
|
+
file: TelegramUpdateJournalFile;
|
|
1803
|
+
/** Validation constraint includes the caller's input, not only observed IDs. */
|
|
1804
|
+
knownBotId?: number;
|
|
1805
|
+
accounting: { files: number; bytes: number; work: number };
|
|
1806
|
+
} {
|
|
1807
|
+
return acquireTelegramUpdateJournalFamily(input).evidence;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/**
|
|
1811
|
+
* Read-only source evidence for cooperating writers serialized by the caller through
|
|
1812
|
+
* consumption; never readiness, recovery, or permission to publish. Ancestors retain
|
|
1813
|
+
* canonical directory type and endpoint dev/ino/mode/uid/gid, tolerating sibling churn.
|
|
1814
|
+
* This deliberately loses ancestor size/nlink/mtime/ctime witnesses: no transient
|
|
1815
|
+
* namespace/permission/ACL continuity, inode-ABA resistance, or hostile-same-user
|
|
1816
|
+
* protection. Manual relocation/restore/security manipulation is outside the protocol.
|
|
1817
|
+
* Files and the segment directory retain full inspection checks and bounded census.
|
|
1818
|
+
* Identity is never enriched; accounting is physical inspection work, not store capacity.
|
|
1819
|
+
*/
|
|
1820
|
+
export function readTelegramUpdateJournalSource(
|
|
1821
|
+
input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0] & { version: TelegramUpdateJournalFile["version"] },
|
|
1822
|
+
): ReturnType<typeof inspectTelegramUpdateJournalFamily> {
|
|
1823
|
+
const version = input.version;
|
|
1824
|
+
if (version !== 1 && version !== 2 && version !== 3) {
|
|
1825
|
+
throw createJournalError("unsupported-version", input.path, "requires a selected source version");
|
|
1826
|
+
}
|
|
1827
|
+
return acquireTelegramUpdateJournalFamily({
|
|
1828
|
+
...input,
|
|
1829
|
+
botIdentity: { ...input.botIdentity },
|
|
1830
|
+
limits: { ...input.limits },
|
|
1831
|
+
}, version).evidence;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function acquireTelegramUpdateJournalFamily(
|
|
1835
|
+
input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0],
|
|
1836
|
+
selectedVersion?: TelegramUpdateJournalFile["version"],
|
|
1837
|
+
): JournalSourceAcquisition {
|
|
1838
|
+
const { path, limits } = input;
|
|
1839
|
+
const acquiredSegments: JournalSourceAcquisition["segments"] = [];
|
|
1840
|
+
let snapshotRevision = 0;
|
|
1841
|
+
const fail = (message: string): never => { throw createJournalError("invalid", path, message); };
|
|
1842
|
+
const capacity = (): never => { throw createJournalError("capacity", path, "exceeds inspection resource limits"); };
|
|
1843
|
+
for (const key of ["maxFiles", "maxBytes", "maxEntries", "maxWork"] as const) {
|
|
1844
|
+
if (!isSafePositiveInteger(limits[key])) fail("requires positive safe-integer limits");
|
|
1845
|
+
}
|
|
1846
|
+
const expected = validateBotIdentity(input.botIdentity, path);
|
|
1847
|
+
if (!isNonEmptyString(input.profile)) fail("requires an exact profile");
|
|
1848
|
+
const anchor = input.directory;
|
|
1849
|
+
const contained = relative(anchor, path);
|
|
1850
|
+
if (!isAbsolute(anchor) || resolve(anchor) !== anchor || !isAbsolute(path) ||
|
|
1851
|
+
resolve(path) !== path || !contained || contained === ".." ||
|
|
1852
|
+
contained.startsWith(`..${sep}`) || isAbsolute(contained)) fail("escapes its canonical directory anchor");
|
|
1853
|
+
if (!constants.O_NOFOLLOW || !constants.O_NONBLOCK) fail("platform lacks no-follow nonblocking open evidence");
|
|
1854
|
+
let files = 0;
|
|
1855
|
+
let bytes = 0;
|
|
1856
|
+
let work = 0;
|
|
1857
|
+
const observed = new Map<string, BigIntStats | undefined>();
|
|
1858
|
+
const ancestors = new Set<string>();
|
|
1859
|
+
const sameAncestor = (a: BigIntStats, b: BigIntStats) =>
|
|
1860
|
+
b.isDirectory() && !b.isSymbolicLink() && a.dev === b.dev && a.ino === b.ino &&
|
|
1861
|
+
a.mode === b.mode && a.uid === b.uid && a.gid === b.gid;
|
|
1862
|
+
const same = (a: BigIntStats, b: BigIntStats) =>
|
|
1863
|
+
a.dev === b.dev && a.ino === b.ino && a.mode === b.mode &&
|
|
1864
|
+
a.nlink === b.nlink && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
|
|
1865
|
+
const status = (target: string): BigIntStats | undefined => {
|
|
1866
|
+
try { return lstatSync(target, { bigint: true }); }
|
|
1867
|
+
catch (error) {
|
|
1868
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
1869
|
+
throw error;
|
|
1870
|
+
}
|
|
1871
|
+
};
|
|
1872
|
+
const observe = (target: string, directory: boolean, optional = false) => {
|
|
1873
|
+
const value = status(target);
|
|
1874
|
+
if (!value && !optional) fail("has a missing path component");
|
|
1875
|
+
if (value && (value.isSymbolicLink() || !(directory ? value.isDirectory() : value.isFile()))) fail("has a linked or unexpected file type");
|
|
1876
|
+
observed.set(target, value);
|
|
1877
|
+
return value;
|
|
1878
|
+
};
|
|
1879
|
+
const charge = (value: unknown) => {
|
|
1880
|
+
if (!isRecord(value)) return;
|
|
1881
|
+
for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions"]) {
|
|
1882
|
+
const collection = value[key];
|
|
1883
|
+
if (!Array.isArray(collection)) continue;
|
|
1884
|
+
if (collection.length > limits.maxEntries || collection.length > limits.maxWork - work) capacity();
|
|
1885
|
+
work += collection.length;
|
|
1886
|
+
}
|
|
1887
|
+
};
|
|
1888
|
+
const read = (target: string, before: BigIntStats): unknown => {
|
|
1889
|
+
if (before.size > BigInt(limits.maxBytes - bytes)) capacity();
|
|
1890
|
+
const fd = openSync(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
1891
|
+
try {
|
|
1892
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
1893
|
+
if (!opened.isFile() || !same(before, opened)) fail("changed before handle acquisition");
|
|
1894
|
+
const length = Number(opened.size);
|
|
1895
|
+
const buffer = Buffer.alloc(length);
|
|
1896
|
+
let offset = 0;
|
|
1897
|
+
while (offset < length) {
|
|
1898
|
+
const count = readSync(fd, buffer, offset, length - offset, offset);
|
|
1899
|
+
if (!count) fail("disappeared or shrank during read");
|
|
1900
|
+
offset += count;
|
|
1901
|
+
}
|
|
1902
|
+
if (readSync(fd, Buffer.alloc(1), 0, 1, length) !== 0 ||
|
|
1903
|
+
!same(opened, fstatSync(fd, { bigint: true }))) fail("changed during handle read");
|
|
1904
|
+
const after = status(target);
|
|
1905
|
+
if (!after || !same(opened, after)) fail("changed path during read");
|
|
1906
|
+
bytes += length;
|
|
1907
|
+
const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer));
|
|
1908
|
+
charge(value);
|
|
1909
|
+
return value;
|
|
1910
|
+
} finally { closeSync(fd); }
|
|
1911
|
+
};
|
|
1912
|
+
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
1913
|
+
const enumerate = (): string[] => {
|
|
1914
|
+
const names: string[] = [];
|
|
1915
|
+
const directory = opendirSync(segmentDirectory, { bufferSize: 1 });
|
|
1916
|
+
try {
|
|
1917
|
+
for (;;) {
|
|
1918
|
+
const entry = directory.readSync();
|
|
1919
|
+
if (!entry) break;
|
|
1920
|
+
if (names.length >= limits.maxFiles - files) capacity();
|
|
1921
|
+
if (!/^\d{16}\.json$/u.test(entry.name) || !entry.isFile() || entry.isSymbolicLink()) fail("has an unexpected segment entry");
|
|
1922
|
+
names.push(entry.name);
|
|
1923
|
+
}
|
|
1924
|
+
} finally { directory.closeSync(); }
|
|
1925
|
+
return names.sort();
|
|
1926
|
+
};
|
|
1927
|
+
try {
|
|
1928
|
+
observe(anchor, true);
|
|
1929
|
+
ancestors.add(anchor);
|
|
1930
|
+
if (realpathSync(anchor) !== anchor) fail("requires a canonical directory anchor");
|
|
1931
|
+
let parent = anchor;
|
|
1932
|
+
for (const component of relative(anchor, dirname(path)).split(sep).filter(Boolean)) {
|
|
1933
|
+
parent = join(parent, component);
|
|
1934
|
+
observe(parent, true);
|
|
1935
|
+
ancestors.add(parent);
|
|
1936
|
+
}
|
|
1937
|
+
const snapshot = observe(path, false, true);
|
|
1938
|
+
const segments = observe(segmentDirectory, true, true);
|
|
1939
|
+
if (!snapshot && segments) fail("retains segments without a snapshot");
|
|
1940
|
+
files = snapshot ? 1 : 0;
|
|
1941
|
+
const names = segments ? enumerate() : [];
|
|
1942
|
+
let file: TelegramUpdateJournalFile | undefined;
|
|
1943
|
+
let knownBotId = expected.botId;
|
|
1944
|
+
const identity = (value: { profile: string; botIdentity: TelegramUpdateJournalBotIdentity }) => {
|
|
1945
|
+
if (value.profile !== input.profile || value.botIdentity.tokenSha256 !== expected.tokenSha256 ||
|
|
1946
|
+
(knownBotId !== undefined && value.botIdentity.botId !== undefined && knownBotId !== value.botIdentity.botId)) {
|
|
1947
|
+
throw createJournalError("identity-mismatch", path, "belongs to another exact journal identity");
|
|
1948
|
+
}
|
|
1949
|
+
knownBotId ??= value.botIdentity.botId;
|
|
1950
|
+
};
|
|
1951
|
+
if (snapshot) {
|
|
1952
|
+
const raw = read(path, snapshot);
|
|
1953
|
+
if (!isRecord(raw) || (raw.version !== 1 && raw.version !== 2 && raw.version !== 3)) {
|
|
1954
|
+
throw createJournalError("unsupported-version", path, "has an unsupported snapshot version");
|
|
1955
|
+
}
|
|
1956
|
+
file = parseJournalFile(raw, path, raw.version);
|
|
1957
|
+
snapshotRevision = file.revision ?? 0;
|
|
1958
|
+
identity(file);
|
|
1959
|
+
for (const name of names) {
|
|
1960
|
+
const target = join(segmentDirectory, name);
|
|
1961
|
+
const metadata = observe(target, false)!;
|
|
1962
|
+
const previousWork = work;
|
|
1963
|
+
const segment = parseJournalSegment(read(target, metadata), target, file.version);
|
|
1964
|
+
acquiredSegments.push({ name, bytes: Number(metadata.size), work: work - previousWork,
|
|
1965
|
+
...(segment.botIdentity.botId !== undefined ? { botId: segment.botIdentity.botId } : {}) });
|
|
1966
|
+
files += 1;
|
|
1967
|
+
identity(segment);
|
|
1968
|
+
if (segment.revision !== Number(name.slice(0, 16)) || segment.previousRevision !== segment.revision - 1) fail("has an invalid intrinsic segment revision");
|
|
1969
|
+
const dispositionIds = new Set<string>();
|
|
1970
|
+
for (const disposition of segment.operatorDispositions ?? []) {
|
|
1971
|
+
if (dispositionIds.has(disposition.failureId)) fail("has duplicate segment disposition failure ids");
|
|
1972
|
+
dispositionIds.add(disposition.failureId);
|
|
1973
|
+
}
|
|
1974
|
+
const revision = file.revision ?? 0;
|
|
1975
|
+
if (segment.revision <= revision) continue;
|
|
1976
|
+
if (segment.revision !== revision + 1 || segment.previousRevision !== revision) fail("has a newer revision gap");
|
|
1977
|
+
if (segment.acceptedThroughUpdateId !== undefined && file.acceptedThroughUpdateId !== undefined &&
|
|
1978
|
+
segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId) fail("regresses the admission cursor");
|
|
1979
|
+
const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
|
|
1980
|
+
for (const id of segment.removedUpdateIds) entries.delete(id);
|
|
1981
|
+
for (const entry of segment.upsertedEntries) {
|
|
1982
|
+
const previous = entries.get(entry.updateId);
|
|
1983
|
+
if (journalRequiresExclusion(file.version) && ((previous && previous.preApprovalExcluded !== entry.preApprovalExcluded) ||
|
|
1984
|
+
(!previous && entry.updateId <= (file.acceptedThroughUpdateId ?? -1)))) {
|
|
1985
|
+
throw createJournalError("pairing-evidence", target, "changes exclusion evidence or resurrects a settled source");
|
|
1986
|
+
}
|
|
1987
|
+
if (!entries.has(entry.updateId) && entries.size >= limits.maxEntries) capacity();
|
|
1988
|
+
entries.set(entry.updateId, entry);
|
|
1989
|
+
}
|
|
1990
|
+
const next = {
|
|
1991
|
+
...file,
|
|
1992
|
+
revision: segment.revision,
|
|
1993
|
+
...(segment.acceptedThroughUpdateId !== undefined ? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId } : {}),
|
|
1994
|
+
entries: [...entries.values()].sort((a, b) => a.updateId - b.updateId),
|
|
1995
|
+
operatorDispositions: segment.operatorDispositions ?? file.operatorDispositions,
|
|
1996
|
+
};
|
|
1997
|
+
charge(next);
|
|
1998
|
+
file = parseJournalFile(next, target, file.version);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
// Re-enumeration has the same bound, rather than allocating an unbounded census.
|
|
2002
|
+
files = snapshot ? 1 : 0;
|
|
2003
|
+
if (segments && !isDeepStrictEqual(names, enumerate())) fail("changed segment namespace");
|
|
2004
|
+
files += names.length;
|
|
2005
|
+
for (const [target, before] of observed) {
|
|
2006
|
+
const after = status(target);
|
|
2007
|
+
const compare = selectedVersion !== undefined && ancestors.has(target) ? sameAncestor : same;
|
|
2008
|
+
if (before ? !after || !compare(before, after) : after !== undefined) fail("changed observed namespace or file");
|
|
2009
|
+
}
|
|
2010
|
+
if (selectedVersion !== undefined) {
|
|
2011
|
+
if (realpathSync(anchor) !== anchor) fail("requires a canonical directory anchor");
|
|
2012
|
+
if (file) {
|
|
2013
|
+
if (file.version !== selectedVersion) {
|
|
2014
|
+
throw createJournalError("unsupported-version", path, "does not match the selected source version");
|
|
2015
|
+
}
|
|
2016
|
+
if (createTelegramUpdateJournalReceiptScope({ profileName: input.profile, botIdentity: expected }) !==
|
|
2017
|
+
createTelegramUpdateJournalReceiptScope({ profileName: file.profile, botIdentity: file.botIdentity })) {
|
|
2018
|
+
throw createJournalError("identity-mismatch", path, "does not retain the expected receipt scope");
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
return {
|
|
2023
|
+
evidence: file ? { kind: "present", file, ...(knownBotId !== undefined ? { knownBotId } : {}), accounting: { files, bytes, work } } : { kind: "absent" },
|
|
2024
|
+
snapshotRevision,
|
|
2025
|
+
segments: acquiredSegments,
|
|
2026
|
+
};
|
|
2027
|
+
} catch (error) {
|
|
2028
|
+
if (error instanceof TelegramUpdateJournalError) throw error;
|
|
2029
|
+
throw createJournalError("io", path, "could not establish strict journal evidence", error);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/**
|
|
2034
|
+
* Canonical namespace evidence only, never source readiness or authorization.
|
|
2035
|
+
* Caller-proven serialization/quiescence is mandatory through consumption.
|
|
2036
|
+
* Recensus detects observable changes, not hostile same-user path swaps; arbitrary
|
|
2037
|
+
* consumer references and archive consumption require separate audits.
|
|
2038
|
+
*/
|
|
2039
|
+
export function inspectTelegramProfileJournalNamespace(input: {
|
|
2040
|
+
directory: string;
|
|
2041
|
+
profile: string;
|
|
2042
|
+
botIdentity: TelegramUpdateJournalBotIdentity;
|
|
2043
|
+
limits: { maxDirectoryEntries: number; maxFiles: number; maxBytes: number; maxEntries: number; maxWork: number };
|
|
2044
|
+
}): {
|
|
2045
|
+
sources: { role: "polling" | "follower"; path: string; evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily> }[];
|
|
2046
|
+
accounting: { directoryEntries: number; files: number; bytes: number; work: number };
|
|
2047
|
+
knownBotId?: number;
|
|
2048
|
+
} {
|
|
2049
|
+
const { directory, profile, limits } = input;
|
|
2050
|
+
const fail = (message: string): never => { throw createJournalError("invalid", directory, message); };
|
|
2051
|
+
const capacity = (): never => { throw createJournalError("capacity", directory, "exceeds namespace inspection resource limits"); };
|
|
2052
|
+
for (const key of ["maxDirectoryEntries", "maxFiles", "maxBytes", "maxEntries", "maxWork"] as const) {
|
|
2053
|
+
if (!isSafePositiveInteger(limits[key])) fail("requires positive safe-integer limits");
|
|
2054
|
+
}
|
|
2055
|
+
if (!/^[a-z0-9]{1,32}$/u.test(profile)) fail("requires a canonical profile namespace");
|
|
2056
|
+
const expected = validateBotIdentity(input.botIdentity, directory);
|
|
2057
|
+
if (!isAbsolute(directory) || resolve(directory) !== directory) fail("requires a canonical directory anchor");
|
|
2058
|
+
if (!constants.O_NOFOLLOW || !constants.O_NONBLOCK) fail("platform lacks no-follow nonblocking open evidence");
|
|
2059
|
+
const metadata = (target: string) => {
|
|
2060
|
+
const value = lstatSync(target, { bigint: true });
|
|
2061
|
+
return { dev: value.dev, ino: value.ino, mode: value.mode, nlink: value.nlink,
|
|
2062
|
+
size: value.size, mtimeNs: value.mtimeNs, ctimeNs: value.ctimeNs };
|
|
2063
|
+
};
|
|
2064
|
+
const suffix = profile === "default" ? "" : `.${profile}`;
|
|
2065
|
+
const polling = `inbox${suffix}.json`;
|
|
2066
|
+
try {
|
|
2067
|
+
const root = lstatSync(directory);
|
|
2068
|
+
if (!root.isDirectory() || root.isSymbolicLink() || realpathSync(directory) !== directory) fail("requires a canonical directory anchor");
|
|
2069
|
+
const beforeRoot = metadata(directory);
|
|
2070
|
+
const census = () => {
|
|
2071
|
+
const entries = new Map<string, ReturnType<typeof metadata>>();
|
|
2072
|
+
const followers = new Set<string>();
|
|
2073
|
+
const handle = opendirSync(directory, { bufferSize: 1 });
|
|
2074
|
+
try {
|
|
2075
|
+
for (;;) {
|
|
2076
|
+
const entry = handle.readSync();
|
|
2077
|
+
if (!entry) break;
|
|
2078
|
+
if (entries.size >= limits.maxDirectoryEntries) capacity();
|
|
2079
|
+
if (entry.name.toLowerCase() === "recovery") fail("retains unclassified recovery storage");
|
|
2080
|
+
const target = join(directory, entry.name);
|
|
2081
|
+
entries.set(entry.name, metadata(target));
|
|
2082
|
+
if (!/inbox/iu.test(entry.name)) continue;
|
|
2083
|
+
const match = /^(inbox|follower-inbox-[a-f0-9]{16})(?:\.([a-z0-9]{1,32}))?\.json(\.segments)?$/u.exec(entry.name);
|
|
2084
|
+
if (!match || match[2] === "default") fail("has a noncanonical journal-like entry");
|
|
2085
|
+
const value = lstatSync(target);
|
|
2086
|
+
if (value.isSymbolicLink() || !(match![3] ? value.isDirectory() : value.isFile())) fail("has a linked or unexpected journal type");
|
|
2087
|
+
if ((match![2] ?? "default") !== profile) continue;
|
|
2088
|
+
if (match![1] !== "inbox") followers.add(entry.name.replace(/\.segments$/u, ""));
|
|
2089
|
+
}
|
|
2090
|
+
} finally { handle.closeSync(); }
|
|
2091
|
+
return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), followers: [...followers].sort() };
|
|
2092
|
+
};
|
|
2093
|
+
const before = census();
|
|
2094
|
+
const sources: { role: "polling" | "follower"; path: string; evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily> }[] = [];
|
|
2095
|
+
const accounting = { directoryEntries: before.entries.length, files: 0, bytes: 0, work: 0 };
|
|
2096
|
+
let knownBotId = expected.botId;
|
|
2097
|
+
for (const name of [polling, ...before.followers]) {
|
|
2098
|
+
const remaining = { maxFiles: limits.maxFiles - accounting.files, maxBytes: limits.maxBytes - accounting.bytes,
|
|
2099
|
+
maxWork: limits.maxWork - accounting.work, maxEntries: limits.maxEntries };
|
|
2100
|
+
if (remaining.maxFiles <= 0 || remaining.maxBytes <= 0 || remaining.maxWork <= 0) capacity();
|
|
2101
|
+
const path = join(directory, name);
|
|
2102
|
+
const evidence = inspectTelegramUpdateJournalFamily({ directory, path, profile,
|
|
2103
|
+
botIdentity: { ...expected, ...(knownBotId !== undefined ? { botId: knownBotId } : {}) }, limits: remaining });
|
|
2104
|
+
if (evidence.kind === "absent" && (name !== polling || before.entries.some(([entry]) => entry === polling || entry === `${polling}.segments`))) {
|
|
2105
|
+
fail("lost a discovered journal family");
|
|
2106
|
+
}
|
|
2107
|
+
if (evidence.kind === "present") {
|
|
2108
|
+
knownBotId = evidence.knownBotId;
|
|
2109
|
+
accounting.files += evidence.accounting.files;
|
|
2110
|
+
accounting.bytes += evidence.accounting.bytes;
|
|
2111
|
+
}
|
|
2112
|
+
accounting.work += evidence.kind === "present" ? Math.max(1, evidence.accounting.work) : 1;
|
|
2113
|
+
sources.push({ role: name === polling ? "polling" : "follower", path, evidence });
|
|
2114
|
+
}
|
|
2115
|
+
if (!isDeepStrictEqual(before, census()) || !isDeepStrictEqual(beforeRoot, metadata(directory))) fail("changed observed root namespace or identity");
|
|
2116
|
+
return { sources, accounting, ...(knownBotId !== undefined ? { knownBotId } : {}) };
|
|
2117
|
+
} catch (error) {
|
|
2118
|
+
if (error instanceof TelegramUpdateJournalError) throw error;
|
|
2119
|
+
throw createJournalError("io", directory, "could not establish canonical namespace evidence", error);
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
1213
2123
|
function cloneEntry(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEntry {
|
|
1214
2124
|
return {
|
|
1215
2125
|
...entry,
|
|
1216
2126
|
update: structuredClone(entry.update),
|
|
2127
|
+
...(entry.inputClaim ? { inputClaim: structuredClone(entry.inputClaim) } : {}),
|
|
2128
|
+
...(entry.inputProvenance
|
|
2129
|
+
? { inputProvenance: structuredClone(entry.inputProvenance) } : {}),
|
|
1217
2130
|
...(entry.queueOwner
|
|
1218
2131
|
? { queueOwner: cloneJournalQueueOwner(entry.queueOwner) }
|
|
1219
2132
|
: {}),
|
|
@@ -1226,7 +2139,7 @@ function cloneEntry(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEnt
|
|
|
1226
2139
|
|
|
1227
2140
|
function cloneFile(file: TelegramUpdateJournalFile): TelegramUpdateJournalFile {
|
|
1228
2141
|
return {
|
|
1229
|
-
version:
|
|
2142
|
+
version: file.version,
|
|
1230
2143
|
...(file.revision !== undefined ? { revision: file.revision } : {}),
|
|
1231
2144
|
...(file.acceptedThroughUpdateId !== undefined
|
|
1232
2145
|
? { acceptedThroughUpdateId: file.acceptedThroughUpdateId }
|
|
@@ -1283,7 +2196,7 @@ function publishTelegramUpdateJournalSegmentUnlocked(
|
|
|
1283
2196
|
let snapshotRevision = 0;
|
|
1284
2197
|
try {
|
|
1285
2198
|
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
1286
|
-
snapshotRevision = parseJournalFile(parsed, path).revision ?? 0;
|
|
2199
|
+
snapshotRevision = parseJournalFile(parsed, path, segment.version).revision ?? 0;
|
|
1287
2200
|
} catch (error) {
|
|
1288
2201
|
if ((error as { code?: unknown })?.code !== "ENOENT") throw error;
|
|
1289
2202
|
}
|
|
@@ -1379,8 +2292,9 @@ function writeJournalFile(
|
|
|
1379
2292
|
path: string,
|
|
1380
2293
|
serialized: string,
|
|
1381
2294
|
onPublicationBoundary?: TelegramUpdateJournalStoreOptions["onPublicationBoundary"],
|
|
2295
|
+
stagingPath = path,
|
|
1382
2296
|
): void {
|
|
1383
|
-
const tempPath = `${
|
|
2297
|
+
const tempPath = `${stagingPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1384
2298
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
1385
2299
|
try {
|
|
1386
2300
|
onPublicationBoundary?.("before-write", path);
|
|
@@ -1440,7 +2354,7 @@ export function createTelegramUpdateJournalReceiptScope(input: {
|
|
|
1440
2354
|
);
|
|
1441
2355
|
}
|
|
1442
2356
|
return JSON.stringify({
|
|
1443
|
-
version:
|
|
2357
|
+
version: TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION,
|
|
1444
2358
|
profile,
|
|
1445
2359
|
bot:
|
|
1446
2360
|
input.botIdentity.botId === undefined
|
|
@@ -1458,7 +2372,7 @@ export function createTelegramUpdateJournalBindingKey(input: {
|
|
|
1458
2372
|
throw new Error("Telegram update journal binding requires a path.");
|
|
1459
2373
|
}
|
|
1460
2374
|
return JSON.stringify({
|
|
1461
|
-
version:
|
|
2375
|
+
version: TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION,
|
|
1462
2376
|
path: input.path,
|
|
1463
2377
|
receiptScope: createTelegramUpdateJournalReceiptScope(input),
|
|
1464
2378
|
});
|
|
@@ -1469,7 +2383,7 @@ export function getTelegramUpdateJournalBindingPath(
|
|
|
1469
2383
|
): string | undefined {
|
|
1470
2384
|
try {
|
|
1471
2385
|
const value = JSON.parse(journalBindingKey) as Record<string, unknown>;
|
|
1472
|
-
return value.version ===
|
|
2386
|
+
return value.version === TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION &&
|
|
1473
2387
|
typeof value.path === "string" &&
|
|
1474
2388
|
value.path.length > 0 &&
|
|
1475
2389
|
typeof value.receiptScope === "string"
|
|
@@ -1522,6 +2436,11 @@ export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
|
|
|
1522
2436
|
getBotId: () => number | undefined;
|
|
1523
2437
|
getJournalPath: (profileName?: string) => string;
|
|
1524
2438
|
getQueueRuntimeIdentity?: () => TelegramUpdateJournalQueueRuntimeIdentity;
|
|
2439
|
+
withWriterAdmission?: <T>(operation: () => T) => T;
|
|
2440
|
+
getWorkspaceAdmission?: () => Pick<
|
|
2441
|
+
TelegramWorkspaceAdmissionLedger,
|
|
2442
|
+
"acquireAdmission" | "releaseAdmission"
|
|
2443
|
+
> | undefined;
|
|
1525
2444
|
onRecovery?: (event: TelegramUpdateJournalRecoveryEvent) => void;
|
|
1526
2445
|
}
|
|
1527
2446
|
|
|
@@ -1539,6 +2458,7 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
|
|
|
1539
2458
|
botId: deps.getBotId(),
|
|
1540
2459
|
});
|
|
1541
2460
|
const path = deps.getJournalPath(configuredProfileName);
|
|
2461
|
+
const workspaceAdmission = deps.getWorkspaceAdmission?.();
|
|
1542
2462
|
return {
|
|
1543
2463
|
runtimeKey: JSON.stringify({
|
|
1544
2464
|
path,
|
|
@@ -1557,12 +2477,75 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
|
|
|
1557
2477
|
...(deps.getQueueRuntimeIdentity
|
|
1558
2478
|
? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() }
|
|
1559
2479
|
: {}),
|
|
2480
|
+
...(workspaceAdmission ? { workspaceAdmission } : {}),
|
|
2481
|
+
...(deps.withWriterAdmission ? { withWriterAdmission: deps.withWriterAdmission } : {}),
|
|
1560
2482
|
...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}),
|
|
1561
2483
|
}),
|
|
1562
2484
|
};
|
|
1563
2485
|
};
|
|
1564
2486
|
}
|
|
1565
2487
|
|
|
2488
|
+
export type TelegramUpdateJournalReferenceClass =
|
|
2489
|
+
| "leader-lifecycle"
|
|
2490
|
+
| "follower-lifecycle"
|
|
2491
|
+
| "polling-cursor"
|
|
2492
|
+
| "polling-bootstrap"
|
|
2493
|
+
| "workspace-retirement"
|
|
2494
|
+
| "operator-disposition";
|
|
2495
|
+
|
|
2496
|
+
export function createTelegramUpdateJournalReferenceRegistry(input: {
|
|
2497
|
+
maxActive?: number;
|
|
2498
|
+
} = {}) {
|
|
2499
|
+
const maxActive = input.maxActive ?? 64;
|
|
2500
|
+
let sequence = 0;
|
|
2501
|
+
const active = new Map<number, { referenceClass: TelegramUpdateJournalReferenceClass;
|
|
2502
|
+
recoveryKey: string }>();
|
|
2503
|
+
return {
|
|
2504
|
+
acquire(reference: { referenceClass: TelegramUpdateJournalReferenceClass;
|
|
2505
|
+
recoveryKey: string }): () => void {
|
|
2506
|
+
if (!reference.recoveryKey || active.size >= maxActive) throw new Error(
|
|
2507
|
+
"Telegram update journal reference registry is unavailable or full.",
|
|
2508
|
+
);
|
|
2509
|
+
const id = ++sequence;
|
|
2510
|
+
active.set(id, { ...reference });
|
|
2511
|
+
let released = false;
|
|
2512
|
+
return () => {
|
|
2513
|
+
if (released || !active.delete(id)) throw new Error(
|
|
2514
|
+
"Telegram update journal reference lease is stale.",
|
|
2515
|
+
);
|
|
2516
|
+
released = true;
|
|
2517
|
+
};
|
|
2518
|
+
},
|
|
2519
|
+
list: () => [...active.values()].map(reference => ({ ...reference })),
|
|
2520
|
+
withReference<T>(reference: { referenceClass: TelegramUpdateJournalReferenceClass;
|
|
2521
|
+
recoveryKey: string }, operation: () => T): T {
|
|
2522
|
+
const release = this.acquire(reference);
|
|
2523
|
+
try {
|
|
2524
|
+
const result = operation();
|
|
2525
|
+
if (result && typeof (result as { finally?: unknown }).finally === "function")
|
|
2526
|
+
return ((result as unknown) as Promise<unknown>).finally(release) as T;
|
|
2527
|
+
release();
|
|
2528
|
+
return result;
|
|
2529
|
+
} catch (error) {
|
|
2530
|
+
release();
|
|
2531
|
+
throw error;
|
|
2532
|
+
}
|
|
2533
|
+
},
|
|
2534
|
+
};
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
export function withTelegramResolvedUpdateJournalReference<T>(input: {
|
|
2538
|
+
registry: ReturnType<typeof createTelegramUpdateJournalReferenceRegistry>;
|
|
2539
|
+
resolveBinding(): TelegramUpdateJournalRuntimeBinding | undefined;
|
|
2540
|
+
referenceClass: TelegramUpdateJournalReferenceClass;
|
|
2541
|
+
operation(binding: TelegramUpdateJournalRuntimeBinding): T;
|
|
2542
|
+
}): T | undefined {
|
|
2543
|
+
const binding = input.resolveBinding();
|
|
2544
|
+
if (!binding) return undefined;
|
|
2545
|
+
return input.registry.withReference({ referenceClass: input.referenceClass,
|
|
2546
|
+
recoveryKey: binding.recoveryKey }, () => input.operation(binding));
|
|
2547
|
+
}
|
|
2548
|
+
|
|
1566
2549
|
export interface TelegramUpdateJournalBindingRuntime {
|
|
1567
2550
|
resolveLeader: () => TelegramUpdateJournalRuntimeBinding | undefined;
|
|
1568
2551
|
resolveFollower: () => TelegramUpdateJournalRuntimeBinding | undefined;
|
|
@@ -1571,6 +2554,9 @@ export interface TelegramUpdateJournalBindingRuntime {
|
|
|
1571
2554
|
createRecipientResolver: (
|
|
1572
2555
|
recipientBindingKey: string,
|
|
1573
2556
|
) => () => TelegramUpdateJournalRuntimeBinding | undefined;
|
|
2557
|
+
createPathResolver: (
|
|
2558
|
+
path: string,
|
|
2559
|
+
) => () => TelegramUpdateJournalRuntimeBinding | undefined;
|
|
1574
2560
|
}
|
|
1575
2561
|
|
|
1576
2562
|
export function createTelegramUpdateJournalBindingRuntime(deps: {
|
|
@@ -1598,6 +2584,11 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
|
|
|
1598
2584
|
...(includeQueueRuntimeIdentity && deps.base.getQueueRuntimeIdentity
|
|
1599
2585
|
? { getQueueRuntimeIdentity: deps.base.getQueueRuntimeIdentity }
|
|
1600
2586
|
: {}),
|
|
2587
|
+
...(deps.base.withWriterAdmission
|
|
2588
|
+
? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
|
|
2589
|
+
...(deps.base.getWorkspaceAdmission
|
|
2590
|
+
? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
|
|
2591
|
+
: {}),
|
|
1601
2592
|
...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
|
|
1602
2593
|
getJournalPath(profileName) {
|
|
1603
2594
|
return deps.getFollowerJournalPath(bindingKey, profileName);
|
|
@@ -1614,12 +2605,48 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
|
|
|
1614
2605
|
getActiveRecoveryKey: () => resolveActive()?.recoveryKey,
|
|
1615
2606
|
createRecipientResolver: (bindingKey) =>
|
|
1616
2607
|
createFollowerResolver(bindingKey, false),
|
|
2608
|
+
createPathResolver: (path) =>
|
|
2609
|
+
createTelegramUpdateJournalRuntimeBindingResolver({
|
|
2610
|
+
getProfileName: deps.base.getProfileName,
|
|
2611
|
+
getBotToken: deps.base.getBotToken,
|
|
2612
|
+
getBotId: deps.base.getBotId,
|
|
2613
|
+
...(deps.base.withWriterAdmission
|
|
2614
|
+
? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
|
|
2615
|
+
...(deps.base.getWorkspaceAdmission
|
|
2616
|
+
? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
|
|
2617
|
+
: {}),
|
|
2618
|
+
...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
|
|
2619
|
+
getJournalPath: () => path,
|
|
2620
|
+
}),
|
|
1617
2621
|
};
|
|
1618
2622
|
}
|
|
1619
2623
|
|
|
1620
2624
|
export function createTelegramUpdateJournalStore(
|
|
1621
2625
|
options: TelegramUpdateJournalStoreOptions,
|
|
1622
2626
|
): TelegramUpdateJournalStore {
|
|
2627
|
+
return createJournalStoreCore(options).journal;
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
/** Opt-in v3 only; does not migrate old files or expose legacy unowned mutation ports. */
|
|
2631
|
+
export function createTelegramInputJournalStore(
|
|
2632
|
+
options: TelegramInputJournalStoreOptions,
|
|
2633
|
+
): TelegramInputJournalStore {
|
|
2634
|
+
const captured = { ...options };
|
|
2635
|
+
if (!captured.sourceAccess || !captured.queueRuntimeIdentity ||
|
|
2636
|
+
typeof captured.withSourceSerialization !== "function" ||
|
|
2637
|
+
typeof captured.withPairingAdmission !== "function" ||
|
|
2638
|
+
typeof captured.getInputContext !== "function" ||
|
|
2639
|
+
("withPairedAdmission" in captured && captured.withPairedAdmission !== undefined)) {
|
|
2640
|
+
throw new Error("Telegram input custody requires strict serialized polling admission and runtime identity.");
|
|
2641
|
+
}
|
|
2642
|
+
return createJournalStoreCore({ ...captured, queueRuntimeIdentity: { ...captured.queueRuntimeIdentity } },
|
|
2643
|
+
captured.getInputContext).input!;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
function createJournalStoreCore(
|
|
2647
|
+
options: TelegramUpdateJournalStoreOptions,
|
|
2648
|
+
getInputContext?: TelegramInputJournalStoreOptions["getInputContext"],
|
|
2649
|
+
): { journal: TelegramUpdateJournalStore; input?: TelegramInputJournalStore } {
|
|
1623
2650
|
const path = options.path;
|
|
1624
2651
|
const profile = options.profileName ?? TELEGRAM_DEFAULT_PROFILE_NAME;
|
|
1625
2652
|
if (!path) throw new Error("Telegram update journal path is required.");
|
|
@@ -1654,6 +2681,22 @@ export function createTelegramUpdateJournalStore(
|
|
|
1654
2681
|
);
|
|
1655
2682
|
const getNowMs = options.getNowMs ?? Date.now;
|
|
1656
2683
|
const onPublicationBoundary = options.onPublicationBoundary;
|
|
2684
|
+
const workspaceAdmission = options.workspaceAdmission;
|
|
2685
|
+
const withSourceSerialization = options.withSourceSerialization;
|
|
2686
|
+
const sourceAccess = options.sourceAccess && {
|
|
2687
|
+
directory: options.sourceAccess.directory,
|
|
2688
|
+
limits: { ...options.sourceAccess.limits },
|
|
2689
|
+
};
|
|
2690
|
+
if (sourceAccess && !withSourceSerialization) {
|
|
2691
|
+
throw new Error("Telegram journal source access requires source serialization.");
|
|
2692
|
+
}
|
|
2693
|
+
const withPairingAdmission = options.withPairingAdmission;
|
|
2694
|
+
const withPairedAdmission = options.withPairedAdmission;
|
|
2695
|
+
if (withPairingAdmission && withPairedAdmission) {
|
|
2696
|
+
throw new Error("Telegram journal admission modes are mutually exclusive.");
|
|
2697
|
+
}
|
|
2698
|
+
const version = getInputContext ? TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION
|
|
2699
|
+
: withPairingAdmission ? TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION : TELEGRAM_UPDATE_JOURNAL_VERSION;
|
|
1657
2700
|
const notifyRecovery = (event: TelegramUpdateJournalRecoveryEvent): void => {
|
|
1658
2701
|
try {
|
|
1659
2702
|
options.onRecovery?.(event);
|
|
@@ -1796,7 +2839,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
1796
2839
|
};
|
|
1797
2840
|
|
|
1798
2841
|
const emptyFile = (): TelegramUpdateJournalFile => ({
|
|
1799
|
-
version
|
|
2842
|
+
version,
|
|
1800
2843
|
profile,
|
|
1801
2844
|
botIdentity: { ...expectedIdentity },
|
|
1802
2845
|
entries: [],
|
|
@@ -1836,6 +2879,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
1836
2879
|
);
|
|
1837
2880
|
}
|
|
1838
2881
|
if (orphanedSegmentNames.length > 0) {
|
|
2882
|
+
if (withPairingAdmission) throw createJournalError("pairing-evidence", path, "requires explicit missing-snapshot reconciliation");
|
|
1839
2883
|
recoveringMissingSnapshot = true;
|
|
1840
2884
|
source = serializeJournalFile(emptyFile());
|
|
1841
2885
|
} else {
|
|
@@ -1851,7 +2895,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
1851
2895
|
} catch (error) {
|
|
1852
2896
|
throw createJournalError("invalid", path, "contains invalid JSON", error);
|
|
1853
2897
|
}
|
|
1854
|
-
let file = parseJournalFile(parsed, path);
|
|
2898
|
+
let file = parseJournalFile(parsed, path, version);
|
|
1855
2899
|
const storedProfile = file.profile;
|
|
1856
2900
|
const storedIdentity = file.botIdentity;
|
|
1857
2901
|
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
@@ -1896,6 +2940,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
1896
2940
|
segment = parseJournalSegment(
|
|
1897
2941
|
JSON.parse(readFileSync(segmentPath, "utf8")) as unknown,
|
|
1898
2942
|
segmentPath,
|
|
2943
|
+
version,
|
|
1899
2944
|
);
|
|
1900
2945
|
} catch (error) {
|
|
1901
2946
|
if (error instanceof TelegramUpdateJournalError) throw error;
|
|
@@ -1933,7 +2978,8 @@ export function createTelegramUpdateJournalStore(
|
|
|
1933
2978
|
}
|
|
1934
2979
|
if (
|
|
1935
2980
|
segment.profile !== storedProfile ||
|
|
1936
|
-
!identitiesMatch(segment.botIdentity, storedIdentity)
|
|
2981
|
+
!identitiesMatch(segment.botIdentity, storedIdentity) ||
|
|
2982
|
+
(withPairingAdmission && segment.botIdentity.tokenSha256 !== storedIdentity.tokenSha256)
|
|
1937
2983
|
) {
|
|
1938
2984
|
throw createJournalError(
|
|
1939
2985
|
"identity-mismatch",
|
|
@@ -1952,12 +2998,18 @@ export function createTelegramUpdateJournalStore(
|
|
|
1952
2998
|
entriesById.delete(updateId);
|
|
1953
2999
|
}
|
|
1954
3000
|
for (const entry of segment.upsertedEntries) {
|
|
3001
|
+
const previous = entriesById.get(entry.updateId);
|
|
3002
|
+
if (withPairingAdmission &&
|
|
3003
|
+
((previous && previous.preApprovalExcluded !== entry.preApprovalExcluded) ||
|
|
3004
|
+
(!previous && entry.updateId <= (file.acceptedThroughUpdateId ?? -1)))) {
|
|
3005
|
+
throw createJournalError("pairing-evidence", segmentPath, "changes exclusion evidence or resurrects a settled source");
|
|
3006
|
+
}
|
|
1955
3007
|
entriesById.set(entry.updateId, entry);
|
|
1956
3008
|
}
|
|
1957
3009
|
if (segment.upsertedEntries.length > 0) orphanRecoverySawUpsert = true;
|
|
1958
3010
|
file = parseJournalFile(
|
|
1959
3011
|
{
|
|
1960
|
-
version
|
|
3012
|
+
version,
|
|
1961
3013
|
revision: segment.revision,
|
|
1962
3014
|
...(segment.acceptedThroughUpdateId !== undefined
|
|
1963
3015
|
? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId }
|
|
@@ -1976,14 +3028,16 @@ export function createTelegramUpdateJournalStore(
|
|
|
1976
3028
|
: {}),
|
|
1977
3029
|
},
|
|
1978
3030
|
segmentPath,
|
|
3031
|
+
version,
|
|
1979
3032
|
);
|
|
1980
3033
|
revision = segment.revision;
|
|
1981
3034
|
}
|
|
1982
3035
|
const identityChanged =
|
|
1983
3036
|
storedProfile !== profile ||
|
|
1984
|
-
!identitiesMatch(file.botIdentity, expectedIdentity)
|
|
3037
|
+
!identitiesMatch(file.botIdentity, expectedIdentity) ||
|
|
3038
|
+
(withPairingAdmission && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
|
|
1985
3039
|
if (identityChanged) {
|
|
1986
|
-
if (file.entries.length > 0) {
|
|
3040
|
+
if (file.entries.length > 0 || withPairingAdmission) {
|
|
1987
3041
|
throw createJournalError(
|
|
1988
3042
|
"identity-mismatch",
|
|
1989
3043
|
path,
|
|
@@ -1993,7 +3047,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
1993
3047
|
);
|
|
1994
3048
|
}
|
|
1995
3049
|
file = {
|
|
1996
|
-
version
|
|
3050
|
+
version,
|
|
1997
3051
|
...(file.revision !== undefined ? { revision: file.revision } : {}),
|
|
1998
3052
|
profile,
|
|
1999
3053
|
botIdentity: { ...expectedIdentity },
|
|
@@ -2051,6 +3105,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
2051
3105
|
return readCurrentStrict();
|
|
2052
3106
|
} catch (error) {
|
|
2053
3107
|
if (
|
|
3108
|
+
withPairingAdmission ||
|
|
2054
3109
|
!(error instanceof TelegramUpdateJournalError) ||
|
|
2055
3110
|
error.code !== "invalid"
|
|
2056
3111
|
) {
|
|
@@ -2074,6 +3129,24 @@ export function createTelegramUpdateJournalStore(
|
|
|
2074
3129
|
}
|
|
2075
3130
|
if (segmentNames.length === 0) throw error;
|
|
2076
3131
|
|
|
3132
|
+
// Earlier corruption must not hide a newer schema behind repair or reset.
|
|
3133
|
+
let recoverySegmentBytes = 0;
|
|
3134
|
+
for (const candidatePath of [
|
|
3135
|
+
...(snapshotExists ? [path] : []),
|
|
3136
|
+
...segmentNames.map((name) => join(segmentDirectory, name)),
|
|
3137
|
+
]) {
|
|
3138
|
+
const size = statSync(candidatePath).size;
|
|
3139
|
+
if (candidatePath !== path) recoverySegmentBytes += size;
|
|
3140
|
+
if (size > maxBytes || recoverySegmentBytes > maxBytes) {
|
|
3141
|
+
throw createJournalError("capacity", candidatePath, "exceeds the recovery inspection byte limit");
|
|
3142
|
+
}
|
|
3143
|
+
try {
|
|
3144
|
+
assertSupportedJournalVersion(JSON.parse(readFileSync(candidatePath, "utf8")), candidatePath);
|
|
3145
|
+
} catch (inspectionError) {
|
|
3146
|
+
if (!(inspectionError instanceof SyntaxError)) throw inspectionError;
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
|
|
2077
3150
|
if (snapshotExists) {
|
|
2078
3151
|
try {
|
|
2079
3152
|
const snapshot = parseJournalFile(
|
|
@@ -2108,8 +3181,13 @@ export function createTelegramUpdateJournalStore(
|
|
|
2108
3181
|
});
|
|
2109
3182
|
return repaired;
|
|
2110
3183
|
}
|
|
2111
|
-
} catch {
|
|
2112
|
-
|
|
3184
|
+
} catch (recoveryError) {
|
|
3185
|
+
if (recoveryError instanceof TelegramUpdateJournalError) {
|
|
3186
|
+
if (recoveryError.code !== "invalid") throw recoveryError;
|
|
3187
|
+
} else if (!(recoveryError instanceof SyntaxError)) {
|
|
3188
|
+
throw recoveryError;
|
|
3189
|
+
}
|
|
3190
|
+
// Only known schema/JSON corruption may fall through to reset.
|
|
2113
3191
|
}
|
|
2114
3192
|
}
|
|
2115
3193
|
|
|
@@ -2179,23 +3257,363 @@ export function createTelegramUpdateJournalStore(
|
|
|
2179
3257
|
}
|
|
2180
3258
|
};
|
|
2181
3259
|
|
|
2182
|
-
const
|
|
3260
|
+
const runJournalTransaction = <T>(operation: (read: typeof readCurrent) => T): T => {
|
|
2183
3261
|
try {
|
|
2184
|
-
|
|
3262
|
+
// Acquire before the transaction helper can create parents or staging names.
|
|
3263
|
+
// The config continuation excludes participating writers through consumption.
|
|
3264
|
+
const source = sourceAccess ? acquireTelegramUpdateJournalFamily({
|
|
3265
|
+
...sourceAccess, path, profile, botIdentity: expectedIdentity,
|
|
3266
|
+
}, version) : undefined;
|
|
3267
|
+
const readSource = (): ReadTelegramUpdateJournalResult => {
|
|
3268
|
+
if (!source) return readCurrent();
|
|
3269
|
+
const file = source.evidence.kind === "present" ? source.evidence.file : emptyFile();
|
|
3270
|
+
return { file, exists: source.evidence.kind === "present",
|
|
3271
|
+
serializedBytes: assertCapacity(file), source };
|
|
3272
|
+
};
|
|
3273
|
+
return withTelegramFileTransaction(`${path}.transaction`, () => operation(readSource));
|
|
2185
3274
|
} catch (error) {
|
|
2186
3275
|
if (error instanceof TelegramUpdateJournalError) throw error;
|
|
2187
3276
|
throw createJournalError("io", path, "mutation failed", error);
|
|
2188
3277
|
}
|
|
2189
3278
|
};
|
|
2190
3279
|
|
|
3280
|
+
const runMutationCore = <T>(operation: (read: typeof readCurrent) => T): T =>
|
|
3281
|
+
withSourceSerialization
|
|
3282
|
+
? withSourceSerialization(() => runJournalTransaction(operation))
|
|
3283
|
+
: runJournalTransaction(operation);
|
|
3284
|
+
const runMutation = <T>(operation: (read: typeof readCurrent) => T): T =>
|
|
3285
|
+
options.withWriterAdmission
|
|
3286
|
+
? options.withWriterAdmission(() => runMutationCore(operation))
|
|
3287
|
+
: runMutationCore(operation);
|
|
3288
|
+
|
|
3289
|
+
const assertSourceResources = (files: number, bytes: number, work: number,
|
|
3290
|
+
collections: readonly number[]): void => {
|
|
3291
|
+
if (!sourceAccess) return;
|
|
3292
|
+
const limits = sourceAccess.limits;
|
|
3293
|
+
if (files > limits.maxFiles || bytes > limits.maxBytes || work > limits.maxWork ||
|
|
3294
|
+
collections.some((length) => length > limits.maxEntries)) {
|
|
3295
|
+
throw createJournalError("capacity", path, "publication would exceed source inspection resource limits");
|
|
3296
|
+
}
|
|
3297
|
+
};
|
|
3298
|
+
|
|
3299
|
+
type InputHeadroomState = { file: TelegramUpdateJournalFile; projectionSlack: number };
|
|
3300
|
+
const inputHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH);
|
|
3301
|
+
const inputBindingHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH);
|
|
3302
|
+
const inputHandoffHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH);
|
|
3303
|
+
const getFileWork = (file: TelegramUpdateJournalFile): number =>
|
|
3304
|
+
file.entries.length + (file.operatorDispositions?.length ?? 0);
|
|
3305
|
+
const advanceInputHeadroomState = (state: InputHeadroomState,
|
|
3306
|
+
entries: TelegramUpdateJournalEntry[], projectionSlack: number): InputHeadroomState => {
|
|
3307
|
+
const revision = (state.file.revision ?? 0) + 1;
|
|
3308
|
+
if (!isSafePositiveInteger(revision)) throw createJournalError("capacity", path, "exhausted input custody revisions");
|
|
3309
|
+
return { file: { ...state.file, revision, entries }, projectionSlack };
|
|
3310
|
+
};
|
|
3311
|
+
const replaceInputHeadroomEntry = (file: TelegramUpdateJournalFile, updateId: number,
|
|
3312
|
+
replacement?: TelegramUpdateJournalEntry): TelegramUpdateJournalEntry[] => file.entries.flatMap(entry =>
|
|
3313
|
+
entry.updateId === updateId ? (replacement ? [replacement] : []) : [entry]);
|
|
3314
|
+
const createInputHeadroomClaim = (entry: TelegramUpdateJournalEntry,
|
|
3315
|
+
phase: TelegramUpdateJournalInputClaim["phase"]): TelegramUpdateJournalInputClaim => ({
|
|
3316
|
+
phase,
|
|
3317
|
+
owner: { instanceId: inputHeadroomText, processId: Number.MAX_SAFE_INTEGER,
|
|
3318
|
+
processBirthId: inputHeadroomText, sessionGeneration: Number.MAX_SAFE_INTEGER,
|
|
3319
|
+
acquisitionId: inputHeadroomText, acquiredAtMs: Number.MAX_SAFE_INTEGER,
|
|
3320
|
+
handoffId: inputHandoffHeadroomText },
|
|
3321
|
+
recipientBindingKey: inputBindingHeadroomText,
|
|
3322
|
+
executionUpdate: structuredClone(entry.update),
|
|
3323
|
+
});
|
|
3324
|
+
const largestInputHeadroomEntry = (entries: TelegramUpdateJournalEntry[],
|
|
3325
|
+
project: (entry: TelegramUpdateJournalEntry) => TelegramUpdateJournalEntry) => entries.reduce<
|
|
3326
|
+
{ entry: TelegramUpdateJournalEntry; bytes: number } | undefined
|
|
3327
|
+
>((selected, entry) => {
|
|
3328
|
+
const bytes = Buffer.byteLength(JSON.stringify(project(entry), null, 2));
|
|
3329
|
+
return !selected || bytes > selected.bytes ? { entry, bytes } : selected;
|
|
3330
|
+
}, undefined)?.entry;
|
|
3331
|
+
const smallestInputHeadroomEntry = (entries: TelegramUpdateJournalEntry[]) => entries.reduce<
|
|
3332
|
+
{ entry: TelegramUpdateJournalEntry; bytes: number } | undefined
|
|
3333
|
+
>((selected, entry) => {
|
|
3334
|
+
const bytes = Buffer.byteLength(JSON.stringify(entry, null, 2));
|
|
3335
|
+
return !selected || bytes < selected.bytes ? { entry, bytes } : selected;
|
|
3336
|
+
}, undefined)?.entry;
|
|
3337
|
+
const createInputHeadroomTransitionPlan = (file: TelegramUpdateJournalFile,
|
|
3338
|
+
entry: TelegramUpdateJournalEntry): InputHeadroomState[] => {
|
|
3339
|
+
const initial: InputHeadroomState = { file, projectionSlack: 0 };
|
|
3340
|
+
if (entry.inputClaim?.phase === "running") return [advanceInputHeadroomState(initial,
|
|
3341
|
+
replaceInputHeadroomEntry(file, entry.updateId), TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)];
|
|
3342
|
+
if (entry.state === "pending" && entry.inputClaim?.phase === "ready" && entry.inputClaim.handoff) {
|
|
3343
|
+
const acceptedClaim = { ...entry.inputClaim,
|
|
3344
|
+
owner: { ...entry.inputClaim.handoff.recipientOwner, acquisitionId: inputHeadroomText,
|
|
3345
|
+
acquiredAtMs: Number.MAX_SAFE_INTEGER, handoffId: inputHandoffHeadroomText } };
|
|
3346
|
+
delete acceptedClaim.handoff;
|
|
3347
|
+
const acceptedEntry = { ...entry, inputClaim: acceptedClaim };
|
|
3348
|
+
const acceptedState = advanceInputHeadroomState(initial,
|
|
3349
|
+
replaceInputHeadroomEntry(file, entry.updateId, acceptedEntry),
|
|
3350
|
+
TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES);
|
|
3351
|
+
return [acceptedState, ...createInputHeadroomTransitionPlan(acceptedState.file, acceptedEntry)];
|
|
3352
|
+
}
|
|
3353
|
+
if (entry.state === "pending" && entry.inputClaim?.phase === "ready") {
|
|
3354
|
+
const runningEntry = { ...entry, inputClaim: { ...entry.inputClaim, phase: "running" as const } };
|
|
3355
|
+
const runningState = advanceInputHeadroomState(initial,
|
|
3356
|
+
replaceInputHeadroomEntry(file, entry.updateId, runningEntry),
|
|
3357
|
+
TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES);
|
|
3358
|
+
return [runningState, advanceInputHeadroomState(runningState,
|
|
3359
|
+
replaceInputHeadroomEntry(runningState.file, entry.updateId),
|
|
3360
|
+
TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)];
|
|
3361
|
+
}
|
|
3362
|
+
if (entry.state === "pending" && entry.preApprovalExcluded === false && !entry.inputClaim) {
|
|
3363
|
+
const readyEntry = { ...entry, inputClaim: createInputHeadroomClaim(entry, "ready") };
|
|
3364
|
+
const readyState = advanceInputHeadroomState(initial,
|
|
3365
|
+
replaceInputHeadroomEntry(file, entry.updateId, readyEntry),
|
|
3366
|
+
TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES);
|
|
3367
|
+
const runningEntry = { ...readyEntry, inputClaim: { ...readyEntry.inputClaim, phase: "running" as const } };
|
|
3368
|
+
const runningState = advanceInputHeadroomState(readyState,
|
|
3369
|
+
replaceInputHeadroomEntry(readyState.file, entry.updateId, runningEntry),
|
|
3370
|
+
TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES);
|
|
3371
|
+
return [readyState, runningState, advanceInputHeadroomState(runningState,
|
|
3372
|
+
replaceInputHeadroomEntry(runningState.file, entry.updateId),
|
|
3373
|
+
TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)];
|
|
3374
|
+
}
|
|
3375
|
+
return entry.preApprovalExcluded === true ? [advanceInputHeadroomState(initial,
|
|
3376
|
+
replaceInputHeadroomEntry(file, entry.updateId),
|
|
3377
|
+
TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)] : [];
|
|
3378
|
+
};
|
|
3379
|
+
const createInputHeadroomCancelPlan = (file: TelegramUpdateJournalFile,
|
|
3380
|
+
entry: TelegramUpdateJournalEntry): InputHeadroomState[] => {
|
|
3381
|
+
if (!entry.inputClaim?.handoff) return [];
|
|
3382
|
+
const claim = structuredClone(entry.inputClaim);
|
|
3383
|
+
delete claim.handoff;
|
|
3384
|
+
const cancelledEntry = { ...entry, inputClaim: claim };
|
|
3385
|
+
const initial: InputHeadroomState = { file, projectionSlack: 0 };
|
|
3386
|
+
const cancelledState = advanceInputHeadroomState(initial,
|
|
3387
|
+
replaceInputHeadroomEntry(file, entry.updateId, cancelledEntry),
|
|
3388
|
+
TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES);
|
|
3389
|
+
return [cancelledState, ...createInputHeadroomTransitionPlan(cancelledState.file, cancelledEntry)];
|
|
3390
|
+
};
|
|
3391
|
+
const createInputHeadroomReleasePlan = (file: TelegramUpdateJournalFile,
|
|
3392
|
+
entry: TelegramUpdateJournalEntry, allowOffered = false): InputHeadroomState[] => {
|
|
3393
|
+
if (entry.inputClaim?.handoff && !allowOffered) return [];
|
|
3394
|
+
const normal = createInputHeadroomTransitionPlan(file, entry);
|
|
3395
|
+
const readyState = entry.inputClaim?.phase === "ready" ? { file, projectionSlack: 0 } : normal[0];
|
|
3396
|
+
if (!readyState) return [];
|
|
3397
|
+
const readyEntry = readyState.file.entries.find(candidate => candidate.updateId === entry.updateId);
|
|
3398
|
+
if (readyEntry?.inputClaim?.phase !== "ready") return [];
|
|
3399
|
+
const releasedEntry = cloneEntry(readyEntry);
|
|
3400
|
+
delete releasedEntry.inputClaim;
|
|
3401
|
+
const releasedState = advanceInputHeadroomState(readyState,
|
|
3402
|
+
replaceInputHeadroomEntry(readyState.file, entry.updateId, releasedEntry),
|
|
3403
|
+
TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES);
|
|
3404
|
+
const reacquisition = createInputHeadroomTransitionPlan(releasedState.file, releasedEntry);
|
|
3405
|
+
return [...(readyState.file === file ? [] : [readyState]), releasedState, ...reacquisition];
|
|
3406
|
+
};
|
|
3407
|
+
const createInputHeadroomQueueCompletionPlan = (file: TelegramUpdateJournalFile,
|
|
3408
|
+
entries: TelegramUpdateJournalEntry[]): InputHeadroomState[] => {
|
|
3409
|
+
if (entries.length === 0 || entries.some(entry => entry.state !== "queued" || entry.queueHandoff)) return [];
|
|
3410
|
+
const requestedIds = new Set(entries.map(entry => entry.updateId));
|
|
3411
|
+
const initial: InputHeadroomState = { file, projectionSlack: 0 };
|
|
3412
|
+
return [advanceInputHeadroomState(initial,
|
|
3413
|
+
file.entries.filter(entry => !requestedIds.has(entry.updateId)),
|
|
3414
|
+
TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)];
|
|
3415
|
+
};
|
|
3416
|
+
const getInputHeadroomTransitionPlans = (file: TelegramUpdateJournalFile,
|
|
3417
|
+
updateId: number | readonly number[] | "all"): InputHeadroomState[][] => {
|
|
3418
|
+
if (updateId !== "all") {
|
|
3419
|
+
const requestedIds = new Set<number>(typeof updateId === "number" ? [updateId] : [...updateId]);
|
|
3420
|
+
const requestedEntries = file.entries.filter(entry => requestedIds.has(entry.updateId));
|
|
3421
|
+
const queuedReceiptId = requestedEntries[0]?.queueReceiptId;
|
|
3422
|
+
if (requestedEntries.length === requestedIds.size && queuedReceiptId &&
|
|
3423
|
+
requestedEntries.every(entry => entry.state === "queued" &&
|
|
3424
|
+
entry.queueReceiptId === queuedReceiptId) &&
|
|
3425
|
+
file.entries.filter(entry => entry.queueReceiptId === queuedReceiptId).length === requestedIds.size) {
|
|
3426
|
+
const completion = createInputHeadroomQueueCompletionPlan(file, requestedEntries);
|
|
3427
|
+
return completion.length > 0 ? [completion] : [];
|
|
3428
|
+
}
|
|
3429
|
+
if (typeof updateId !== "number") return [];
|
|
3430
|
+
const entry = requestedEntries[0];
|
|
3431
|
+
const plan = entry ? createInputHeadroomTransitionPlan(file, entry) : [];
|
|
3432
|
+
const cancel = entry ? createInputHeadroomCancelPlan(file, entry) : [];
|
|
3433
|
+
const recover = entry?.inputClaim?.handoff
|
|
3434
|
+
? createInputHeadroomReleasePlan(file, entry, true) : [];
|
|
3435
|
+
return [plan, cancel, recover].filter(candidate => candidate.length > 0);
|
|
3436
|
+
}
|
|
3437
|
+
const running = smallestInputHeadroomEntry(file.entries.filter(entry =>
|
|
3438
|
+
entry.inputClaim?.phase === "running"));
|
|
3439
|
+
const offered = largestInputHeadroomEntry(file.entries.filter(entry =>
|
|
3440
|
+
entry.state === "pending" && entry.inputClaim?.phase === "ready" && entry.inputClaim.handoff), entry =>
|
|
3441
|
+
({ ...entry, inputClaim: { ...entry.inputClaim!, owner: {
|
|
3442
|
+
...entry.inputClaim!.handoff!.recipientOwner, acquisitionId: inputHeadroomText,
|
|
3443
|
+
acquiredAtMs: Number.MAX_SAFE_INTEGER, handoffId: inputHandoffHeadroomText }, handoff: undefined } }));
|
|
3444
|
+
const ready = largestInputHeadroomEntry(file.entries.filter(entry =>
|
|
3445
|
+
entry.state === "pending" && entry.inputClaim?.phase === "ready" && !entry.inputClaim.handoff), entry =>
|
|
3446
|
+
({ ...entry, inputClaim: { ...entry.inputClaim!, phase: "running" as const } }));
|
|
3447
|
+
const unclaimed = largestInputHeadroomEntry(file.entries.filter(entry =>
|
|
3448
|
+
entry.state === "pending" && entry.preApprovalExcluded === false && !entry.inputClaim), entry =>
|
|
3449
|
+
({ ...entry, inputClaim: createInputHeadroomClaim(entry, "running") }));
|
|
3450
|
+
const excluded = smallestInputHeadroomEntry(file.entries.filter(entry =>
|
|
3451
|
+
entry.preApprovalExcluded === true));
|
|
3452
|
+
const representatives = [running, offered, ready, unclaimed, excluded].filter(
|
|
3453
|
+
(entry): entry is TelegramUpdateJournalEntry => entry !== undefined);
|
|
3454
|
+
const plans = representatives.map(entry => createInputHeadroomTransitionPlan(file, entry));
|
|
3455
|
+
if (offered) {
|
|
3456
|
+
plans.push(createInputHeadroomCancelPlan(file, offered));
|
|
3457
|
+
plans.push(createInputHeadroomReleasePlan(file, offered, true));
|
|
3458
|
+
}
|
|
3459
|
+
for (const entry of [ready, unclaimed]) {
|
|
3460
|
+
if (entry) plans.push(createInputHeadroomReleasePlan(file, entry));
|
|
3461
|
+
}
|
|
3462
|
+
const queuedReceipts = new Map<string, TelegramUpdateJournalEntry[]>();
|
|
3463
|
+
for (const entry of file.entries) {
|
|
3464
|
+
if (entry.state !== "queued" || !entry.queueReceiptId || entry.queueHandoff) continue;
|
|
3465
|
+
const grouped = queuedReceipts.get(entry.queueReceiptId) ?? [];
|
|
3466
|
+
grouped.push(entry);
|
|
3467
|
+
queuedReceipts.set(entry.queueReceiptId, grouped);
|
|
3468
|
+
}
|
|
3469
|
+
for (const entries of queuedReceipts.values()) {
|
|
3470
|
+
const completion = createInputHeadroomQueueCompletionPlan(file, entries);
|
|
3471
|
+
if (completion.length > 0) plans.push(completion);
|
|
3472
|
+
}
|
|
3473
|
+
return plans;
|
|
3474
|
+
};
|
|
3475
|
+
const createInputHeadroomSegment = (
|
|
3476
|
+
previous: TelegramUpdateJournalFile, next: TelegramUpdateJournalFile,
|
|
3477
|
+
): TelegramUpdateJournalSegment => {
|
|
3478
|
+
const previousEntries = new Map(previous.entries.map(entry => [entry.updateId, entry]));
|
|
3479
|
+
const nextEntries = new Map(next.entries.map(entry => [entry.updateId, entry]));
|
|
3480
|
+
return { version: TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
|
|
3481
|
+
revision: next.revision!, previousRevision: next.revision! - 1,
|
|
3482
|
+
profile: next.profile, botIdentity: next.botIdentity,
|
|
3483
|
+
upsertedEntries: next.entries.filter(entry =>
|
|
3484
|
+
!previousEntries.has(entry.updateId) || !isDeepStrictEqual(previousEntries.get(entry.updateId), entry)),
|
|
3485
|
+
removedUpdateIds: previous.entries.filter(entry => !nextEntries.has(entry.updateId)).map(entry => entry.updateId),
|
|
3486
|
+
...(next.acceptedThroughUpdateId !== undefined ? { acceptedThroughUpdateId: next.acceptedThroughUpdateId } : {}) };
|
|
3487
|
+
};
|
|
3488
|
+
const assertInputHeadroomCapacity = (file: TelegramUpdateJournalFile, extraBytes: number): number => {
|
|
3489
|
+
const bytes = assertCapacity(file);
|
|
3490
|
+
if (bytes > maxBytes - extraBytes) {
|
|
3491
|
+
throw createJournalError("capacity", path, "does not retain logical input transition headroom");
|
|
3492
|
+
}
|
|
3493
|
+
return bytes + extraBytes;
|
|
3494
|
+
};
|
|
3495
|
+
const assertInputProgressHeadroom = (current: ReadTelegramUpdateJournalResult,
|
|
3496
|
+
publishedFile: TelegramUpdateJournalFile, updateId: number | readonly number[] | "all",
|
|
3497
|
+
publishedSegment?: TelegramUpdateJournalSegment): void => {
|
|
3498
|
+
const plans = getInputHeadroomTransitionPlans(publishedFile, updateId);
|
|
3499
|
+
if (plans.length === 0) return;
|
|
3500
|
+
const sourceSegments = current.source?.segments ?? [];
|
|
3501
|
+
let baseRetainedCount = sourceSegments.length;
|
|
3502
|
+
let baseRetainedBytes = sourceSegments.reduce((sum, segment) => sum + segment.bytes, 0);
|
|
3503
|
+
let baseRetainedWork = sourceSegments.reduce((sum, segment) => sum + segment.work, 0);
|
|
3504
|
+
const publishedBytes = assertInputHeadroomCapacity(publishedFile, 0);
|
|
3505
|
+
let baseAccounting = current.source?.evidence.kind === "present"
|
|
3506
|
+
? { ...current.source.evidence.accounting }
|
|
3507
|
+
: { files: 1, bytes: publishedBytes, work: getFileWork(publishedFile) };
|
|
3508
|
+
if (publishedSegment) {
|
|
3509
|
+
const segmentWork = publishedSegment.upsertedEntries.length + publishedSegment.removedUpdateIds.length +
|
|
3510
|
+
(publishedSegment.operatorDispositions?.length ?? 0);
|
|
3511
|
+
const segmentBytes = Buffer.byteLength(`${JSON.stringify(publishedSegment, null, 2)}\n`);
|
|
3512
|
+
baseRetainedCount += 1;
|
|
3513
|
+
baseRetainedBytes += segmentBytes;
|
|
3514
|
+
baseRetainedWork += segmentWork;
|
|
3515
|
+
const segmentFirst = { files: baseAccounting.files + 1, bytes: baseAccounting.bytes + segmentBytes,
|
|
3516
|
+
work: baseAccounting.work + segmentWork + getFileWork(publishedFile) };
|
|
3517
|
+
const compactResidue = { files: 1 + baseRetainedCount,
|
|
3518
|
+
bytes: publishedBytes + baseRetainedBytes, work: getFileWork(publishedFile) + baseRetainedWork };
|
|
3519
|
+
baseAccounting = { files: Math.max(segmentFirst.files, compactResidue.files),
|
|
3520
|
+
bytes: Math.max(segmentFirst.bytes, compactResidue.bytes),
|
|
3521
|
+
work: Math.max(segmentFirst.work, compactResidue.work) };
|
|
3522
|
+
}
|
|
3523
|
+
for (const transitions of plans) {
|
|
3524
|
+
let retainedCount = baseRetainedCount;
|
|
3525
|
+
let retainedBytes = baseRetainedBytes;
|
|
3526
|
+
let retainedWork = baseRetainedWork;
|
|
3527
|
+
let accounting = { ...baseAccounting };
|
|
3528
|
+
let state: InputHeadroomState = { file: publishedFile, projectionSlack: 0 };
|
|
3529
|
+
for (const next of transitions) {
|
|
3530
|
+
const segment = createInputHeadroomSegment(state.file, next.file);
|
|
3531
|
+
const segmentCollections = [segment.upsertedEntries.length, segment.removedUpdateIds.length,
|
|
3532
|
+
segment.operatorDispositions?.length ?? 0, next.file.entries.length,
|
|
3533
|
+
next.file.operatorDispositions?.length ?? 0];
|
|
3534
|
+
const segmentWork = segmentCollections[0]! + segmentCollections[1]! + segmentCollections[2]!;
|
|
3535
|
+
const segmentBytes = Buffer.byteLength(`${JSON.stringify(segment, null, 2)}\n`) + next.projectionSlack;
|
|
3536
|
+
const nextBytes = assertInputHeadroomCapacity(next.file, next.projectionSlack);
|
|
3537
|
+
const segmentFirst = { files: accounting.files + 1, bytes: accounting.bytes + segmentBytes,
|
|
3538
|
+
work: accounting.work + segmentWork + getFileWork(next.file) };
|
|
3539
|
+
assertSourceResources(segmentFirst.files, segmentFirst.bytes, segmentFirst.work, segmentCollections);
|
|
3540
|
+
retainedCount += 1;
|
|
3541
|
+
retainedBytes += segmentBytes;
|
|
3542
|
+
retainedWork += segmentWork;
|
|
3543
|
+
const compactResidue = { files: 1 + retainedCount, bytes: nextBytes + retainedBytes,
|
|
3544
|
+
work: getFileWork(next.file) + retainedWork };
|
|
3545
|
+
assertSourceResources(compactResidue.files, compactResidue.bytes, compactResidue.work, segmentCollections);
|
|
3546
|
+
accounting = { files: Math.max(segmentFirst.files, compactResidue.files),
|
|
3547
|
+
bytes: Math.max(segmentFirst.bytes, compactResidue.bytes),
|
|
3548
|
+
work: Math.max(segmentFirst.work, compactResidue.work) };
|
|
3549
|
+
state = next;
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
};
|
|
3553
|
+
|
|
3554
|
+
const publishSourceSegment = (
|
|
3555
|
+
current: ReadTelegramUpdateJournalResult,
|
|
3556
|
+
file: TelegramUpdateJournalFile,
|
|
3557
|
+
segment: TelegramUpdateJournalSegment,
|
|
3558
|
+
publicationBoundary: TelegramUpdateJournalStoreOptions["onPublicationBoundary"],
|
|
3559
|
+
forceCompact: boolean,
|
|
3560
|
+
): { file: TelegramUpdateJournalFile; serializedBytes: number } => {
|
|
3561
|
+
const source = current.source!;
|
|
3562
|
+
if (source.evidence.kind !== "present") throw createJournalError("invalid", path, "requires existing source evidence");
|
|
3563
|
+
const revisedFile = { ...file, revision: segment.revision };
|
|
3564
|
+
const serialized = serializeJournalFile(revisedFile);
|
|
3565
|
+
const serializedBytes = assertCapacity(revisedFile, serialized);
|
|
3566
|
+
const segmentText = `${JSON.stringify(segment, null, 2)}\n`;
|
|
3567
|
+
const segmentBytes = Buffer.byteLength(segmentText);
|
|
3568
|
+
const collections = [segment.upsertedEntries.length, segment.removedUpdateIds.length,
|
|
3569
|
+
segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0];
|
|
3570
|
+
const segmentWork = collections[0]! + collections[1]! + collections[2]!;
|
|
3571
|
+
const fileWork = collections[3]! + collections[4]!;
|
|
3572
|
+
const accounting = source.evidence.accounting;
|
|
3573
|
+
// Both the segment-first state and snapshot-first cleanup residue must fit.
|
|
3574
|
+
assertSourceResources(accounting.files + 1, accounting.bytes + segmentBytes,
|
|
3575
|
+
accounting.work + segmentWork + fileWork, collections);
|
|
3576
|
+
const unapplied = source.segments.filter((item) => Number(item.name.slice(0, 16)) > source.snapshotRevision);
|
|
3577
|
+
const compact = forceCompact ||
|
|
3578
|
+
unapplied.length + 1 >= TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_COUNT ||
|
|
3579
|
+
unapplied.reduce((sum, item) => sum + item.bytes, segmentBytes) >= TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_BYTES;
|
|
3580
|
+
if (compact) {
|
|
3581
|
+
assertSourceResources(accounting.files + 1,
|
|
3582
|
+
serializedBytes + source.segments.reduce((sum, item) => sum + item.bytes, segmentBytes),
|
|
3583
|
+
fileWork + source.segments.reduce((sum, item) => sum + item.work, segmentWork), collections);
|
|
3584
|
+
}
|
|
3585
|
+
const segmentPath = getTelegramUpdateJournalSegmentPath(path, segment.revision);
|
|
3586
|
+
// A crash may retain staging, so keep it outside the strictly enumerated segment directory.
|
|
3587
|
+
writeJournalFile(segmentPath, segmentText, publicationBoundary, path);
|
|
3588
|
+
if (compact) {
|
|
3589
|
+
writeJournalFile(path, serialized, publicationBoundary);
|
|
3590
|
+
// Snapshot scope must not change. Preserve a validated redundant ID witness instead.
|
|
3591
|
+
const witness = file.botIdentity.botId === undefined
|
|
3592
|
+
? source.segments.find((item) => item.botId !== undefined)?.name : undefined;
|
|
3593
|
+
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
3594
|
+
const names = [...source.segments.map((item) => item.name), basename(segmentPath)];
|
|
3595
|
+
for (const name of names) {
|
|
3596
|
+
if (name === witness) continue;
|
|
3597
|
+
try { unlinkSync(join(segmentDirectory, name)); } catch {
|
|
3598
|
+
// Every subset of redundant cleanup residue was admitted above.
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
try { rmdirSync(segmentDirectory); } catch { /* Retained witness or cleanup residue. */ }
|
|
3602
|
+
}
|
|
3603
|
+
return { file: revisedFile, serializedBytes };
|
|
3604
|
+
};
|
|
3605
|
+
|
|
2191
3606
|
const publishMutation = (
|
|
2192
3607
|
current: ReadTelegramUpdateJournalResult,
|
|
2193
3608
|
entries: TelegramUpdateJournalEntry[],
|
|
2194
3609
|
contentChanged: boolean,
|
|
2195
3610
|
operatorDispositions = current.file.operatorDispositions,
|
|
2196
3611
|
acceptedThroughUpdateId = current.file.acceptedThroughUpdateId,
|
|
3612
|
+
publicationBoundary = onPublicationBoundary,
|
|
3613
|
+
inputHeadroom: number | readonly number[] | "all" | false =
|
|
3614
|
+
version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION ? "all" : false,
|
|
2197
3615
|
): { file: TelegramUpdateJournalFile; serializedBytes: number } => {
|
|
2198
|
-
const botIdentity = mergeBotIdentity(
|
|
3616
|
+
const botIdentity = current.source ? current.file.botIdentity : mergeBotIdentity(
|
|
2199
3617
|
current.file.botIdentity,
|
|
2200
3618
|
expectedIdentity,
|
|
2201
3619
|
);
|
|
@@ -2211,7 +3629,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
2211
3629
|
return { file: current.file, serializedBytes: current.serializedBytes };
|
|
2212
3630
|
}
|
|
2213
3631
|
const file: TelegramUpdateJournalFile = {
|
|
2214
|
-
version
|
|
3632
|
+
version,
|
|
2215
3633
|
profile,
|
|
2216
3634
|
botIdentity,
|
|
2217
3635
|
entries,
|
|
@@ -2222,6 +3640,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
2222
3640
|
? { operatorDispositions }
|
|
2223
3641
|
: {}),
|
|
2224
3642
|
};
|
|
3643
|
+
if (withPairingAdmission || current.source) parseJournalFile(file, path, version);
|
|
2225
3644
|
const serialized = serializeJournalFile(file);
|
|
2226
3645
|
const serializedBytes = assertCapacity(file, serialized);
|
|
2227
3646
|
const changed =
|
|
@@ -2232,12 +3651,23 @@ export function createTelegramUpdateJournalStore(
|
|
|
2232
3651
|
return { file, serializedBytes: current.serializedBytes };
|
|
2233
3652
|
}
|
|
2234
3653
|
if (!current.exists) {
|
|
2235
|
-
|
|
3654
|
+
if (current.source) {
|
|
3655
|
+
const collections = [file.entries.length, file.operatorDispositions?.length ?? 0];
|
|
3656
|
+
assertSourceResources(1, serializedBytes, collections[0]! + collections[1]!, collections);
|
|
3657
|
+
}
|
|
3658
|
+
if (inputHeadroom !== false) assertInputProgressHeadroom(current, file, inputHeadroom);
|
|
3659
|
+
writeJournalFile(path, serialized, publicationBoundary);
|
|
2236
3660
|
return { file, serializedBytes };
|
|
2237
3661
|
}
|
|
2238
3662
|
const previousEntries = new Map(
|
|
2239
3663
|
current.file.entries.map((entry) => [entry.updateId, entry]),
|
|
2240
3664
|
);
|
|
3665
|
+
if (withPairingAdmission && entries.some((entry) => {
|
|
3666
|
+
const previous = previousEntries.get(entry.updateId);
|
|
3667
|
+
return previous && previous.preApprovalExcluded !== entry.preApprovalExcluded;
|
|
3668
|
+
})) {
|
|
3669
|
+
throw createJournalError("pairing-evidence", path, "changes immutable exclusion evidence");
|
|
3670
|
+
}
|
|
2241
3671
|
const nextEntries = new Map(entries.map((entry) => [entry.updateId, entry]));
|
|
2242
3672
|
const upsertedEntries = entries.filter(
|
|
2243
3673
|
(entry) =>
|
|
@@ -2248,8 +3678,8 @@ export function createTelegramUpdateJournalStore(
|
|
|
2248
3678
|
.filter((entry) => !nextEntries.has(entry.updateId))
|
|
2249
3679
|
.map((entry) => entry.updateId);
|
|
2250
3680
|
const revision = (current.file.revision ?? 0) + 1;
|
|
2251
|
-
|
|
2252
|
-
version
|
|
3681
|
+
const segment: TelegramUpdateJournalSegment = {
|
|
3682
|
+
version,
|
|
2253
3683
|
revision,
|
|
2254
3684
|
previousRevision: revision - 1,
|
|
2255
3685
|
profile,
|
|
@@ -2265,7 +3695,15 @@ export function createTelegramUpdateJournalStore(
|
|
|
2265
3695
|
)
|
|
2266
3696
|
? { operatorDispositions: operatorDispositions ?? [] }
|
|
2267
3697
|
: {}),
|
|
2268
|
-
}
|
|
3698
|
+
};
|
|
3699
|
+
if (current.source) {
|
|
3700
|
+
if (!isSafePositiveInteger(revision)) throw createJournalError("capacity", path, "exhausted source revisions");
|
|
3701
|
+
const revisedFile = { ...file, revision };
|
|
3702
|
+
if (inputHeadroom !== false) assertInputProgressHeadroom(current, revisedFile, inputHeadroom, segment);
|
|
3703
|
+
return publishSourceSegment(current, file, segment, publicationBoundary,
|
|
3704
|
+
version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION);
|
|
3705
|
+
}
|
|
3706
|
+
publishTelegramUpdateJournalSegmentUnlocked(path, segment, publicationBoundary);
|
|
2269
3707
|
const revisedFile: TelegramUpdateJournalFile = { ...file, revision };
|
|
2270
3708
|
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
2271
3709
|
const segmentNames = readdirSync(segmentDirectory).filter((name) =>
|
|
@@ -2276,6 +3714,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
2276
3714
|
snapshotRevision = parseJournalFile(
|
|
2277
3715
|
JSON.parse(readFileSync(path, "utf8")) as unknown,
|
|
2278
3716
|
path,
|
|
3717
|
+
version,
|
|
2279
3718
|
).revision ?? 0;
|
|
2280
3719
|
} catch (error) {
|
|
2281
3720
|
if ((error as { code?: unknown })?.code !== "ENOENT") throw error;
|
|
@@ -2294,7 +3733,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
2294
3733
|
) {
|
|
2295
3734
|
const compacted = serializeJournalFile(revisedFile);
|
|
2296
3735
|
const compactedBytes = assertCapacity(revisedFile, compacted);
|
|
2297
|
-
writeJournalFile(path, compacted,
|
|
3736
|
+
writeJournalFile(path, compacted, publicationBoundary);
|
|
2298
3737
|
for (const name of segmentNames) {
|
|
2299
3738
|
if (Number(name.slice(0, 16)) <= revision) {
|
|
2300
3739
|
try {
|
|
@@ -2315,9 +3754,9 @@ export function createTelegramUpdateJournalStore(
|
|
|
2315
3754
|
return { file: revisedFile, serializedBytes };
|
|
2316
3755
|
};
|
|
2317
3756
|
|
|
2318
|
-
|
|
3757
|
+
const journal: TelegramUpdateJournalStore = {
|
|
2319
3758
|
read() {
|
|
2320
|
-
return runMutation(() => {
|
|
3759
|
+
return runMutation((readCurrent) => {
|
|
2321
3760
|
const current = readCurrent();
|
|
2322
3761
|
return {
|
|
2323
3762
|
...cloneFile(current.file),
|
|
@@ -2327,7 +3766,25 @@ export function createTelegramUpdateJournalStore(
|
|
|
2327
3766
|
});
|
|
2328
3767
|
},
|
|
2329
3768
|
appendBatch(updates, requestedAcceptedThroughUpdateId) {
|
|
2330
|
-
|
|
3769
|
+
const canonicalUpdates = updates.map((update) => normalizeIncomingJournaledUpdate(update, path));
|
|
3770
|
+
const normalizedUpdates: TelegramJournaledUpdate[] = [];
|
|
3771
|
+
for (const normalized of canonicalUpdates) {
|
|
3772
|
+
const previous = normalizedUpdates.at(-1);
|
|
3773
|
+
if (previous && normalized.update_id < previous.update_id) {
|
|
3774
|
+
throw createJournalError("invalid", path, "received an unordered update batch");
|
|
3775
|
+
}
|
|
3776
|
+
if (previous?.update_id === normalized.update_id) {
|
|
3777
|
+
if (!isDeepStrictEqual(previous, normalized)) {
|
|
3778
|
+
throw createJournalError("conflict", path, `received conflicting update ${normalized.update_id}`);
|
|
3779
|
+
}
|
|
3780
|
+
continue;
|
|
3781
|
+
}
|
|
3782
|
+
normalizedUpdates.push(normalized);
|
|
3783
|
+
}
|
|
3784
|
+
const appendWithEvidence = (preApprovalExcluded?: boolean) => runJournalTransaction((readCurrent) => {
|
|
3785
|
+
if (withPairingAdmission && (typeof preApprovalExcluded !== "boolean" || requestedAcceptedThroughUpdateId === undefined)) {
|
|
3786
|
+
throw createJournalError("pairing-evidence", path, "requires exclusion evidence and an admission cursor");
|
|
3787
|
+
}
|
|
2331
3788
|
if (
|
|
2332
3789
|
requestedAcceptedThroughUpdateId !== undefined &&
|
|
2333
3790
|
!isSafeNonNegativeInteger(requestedAcceptedThroughUpdateId)
|
|
@@ -2339,30 +3796,6 @@ export function createTelegramUpdateJournalStore(
|
|
|
2339
3796
|
);
|
|
2340
3797
|
}
|
|
2341
3798
|
const current = readCurrent();
|
|
2342
|
-
const normalizedUpdates: TelegramJournaledUpdate[] = [];
|
|
2343
|
-
for (const update of updates) {
|
|
2344
|
-
const normalized = normalizeIncomingJournaledUpdate(update, path);
|
|
2345
|
-
const previous = normalizedUpdates.at(-1);
|
|
2346
|
-
if (previous && normalized.update_id < previous.update_id) {
|
|
2347
|
-
throw createJournalError(
|
|
2348
|
-
"invalid",
|
|
2349
|
-
path,
|
|
2350
|
-
"received an unordered update batch",
|
|
2351
|
-
);
|
|
2352
|
-
}
|
|
2353
|
-
if (previous?.update_id === normalized.update_id) {
|
|
2354
|
-
if (!isDeepStrictEqual(previous, normalized)) {
|
|
2355
|
-
throw createJournalError(
|
|
2356
|
-
"conflict",
|
|
2357
|
-
path,
|
|
2358
|
-
`received conflicting update ${normalized.update_id}`,
|
|
2359
|
-
);
|
|
2360
|
-
}
|
|
2361
|
-
continue;
|
|
2362
|
-
}
|
|
2363
|
-
normalizedUpdates.push(normalized);
|
|
2364
|
-
}
|
|
2365
|
-
|
|
2366
3799
|
const entriesById = new Map(
|
|
2367
3800
|
current.file.entries.map((entry) => [entry.updateId, entry]),
|
|
2368
3801
|
);
|
|
@@ -2393,6 +3826,10 @@ export function createTelegramUpdateJournalStore(
|
|
|
2393
3826
|
duplicateUpdateIds.push(update.update_id);
|
|
2394
3827
|
continue;
|
|
2395
3828
|
}
|
|
3829
|
+
if (withPairingAdmission && previousAcceptedThroughUpdateId !== undefined && update.update_id <= previousAcceptedThroughUpdateId) {
|
|
3830
|
+
duplicateUpdateIds.push(update.update_id);
|
|
3831
|
+
continue;
|
|
3832
|
+
}
|
|
2396
3833
|
if (admittedAtMs === undefined) {
|
|
2397
3834
|
admittedAtMs = getNowMs();
|
|
2398
3835
|
if (!isSafeNonNegativeInteger(admittedAtMs)) {
|
|
@@ -2406,6 +3843,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
2406
3843
|
const entry: TelegramUpdateJournalEntry = {
|
|
2407
3844
|
updateId: update.update_id,
|
|
2408
3845
|
update,
|
|
3846
|
+
...(withPairingAdmission ? { preApprovalExcluded: preApprovalExcluded! } : {}),
|
|
2409
3847
|
admittedAtMs,
|
|
2410
3848
|
state: "pending",
|
|
2411
3849
|
};
|
|
@@ -2451,15 +3889,47 @@ export function createTelegramUpdateJournalStore(
|
|
|
2451
3889
|
acceptedThroughUpdateId,
|
|
2452
3890
|
);
|
|
2453
3891
|
return {
|
|
3892
|
+
nonExcludedUpdateIds: normalizedUpdates.flatMap((update) => {
|
|
3893
|
+
const entry = entriesById.get(update.update_id);
|
|
3894
|
+
return entry && entry.preApprovalExcluded !== true ? [entry.updateId] : [];
|
|
3895
|
+
}),
|
|
2454
3896
|
addedUpdateIds,
|
|
2455
3897
|
duplicateUpdateIds,
|
|
2456
3898
|
entryCount: published.file.entries.length,
|
|
2457
3899
|
serializedBytes: published.serializedBytes,
|
|
2458
3900
|
};
|
|
2459
3901
|
});
|
|
3902
|
+
const append = () => {
|
|
3903
|
+
if (withPairedAdmission) {
|
|
3904
|
+
const result = withPairedAdmission(normalizedUpdates, () => appendWithEvidence());
|
|
3905
|
+
if (!result.admitted) throw createJournalError("sender-denied", path, "refused paired-only sender admission");
|
|
3906
|
+
return result.value;
|
|
3907
|
+
}
|
|
3908
|
+
if (withPairingAdmission) return withPairingAdmission(appendWithEvidence);
|
|
3909
|
+
return withSourceSerialization
|
|
3910
|
+
? withSourceSerialization(() => appendWithEvidence())
|
|
3911
|
+
: appendWithEvidence();
|
|
3912
|
+
};
|
|
3913
|
+
const admittedAppend = () => {
|
|
3914
|
+
if (!workspaceAdmission) return append();
|
|
3915
|
+
const operationHash = createHash("sha256").update(path).update("\0");
|
|
3916
|
+
for (const update of canonicalUpdates) {
|
|
3917
|
+
operationHash.update(String(update.update_id)).update("\0");
|
|
3918
|
+
}
|
|
3919
|
+
operationHash.update(String(requestedAcceptedThroughUpdateId ?? "none"));
|
|
3920
|
+
return runWithTelegramWorkspaceAdmissions({
|
|
3921
|
+
ledger: workspaceAdmission,
|
|
3922
|
+
operationId: `journal:${operationHash.digest("hex")}`,
|
|
3923
|
+
operationKind: "journal.append",
|
|
3924
|
+
scopes: getTelegramUpdateJournalAdmissionScopes(canonicalUpdates),
|
|
3925
|
+
operation: append,
|
|
3926
|
+
});
|
|
3927
|
+
};
|
|
3928
|
+
return options.withWriterAdmission
|
|
3929
|
+
? options.withWriterAdmission(admittedAppend) : admittedAppend();
|
|
2460
3930
|
},
|
|
2461
3931
|
markQueued(receipt) {
|
|
2462
|
-
return runMutation(() => {
|
|
3932
|
+
return runMutation((readCurrent) => {
|
|
2463
3933
|
if (
|
|
2464
3934
|
(receipt.queueKind !== "prompt" &&
|
|
2465
3935
|
receipt.queueKind !== "control") ||
|
|
@@ -2556,6 +4026,9 @@ export function createTelegramUpdateJournalStore(
|
|
|
2556
4026
|
`cannot queue missing update ${updateId}`,
|
|
2557
4027
|
);
|
|
2558
4028
|
}
|
|
4029
|
+
if (entry.preApprovalExcluded) {
|
|
4030
|
+
throw createJournalError("pairing-evidence", path, `cannot queue excluded update ${updateId}`);
|
|
4031
|
+
}
|
|
2559
4032
|
if (entry.state === "queued") {
|
|
2560
4033
|
if (
|
|
2561
4034
|
entry.queueKind !== receipt.queueKind ||
|
|
@@ -2581,6 +4054,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
2581
4054
|
updateId: entry.updateId,
|
|
2582
4055
|
update: entry.update,
|
|
2583
4056
|
admittedAtMs: entry.admittedAtMs,
|
|
4057
|
+
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
2584
4058
|
state: "queued",
|
|
2585
4059
|
queueKind: receipt.queueKind,
|
|
2586
4060
|
queueReceiptId: receipt.receiptId,
|
|
@@ -2610,7 +4084,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
2610
4084
|
});
|
|
2611
4085
|
},
|
|
2612
4086
|
markExecutionFailure(input) {
|
|
2613
|
-
return runMutation(() => {
|
|
4087
|
+
return runMutation((readCurrent) => {
|
|
2614
4088
|
if (
|
|
2615
4089
|
!isSafeNonNegativeInteger(input.updateId) ||
|
|
2616
4090
|
!isSafeNonNegativeInteger(input.expectedAttemptCount) ||
|
|
@@ -2705,6 +4179,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
2705
4179
|
updateId: entry.updateId,
|
|
2706
4180
|
update: entry.update,
|
|
2707
4181
|
admittedAtMs: entry.admittedAtMs,
|
|
4182
|
+
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
2708
4183
|
state: input.disposition,
|
|
2709
4184
|
failure,
|
|
2710
4185
|
...(input.disposition === "retry-wait"
|
|
@@ -2730,7 +4205,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
2730
4205
|
});
|
|
2731
4206
|
},
|
|
2732
4207
|
applyOperatorDisposition(input) {
|
|
2733
|
-
return runMutation(() => {
|
|
4208
|
+
return runMutation((readCurrent) => {
|
|
2734
4209
|
if (
|
|
2735
4210
|
!isSafeNonNegativeInteger(input.updateId) ||
|
|
2736
4211
|
!isBoundedString(
|
|
@@ -2750,6 +4225,9 @@ export function createTelegramUpdateJournalStore(
|
|
|
2750
4225
|
(candidate) => candidate.failureId === input.failureId,
|
|
2751
4226
|
);
|
|
2752
4227
|
if (existingDisposition) {
|
|
4228
|
+
if ("dispositionKind" in existingDisposition)
|
|
4229
|
+
throw createJournalError("conflict", path,
|
|
4230
|
+
`terminal failure ${input.failureId} collides with legacy custody authority`);
|
|
2753
4231
|
if (
|
|
2754
4232
|
existingDisposition.updateId !== input.updateId ||
|
|
2755
4233
|
existingDisposition.action !== input.action
|
|
@@ -2809,6 +4287,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
2809
4287
|
updateId: entry.updateId,
|
|
2810
4288
|
update: entry.update,
|
|
2811
4289
|
admittedAtMs: entry.admittedAtMs,
|
|
4290
|
+
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
2812
4291
|
state: "retry-wait" as const,
|
|
2813
4292
|
failure: entry.failure,
|
|
2814
4293
|
nextRetryAtMs: disposition.committedAtMs,
|
|
@@ -2832,8 +4311,74 @@ export function createTelegramUpdateJournalStore(
|
|
|
2832
4311
|
};
|
|
2833
4312
|
});
|
|
2834
4313
|
},
|
|
4314
|
+
applyLegacyCustodyDisposition(authority) {
|
|
4315
|
+
return runMutation((readCurrent) => {
|
|
4316
|
+
const current = readCurrent();
|
|
4317
|
+
if (current.file.version !== TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION)
|
|
4318
|
+
throw createJournalError("conflict", path,
|
|
4319
|
+
"legacy custody disposition requires a v3 journal");
|
|
4320
|
+
const existing = current.file.operatorDispositions?.find(
|
|
4321
|
+
candidate => candidate.failureId === authority.dispositionId);
|
|
4322
|
+
if (existing) {
|
|
4323
|
+
if (!("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody")
|
|
4324
|
+
throw createJournalError("conflict", path,
|
|
4325
|
+
`legacy custody disposition ${authority.dispositionId} already has another authority`);
|
|
4326
|
+
const normalizedDuplicate = normalizeTelegramUpdateJournalLegacyCustodyDispositionAuthority(
|
|
4327
|
+
authority, { updateId: existing.updateId, state: "retry-wait", attemptCount: 1,
|
|
4328
|
+
failedAtMs: 0, failureClass: "retained-audit", summary: "retained-audit",
|
|
4329
|
+
evidenceSha256: existing.evidenceSha256 });
|
|
4330
|
+
if (!normalizedDuplicate || existing.action !== normalizedDuplicate.action ||
|
|
4331
|
+
existing.operatorAuthorityId !== normalizedDuplicate.operatorAuthorityId ||
|
|
4332
|
+
existing.authorizedAtMs !== normalizedDuplicate.authorizedAtMs)
|
|
4333
|
+
throw createJournalError("conflict", path,
|
|
4334
|
+
`legacy custody disposition ${authority.dispositionId} already has another authority`);
|
|
4335
|
+
let authorized = false;
|
|
4336
|
+
try { authorized = options.authorizeLegacyCustodyDisposition?.(normalizedDuplicate) === true; }
|
|
4337
|
+
catch { authorized = false; }
|
|
4338
|
+
if (!authorized) throw createJournalError("conflict", path,
|
|
4339
|
+
"legacy custody disposition is unauthorized");
|
|
4340
|
+
return { disposition: { ...existing }, duplicate: true,
|
|
4341
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
4342
|
+
}
|
|
4343
|
+
const entry = current.file.entries.find(candidate => candidate.updateId === authority.updateId);
|
|
4344
|
+
const evidence = entry && createTelegramUpdateJournalLegacyCustodyEvidence(entry);
|
|
4345
|
+
if (!evidence) throw createJournalError("conflict", path,
|
|
4346
|
+
`cannot dispose non-quarantined update ${authority.updateId}`);
|
|
4347
|
+
const normalized = normalizeTelegramUpdateJournalLegacyCustodyDispositionAuthority(
|
|
4348
|
+
authority, evidence);
|
|
4349
|
+
if (!normalized) throw createJournalError("conflict", path,
|
|
4350
|
+
`legacy custody evidence changed for update ${authority.updateId}`);
|
|
4351
|
+
let authorized = false;
|
|
4352
|
+
try { authorized = options.authorizeLegacyCustodyDisposition?.(normalized) === true; }
|
|
4353
|
+
catch { authorized = false; }
|
|
4354
|
+
if (!authorized) throw createJournalError("conflict", path,
|
|
4355
|
+
"legacy custody disposition is unauthorized");
|
|
4356
|
+
const nowMs = getNowMs();
|
|
4357
|
+
if (!isSafeNonNegativeInteger(nowMs)) throw createJournalError("invalid", path,
|
|
4358
|
+
"received an invalid legacy custody disposition timestamp");
|
|
4359
|
+
const disposition: TelegramUpdateJournalLegacyCustodyDisposition = {
|
|
4360
|
+
dispositionKind: "legacy-custody", failureId: normalized.dispositionId,
|
|
4361
|
+
updateId: normalized.updateId, action: normalized.action,
|
|
4362
|
+
committedAtMs: Math.max(nowMs, normalized.authorizedAtMs),
|
|
4363
|
+
evidenceSha256: normalized.evidenceSha256,
|
|
4364
|
+
operatorAuthorityId: normalized.operatorAuthorityId,
|
|
4365
|
+
authorizedAtMs: normalized.authorizedAtMs };
|
|
4366
|
+
const nextEntries = normalized.action === "discard"
|
|
4367
|
+
? current.file.entries.filter(candidate => candidate.updateId !== entry.updateId)
|
|
4368
|
+
: current.file.entries.map(candidate => candidate.updateId === entry.updateId
|
|
4369
|
+
? { updateId: entry.updateId, update: entry.update,
|
|
4370
|
+
admittedAtMs: entry.admittedAtMs,
|
|
4371
|
+
...(entry.preApprovalExcluded === undefined ? {} :
|
|
4372
|
+
{ preApprovalExcluded: entry.preApprovalExcluded }), state: "pending" as const }
|
|
4373
|
+
: candidate);
|
|
4374
|
+
const published = publishMutation(current, nextEntries, true,
|
|
4375
|
+
[...(current.file.operatorDispositions ?? []), disposition]);
|
|
4376
|
+
return { disposition: { ...disposition }, duplicate: false,
|
|
4377
|
+
entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
4378
|
+
});
|
|
4379
|
+
},
|
|
2835
4380
|
offerQueuedHandoff(input) {
|
|
2836
|
-
return runMutation(() => {
|
|
4381
|
+
return runMutation((readCurrent) => {
|
|
2837
4382
|
const { expectedOwner, recipientOwner, requestedIds, handoffId } =
|
|
2838
4383
|
validateQueueHandoffInput(input, "offer");
|
|
2839
4384
|
if (isTelegramUpdateJournalQueueOwnerProcess(expectedOwner, recipientOwner)) {
|
|
@@ -2919,7 +4464,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
2919
4464
|
});
|
|
2920
4465
|
},
|
|
2921
4466
|
acceptQueuedHandoff(input) {
|
|
2922
|
-
return runMutation(() => {
|
|
4467
|
+
return runMutation((readCurrent) => {
|
|
2923
4468
|
const { expectedOwner, recipientOwner, requestedIds, handoffId } =
|
|
2924
4469
|
validateQueueHandoffInput(input, "accept");
|
|
2925
4470
|
const current = readCurrent();
|
|
@@ -3003,10 +4548,13 @@ export function createTelegramUpdateJournalStore(
|
|
|
3003
4548
|
updateId: entry.updateId,
|
|
3004
4549
|
update: entry.update,
|
|
3005
4550
|
admittedAtMs: entry.admittedAtMs,
|
|
4551
|
+
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
3006
4552
|
state: "queued" as const,
|
|
3007
4553
|
queueKind: input.queueKind,
|
|
3008
4554
|
queueReceiptId: input.receiptId,
|
|
3009
4555
|
queueOwner: cloneJournalQueueOwner(queueOwner),
|
|
4556
|
+
...(entry.inputProvenance
|
|
4557
|
+
? { inputProvenance: structuredClone(entry.inputProvenance) } : {}),
|
|
3010
4558
|
}
|
|
3011
4559
|
: entry,
|
|
3012
4560
|
),
|
|
@@ -3024,7 +4572,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
3024
4572
|
});
|
|
3025
4573
|
},
|
|
3026
4574
|
cancelQueuedHandoff(input) {
|
|
3027
|
-
return runMutation(() => {
|
|
4575
|
+
return runMutation((readCurrent) => {
|
|
3028
4576
|
const { expectedOwner, recipientOwner, requestedIds, handoffId } =
|
|
3029
4577
|
validateQueueHandoffInput(input, "cancel");
|
|
3030
4578
|
const current = readCurrent();
|
|
@@ -3086,7 +4634,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
3086
4634
|
});
|
|
3087
4635
|
},
|
|
3088
4636
|
completeQueued(receipts) {
|
|
3089
|
-
return runMutation(() => {
|
|
4637
|
+
return runMutation((readCurrent) => {
|
|
3090
4638
|
if (!Array.isArray(receipts) || receipts.length === 0) {
|
|
3091
4639
|
throw createJournalError(
|
|
3092
4640
|
"invalid",
|
|
@@ -3196,7 +4744,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
3196
4744
|
});
|
|
3197
4745
|
},
|
|
3198
4746
|
discardQueued(input) {
|
|
3199
|
-
return runMutation(() => {
|
|
4747
|
+
return runMutation((readCurrent) => {
|
|
3200
4748
|
if (
|
|
3201
4749
|
(input.queueKind !== "prompt" &&
|
|
3202
4750
|
input.queueKind !== "control") ||
|
|
@@ -3283,7 +4831,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
3283
4831
|
});
|
|
3284
4832
|
},
|
|
3285
4833
|
recoverDeadQueueOwner(input) {
|
|
3286
|
-
return runMutation(() => {
|
|
4834
|
+
return runMutation((readCurrent) => {
|
|
3287
4835
|
if (!getQueueProcessLiveness) {
|
|
3288
4836
|
throw createJournalError(
|
|
3289
4837
|
"conflict",
|
|
@@ -3396,7 +4944,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
3396
4944
|
});
|
|
3397
4945
|
},
|
|
3398
4946
|
removeCompleted(updateIds) {
|
|
3399
|
-
return runMutation(() => {
|
|
4947
|
+
return runMutation((readCurrent) => {
|
|
3400
4948
|
const current = readCurrent();
|
|
3401
4949
|
const requestedIds = new Set<number>();
|
|
3402
4950
|
for (const updateId of updateIds) {
|
|
@@ -3442,4 +4990,514 @@ export function createTelegramUpdateJournalStore(
|
|
|
3442
4990
|
});
|
|
3443
4991
|
},
|
|
3444
4992
|
};
|
|
4993
|
+
if (!getInputContext) return { journal };
|
|
4994
|
+
// Strict source acquisition already requires this exact stored receipt scope.
|
|
4995
|
+
const bindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
4996
|
+
const createInputSourceReference = (updateId: number): TelegramInputJournalSourceReference => ({
|
|
4997
|
+
journalBindingKey: bindingKey,
|
|
4998
|
+
tokenSha256: expectedIdentity.tokenSha256,
|
|
4999
|
+
updateId,
|
|
5000
|
+
});
|
|
5001
|
+
const createInputReceipt = (updateId: number,
|
|
5002
|
+
owner: TelegramUpdateJournalQueueOwner): TelegramInputJournalReceipt => ({
|
|
5003
|
+
...createInputSourceReference(updateId),
|
|
5004
|
+
owner: cloneJournalQueueOwner(owner),
|
|
5005
|
+
});
|
|
5006
|
+
const assertInputProcess = (identity: TelegramUpdateJournalQueueOwnerIdentity): void => {
|
|
5007
|
+
if (!queueRuntimeIdentity ||
|
|
5008
|
+
identity.instanceId !== queueRuntimeIdentity.instanceId ||
|
|
5009
|
+
identity.processId !== queueRuntimeIdentity.processId ||
|
|
5010
|
+
identity.processBirthId !== queueRuntimeIdentity.processBirthId) {
|
|
5011
|
+
throw createJournalError("conflict", path, "input custody belongs to another runtime");
|
|
5012
|
+
}
|
|
5013
|
+
};
|
|
5014
|
+
const inputOwnerMatchesIdentity = (owner: TelegramUpdateJournalQueueOwner,
|
|
5015
|
+
identity: TelegramUpdateJournalQueueOwnerIdentity): boolean =>
|
|
5016
|
+
isTelegramUpdateJournalQueueOwnerProcess(owner, identity) &&
|
|
5017
|
+
owner.sessionGeneration === identity.sessionGeneration;
|
|
5018
|
+
const currentInputContext = (): TelegramInputJournalContext => {
|
|
5019
|
+
const observed = getInputContext();
|
|
5020
|
+
if (observed === undefined) throw createJournalError("conflict", path, "input execution context is unavailable");
|
|
5021
|
+
const owner = validateJournalQueueOwnerIdentity(observed.owner, path);
|
|
5022
|
+
const recipientBindingKey = observed.recipientBindingKey;
|
|
5023
|
+
if (!isBoundedString(recipientBindingKey, TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH) ||
|
|
5024
|
+
!recipientBindingKey.trim()) {
|
|
5025
|
+
throw createJournalError("invalid", path, "input execution binding is invalid");
|
|
5026
|
+
}
|
|
5027
|
+
assertInputProcess(owner);
|
|
5028
|
+
return { owner, recipientBindingKey };
|
|
5029
|
+
};
|
|
5030
|
+
const runInputAdmission = <T>(kind: string, operation: () => T): T =>
|
|
5031
|
+
workspaceAdmission ? runWithTelegramWorkspaceAdmissions({
|
|
5032
|
+
ledger: workspaceAdmission, operationId: `input:${randomUUID()}`, operationKind: `journal.input.${kind}`,
|
|
5033
|
+
scopes: [{ kind: "profile" }], operation,
|
|
5034
|
+
}) : operation();
|
|
5035
|
+
const runInputMutation = <T>(kind: string, operation: (read: typeof readCurrent) => T): T =>
|
|
5036
|
+
runInputAdmission(kind, () => runMutation(operation));
|
|
5037
|
+
const publishInputMutation = (current: ReadTelegramUpdateJournalResult,
|
|
5038
|
+
entries: TelegramUpdateJournalEntry[], updateId: number | readonly number[],
|
|
5039
|
+
context: TelegramInputJournalContext) =>
|
|
5040
|
+
publishMutation(current, entries, true, current.file.operatorDispositions, current.file.acceptedThroughUpdateId,
|
|
5041
|
+
(boundary, publicationPath) => {
|
|
5042
|
+
onPublicationBoundary?.(boundary, publicationPath);
|
|
5043
|
+
if (!isDeepStrictEqual(currentInputContext(), context)) {
|
|
5044
|
+
throw createJournalError("conflict", path, "input execution context changed before publication");
|
|
5045
|
+
}
|
|
5046
|
+
}, updateId);
|
|
5047
|
+
const publishInputSettlement = (current: ReadTelegramUpdateJournalResult,
|
|
5048
|
+
entries: TelegramUpdateJournalEntry[], contentChanged: boolean) => publishMutation(current, entries,
|
|
5049
|
+
contentChanged, current.file.operatorDispositions, current.file.acceptedThroughUpdateId,
|
|
5050
|
+
onPublicationBoundary, false);
|
|
5051
|
+
const normalizeInputSourceReference = (value: TelegramInputJournalSourceReference,
|
|
5052
|
+
operation: string): TelegramInputJournalSourceReference => {
|
|
5053
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["journalBindingKey", "tokenSha256", "updateId"])) {
|
|
5054
|
+
throw createJournalError("invalid", path, `received invalid input handoff ${operation} source`);
|
|
5055
|
+
}
|
|
5056
|
+
const journalBindingKey = value.journalBindingKey;
|
|
5057
|
+
const updateId = value.updateId;
|
|
5058
|
+
if (journalBindingKey !== bindingKey || value.tokenSha256 !== expectedIdentity.tokenSha256 ||
|
|
5059
|
+
!isSafeNonNegativeInteger(updateId)) {
|
|
5060
|
+
throw createJournalError("conflict", path, "input receipt does not match its source identity");
|
|
5061
|
+
}
|
|
5062
|
+
return { journalBindingKey, tokenSha256: expectedIdentity.tokenSha256, updateId };
|
|
5063
|
+
};
|
|
5064
|
+
const normalizeInputReceipt = (value: TelegramInputJournalReceipt,
|
|
5065
|
+
requireCurrentProcess = true): TelegramInputJournalReceipt => {
|
|
5066
|
+
if (!isRecord(value) ||
|
|
5067
|
+
!hasOnlyKeys(value, ["journalBindingKey", "tokenSha256", "updateId", "owner"])) {
|
|
5068
|
+
throw createJournalError("invalid", path, "received an invalid input receipt");
|
|
5069
|
+
}
|
|
5070
|
+
const source = normalizeInputSourceReference({ journalBindingKey: value.journalBindingKey,
|
|
5071
|
+
tokenSha256: value.tokenSha256, updateId: value.updateId }, "receipt");
|
|
5072
|
+
const owner = validateJournalQueueOwner(value.owner, path);
|
|
5073
|
+
if (requireCurrentProcess) assertInputProcess(owner);
|
|
5074
|
+
return { ...source, owner };
|
|
5075
|
+
};
|
|
5076
|
+
const normalizeInputHandoffOffer = (value: TelegramInputJournalHandoffOfferInput) => {
|
|
5077
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["receipt", "recipientOwner", "handoffToken"]) ||
|
|
5078
|
+
!isBoundedString(value.handoffToken, TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH) ||
|
|
5079
|
+
value.handoffToken.length < TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH) {
|
|
5080
|
+
throw createJournalError("invalid", path, "received invalid input handoff offer");
|
|
5081
|
+
}
|
|
5082
|
+
const receipt = normalizeInputReceipt(value.receipt as TelegramInputJournalReceipt);
|
|
5083
|
+
const recipientOwner = validateJournalQueueOwnerIdentity(value.recipientOwner, path);
|
|
5084
|
+
if (isTelegramUpdateJournalQueueOwnerProcess(receipt.owner, recipientOwner)) {
|
|
5085
|
+
throw createJournalError("conflict", path, "cannot transfer input custody to the same runtime process");
|
|
5086
|
+
}
|
|
5087
|
+
return { receipt, recipientOwner, handoffToken: value.handoffToken };
|
|
5088
|
+
};
|
|
5089
|
+
const normalizeInputHandoffId = (value: unknown, operation: "accept" | "cancel") => {
|
|
5090
|
+
if (!isTelegramInputHandoffId(value)) {
|
|
5091
|
+
throw createJournalError("invalid", path, `received invalid input handoff ${operation}`);
|
|
5092
|
+
}
|
|
5093
|
+
return value;
|
|
5094
|
+
};
|
|
5095
|
+
const normalizeInputHandoffAccept = (value: TelegramInputJournalHandoffAcceptInput) => {
|
|
5096
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["source", "recipientOwner", "handoffId"])) {
|
|
5097
|
+
throw createJournalError("invalid", path, "received invalid input handoff accept");
|
|
5098
|
+
}
|
|
5099
|
+
const source = normalizeInputSourceReference(
|
|
5100
|
+
value.source as TelegramInputJournalSourceReference, "accept");
|
|
5101
|
+
const recipientOwner = validateJournalQueueOwnerIdentity(value.recipientOwner, path);
|
|
5102
|
+
assertInputProcess(recipientOwner);
|
|
5103
|
+
return { source, recipientOwner, handoffId: normalizeInputHandoffId(value.handoffId, "accept") };
|
|
5104
|
+
};
|
|
5105
|
+
const normalizeInputHandoffCancel = (value: TelegramInputJournalHandoffCancelInput) => {
|
|
5106
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["receipt", "recipientOwner", "handoffId"])) {
|
|
5107
|
+
throw createJournalError("invalid", path, "received invalid input handoff cancel");
|
|
5108
|
+
}
|
|
5109
|
+
return { receipt: normalizeInputReceipt(value.receipt as TelegramInputJournalReceipt),
|
|
5110
|
+
recipientOwner: validateJournalQueueOwnerIdentity(value.recipientOwner, path),
|
|
5111
|
+
handoffId: normalizeInputHandoffId(value.handoffId, "cancel") };
|
|
5112
|
+
};
|
|
5113
|
+
const normalizeInputQueue = (value: TelegramInputJournalQueueInput) => {
|
|
5114
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["queueKind", "receiptId", "receipts"]) ||
|
|
5115
|
+
(value.queueKind !== "prompt" && value.queueKind !== "control") ||
|
|
5116
|
+
!isBoundedString(value.receiptId, TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH) ||
|
|
5117
|
+
!value.receiptId.trim() || !Array.isArray(value.receipts) || value.receipts.length === 0) {
|
|
5118
|
+
throw createJournalError("invalid", path, "received invalid input queue transition");
|
|
5119
|
+
}
|
|
5120
|
+
const receipts = value.receipts.map(receipt =>
|
|
5121
|
+
normalizeInputReceipt(receipt as TelegramInputJournalReceipt)).sort((left, right) => left.updateId - right.updateId);
|
|
5122
|
+
if (receipts.some((receipt, index) => index > 0 && receipt.updateId === receipts[index - 1]!.updateId)) {
|
|
5123
|
+
throw createJournalError("invalid", path, "received duplicate input queue transition receipts");
|
|
5124
|
+
}
|
|
5125
|
+
return { queueKind: value.queueKind, receiptId: value.receiptId, receipts };
|
|
5126
|
+
};
|
|
5127
|
+
const ownedInput = (current: ReadTelegramUpdateJournalResult, receipt: TelegramInputJournalReceipt): TelegramUpdateJournalEntry => {
|
|
5128
|
+
const entry = current.file.entries.find(candidate => candidate.updateId === receipt.updateId);
|
|
5129
|
+
if (!entry?.inputClaim || entry.state !== "pending" ||
|
|
5130
|
+
!areTelegramUpdateJournalQueueOwnersEqual(entry.inputClaim.owner, receipt.owner)) {
|
|
5131
|
+
throw createJournalError("conflict", path, "input receipt lost its exact acquisition or phase");
|
|
5132
|
+
}
|
|
5133
|
+
return entry;
|
|
5134
|
+
};
|
|
5135
|
+
const getReadyInputRelease = (current: ReadTelegramUpdateJournalResult,
|
|
5136
|
+
receipt: TelegramInputJournalReceipt, allowOffered = false): {
|
|
5137
|
+
entry: TelegramUpdateJournalEntry; unclaimed: boolean;
|
|
5138
|
+
} => {
|
|
5139
|
+
const entry = current.file.entries.find(candidate => candidate.updateId === receipt.updateId);
|
|
5140
|
+
if (!entry || entry.state !== "pending" || entry.preApprovalExcluded !== false) {
|
|
5141
|
+
throw createJournalError("conflict", path, "input is unavailable for ready-claim release");
|
|
5142
|
+
}
|
|
5143
|
+
if (!entry.inputClaim) return { entry, unclaimed: true };
|
|
5144
|
+
if (!areTelegramUpdateJournalQueueOwnersEqual(entry.inputClaim.owner, receipt.owner) ||
|
|
5145
|
+
entry.inputClaim.phase !== "ready" || (!allowOffered && entry.inputClaim.handoff !== undefined)) {
|
|
5146
|
+
throw createJournalError("conflict", path, "cannot release stale, running, or offered input authority");
|
|
5147
|
+
}
|
|
5148
|
+
return { entry, unclaimed: false };
|
|
5149
|
+
};
|
|
5150
|
+
const publishReadyInputRelease = (current: ReadTelegramUpdateJournalResult,
|
|
5151
|
+
entry: TelegramUpdateJournalEntry) => {
|
|
5152
|
+
const released = cloneEntry(entry);
|
|
5153
|
+
delete released.inputClaim;
|
|
5154
|
+
return publishMutation(current, current.file.entries.map(candidate =>
|
|
5155
|
+
candidate.updateId === entry.updateId ? released : candidate), true,
|
|
5156
|
+
current.file.operatorDispositions, current.file.acceptedThroughUpdateId,
|
|
5157
|
+
onPublicationBoundary, entry.updateId);
|
|
5158
|
+
};
|
|
5159
|
+
const input: TelegramInputJournalStore = {
|
|
5160
|
+
read: journal.read,
|
|
5161
|
+
appendBatch: journal.appendBatch,
|
|
5162
|
+
listLegacyCustodyCandidates() {
|
|
5163
|
+
const sourceAccess = options.sourceAccess!;
|
|
5164
|
+
const evidence = readTelegramUpdateJournalSource({
|
|
5165
|
+
directory: sourceAccess.directory, path, profile,
|
|
5166
|
+
botIdentity: expectedIdentity, limits: sourceAccess.limits,
|
|
5167
|
+
version: TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
|
|
5168
|
+
});
|
|
5169
|
+
return evidence.kind === "present"
|
|
5170
|
+
? listTelegramUpdateJournalLegacyCustodyCandidates(evidence.file) : [];
|
|
5171
|
+
},
|
|
5172
|
+
applyLegacyCustodyDisposition(value) {
|
|
5173
|
+
return runInputAdmission("legacy-custody-disposition", () =>
|
|
5174
|
+
journal.applyLegacyCustodyDisposition(value));
|
|
5175
|
+
},
|
|
5176
|
+
completeQueued(receipts) {
|
|
5177
|
+
return runInputAdmission("complete-queued", () => journal.completeQueued(receipts));
|
|
5178
|
+
},
|
|
5179
|
+
discardQueued(value) {
|
|
5180
|
+
return runInputAdmission("discard-queued", () => journal.discardQueued(value));
|
|
5181
|
+
},
|
|
5182
|
+
recoverDeadQueueOwner(value) {
|
|
5183
|
+
return runInputAdmission("recover-queued", () => journal.recoverDeadQueueOwner(value));
|
|
5184
|
+
},
|
|
5185
|
+
offerQueuedHandoff(value) {
|
|
5186
|
+
return runInputAdmission("offer-queued-handoff", () => journal.offerQueuedHandoff(value));
|
|
5187
|
+
},
|
|
5188
|
+
acceptQueuedHandoff(value) {
|
|
5189
|
+
return runInputAdmission("accept-queued-handoff", () => journal.acceptQueuedHandoff(value));
|
|
5190
|
+
},
|
|
5191
|
+
cancelQueuedHandoff(value) {
|
|
5192
|
+
return runInputAdmission("cancel-queued-handoff", () => journal.cancelQueuedHandoff(value));
|
|
5193
|
+
},
|
|
5194
|
+
removeExcluded(updateIds) {
|
|
5195
|
+
const requestedIds = new Set<number>();
|
|
5196
|
+
for (const updateId of updateIds) {
|
|
5197
|
+
if (!isSafeNonNegativeInteger(updateId)) {
|
|
5198
|
+
throw createJournalError("invalid", path, "received an invalid exclusion removal update id");
|
|
5199
|
+
}
|
|
5200
|
+
requestedIds.add(updateId);
|
|
5201
|
+
}
|
|
5202
|
+
return runInputMutation("remove-excluded", read => {
|
|
5203
|
+
const current = read();
|
|
5204
|
+
if (!current.exists || [...requestedIds].some(id => id > (current.file.acceptedThroughUpdateId ?? -1))) {
|
|
5205
|
+
throw createJournalError("conflict", path, "exclusion removal lacks retained source admission evidence");
|
|
5206
|
+
}
|
|
5207
|
+
const selected = current.file.entries.filter(entry => requestedIds.has(entry.updateId));
|
|
5208
|
+
if (selected.some(entry => entry.preApprovalExcluded !== true)) {
|
|
5209
|
+
throw createJournalError("conflict", path, "cannot remove input without immutable exclusion evidence");
|
|
5210
|
+
}
|
|
5211
|
+
// Strict v3 decoding already excludes raw claims and queued authority on vetoed entries.
|
|
5212
|
+
const removedUpdateIds = selected.map(entry => entry.updateId);
|
|
5213
|
+
const published = publishInputSettlement(current,
|
|
5214
|
+
current.file.entries.filter(entry => !requestedIds.has(entry.updateId)), removedUpdateIds.length > 0);
|
|
5215
|
+
return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
5216
|
+
});
|
|
5217
|
+
},
|
|
5218
|
+
releaseInput(value) {
|
|
5219
|
+
const receipt = normalizeInputReceipt(value);
|
|
5220
|
+
return runInputMutation("release", read => {
|
|
5221
|
+
const current = read();
|
|
5222
|
+
const release = getReadyInputRelease(current, receipt);
|
|
5223
|
+
if (release.unclaimed) return { released: false,
|
|
5224
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
5225
|
+
const published = publishReadyInputRelease(current, release.entry);
|
|
5226
|
+
return { released: true, entryCount: published.file.entries.length,
|
|
5227
|
+
serializedBytes: published.serializedBytes };
|
|
5228
|
+
});
|
|
5229
|
+
},
|
|
5230
|
+
recoverReadyInput(value) {
|
|
5231
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["receipt", "recoveryOwner"])) {
|
|
5232
|
+
throw createJournalError("invalid", path, "received invalid ready-input recovery authority");
|
|
5233
|
+
}
|
|
5234
|
+
const receipt = normalizeInputReceipt(value.receipt as TelegramInputJournalReceipt, false);
|
|
5235
|
+
const recoveryOwner = validateJournalQueueOwnerIdentity(value.recoveryOwner, path);
|
|
5236
|
+
assertInputProcess(recoveryOwner);
|
|
5237
|
+
return runInputMutation("recover-ready", read => {
|
|
5238
|
+
const current = read();
|
|
5239
|
+
const release = getReadyInputRelease(current, receipt, true);
|
|
5240
|
+
if (release.unclaimed) return { status: "unclaimed" as const,
|
|
5241
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
5242
|
+
let liveness: TelegramProcessLiveness;
|
|
5243
|
+
try {
|
|
5244
|
+
liveness = getQueueProcessLiveness({ processId: receipt.owner.processId,
|
|
5245
|
+
processBirthId: receipt.owner.processBirthId });
|
|
5246
|
+
} catch (error) {
|
|
5247
|
+
throw createJournalError("io", path, "could not prove ready input owner liveness", error);
|
|
5248
|
+
}
|
|
5249
|
+
if (liveness !== "dead") return { status: liveness === "alive" ? "owner-alive" as const : "owner-unverifiable" as const,
|
|
5250
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
5251
|
+
const published = publishReadyInputRelease(current, release.entry);
|
|
5252
|
+
return { status: "recovered" as const, entryCount: published.file.entries.length,
|
|
5253
|
+
serializedBytes: published.serializedBytes };
|
|
5254
|
+
});
|
|
5255
|
+
},
|
|
5256
|
+
offerInputHandoff(value) {
|
|
5257
|
+
const { receipt, recipientOwner, handoffToken } = normalizeInputHandoffOffer(value);
|
|
5258
|
+
return runInputMutation("offer", read => {
|
|
5259
|
+
const context = currentInputContext();
|
|
5260
|
+
const current = read();
|
|
5261
|
+
const entry = ownedInput(current, receipt);
|
|
5262
|
+
const claim = entry.inputClaim!;
|
|
5263
|
+
if (claim.phase !== "ready" || !inputOwnerMatchesIdentity(receipt.owner, context.owner) ||
|
|
5264
|
+
claim.recipientBindingKey !== context.recipientBindingKey) {
|
|
5265
|
+
throw createJournalError("conflict", path, "input handoff donor authority is stale or running");
|
|
5266
|
+
}
|
|
5267
|
+
if (claim.handoff) {
|
|
5268
|
+
if (!isDeepStrictEqual(claim.handoff.recipientOwner, recipientOwner)) {
|
|
5269
|
+
throw createJournalError("conflict", path, "input already has another handoff offer");
|
|
5270
|
+
}
|
|
5271
|
+
return { source: createInputSourceReference(receipt.updateId),
|
|
5272
|
+
handoff: cloneJournalQueueHandoff(claim.handoff),
|
|
5273
|
+
previousOwner: cloneJournalQueueOwner(receipt.owner), duplicate: true,
|
|
5274
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
5275
|
+
}
|
|
5276
|
+
const handoffId = createTelegramInputHandoffId({ handoffToken, journalBindingKey: bindingKey,
|
|
5277
|
+
updateId: receipt.updateId, donorOwner: receipt.owner, recipientOwner,
|
|
5278
|
+
recipientBindingKey: claim.recipientBindingKey });
|
|
5279
|
+
const offeredAtMs = getNowMs();
|
|
5280
|
+
if (!isSafeNonNegativeInteger(offeredAtMs)) {
|
|
5281
|
+
throw createJournalError("invalid", path, "received an invalid input handoff offer timestamp");
|
|
5282
|
+
}
|
|
5283
|
+
const handoff: TelegramUpdateJournalInputHandoff = { handoffId, offeredAtMs,
|
|
5284
|
+
recipientOwner: { ...recipientOwner } };
|
|
5285
|
+
const offeredClaim = { ...claim, handoff };
|
|
5286
|
+
const published = publishInputMutation(current, current.file.entries.map(candidate =>
|
|
5287
|
+
candidate.updateId === receipt.updateId ? { ...candidate, inputClaim: offeredClaim } : candidate),
|
|
5288
|
+
receipt.updateId, context);
|
|
5289
|
+
return { source: createInputSourceReference(receipt.updateId),
|
|
5290
|
+
handoff: cloneJournalQueueHandoff(handoff),
|
|
5291
|
+
previousOwner: cloneJournalQueueOwner(receipt.owner), duplicate: false,
|
|
5292
|
+
entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
5293
|
+
});
|
|
5294
|
+
},
|
|
5295
|
+
acceptInputHandoff(value) {
|
|
5296
|
+
const { source, recipientOwner, handoffId } = normalizeInputHandoffAccept(value);
|
|
5297
|
+
return runInputMutation("accept", read => {
|
|
5298
|
+
const context = currentInputContext();
|
|
5299
|
+
const current = read();
|
|
5300
|
+
const entry = current.file.entries.find(candidate => candidate.updateId === source.updateId);
|
|
5301
|
+
const claim = entry?.inputClaim;
|
|
5302
|
+
if (!entry || entry.state !== "pending" || entry.preApprovalExcluded !== false || !claim ||
|
|
5303
|
+
!isDeepStrictEqual(context.owner, recipientOwner) ||
|
|
5304
|
+
context.recipientBindingKey !== claim.recipientBindingKey) {
|
|
5305
|
+
throw createJournalError("conflict", path, "input handoff recipient authority is unavailable or changed");
|
|
5306
|
+
}
|
|
5307
|
+
if (claim.owner.handoffId === handoffId && inputOwnerMatchesIdentity(claim.owner, recipientOwner)) {
|
|
5308
|
+
return { handoffId, receipt: createInputReceipt(source.updateId, claim.owner), duplicate: true,
|
|
5309
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
5310
|
+
}
|
|
5311
|
+
if (claim.phase !== "ready" || claim.handoff?.handoffId !== handoffId ||
|
|
5312
|
+
!isDeepStrictEqual(claim.handoff.recipientOwner, recipientOwner)) {
|
|
5313
|
+
throw createJournalError("conflict", path, "cannot accept stale or unauthenticated input handoff");
|
|
5314
|
+
}
|
|
5315
|
+
const acquiredAtMs = getNowMs();
|
|
5316
|
+
if (!isSafeNonNegativeInteger(acquiredAtMs)) {
|
|
5317
|
+
throw createJournalError("invalid", path, "received an invalid input handoff acquisition timestamp");
|
|
5318
|
+
}
|
|
5319
|
+
const previousOwner = cloneJournalQueueOwner(claim.owner);
|
|
5320
|
+
const owner: TelegramUpdateJournalQueueOwner = { ...recipientOwner,
|
|
5321
|
+
acquisitionId: randomUUID(), acquiredAtMs, handoffId };
|
|
5322
|
+
const acceptedClaim = structuredClone(claim);
|
|
5323
|
+
acceptedClaim.owner = owner;
|
|
5324
|
+
delete acceptedClaim.handoff;
|
|
5325
|
+
const publishedEntries = current.file.entries.map(candidate =>
|
|
5326
|
+
candidate.updateId === source.updateId ? { ...candidate, inputClaim: acceptedClaim } : candidate);
|
|
5327
|
+
const published = publishInputMutation(current, publishedEntries, source.updateId, context);
|
|
5328
|
+
return { handoffId, previousOwner, receipt: createInputReceipt(source.updateId, owner), duplicate: false,
|
|
5329
|
+
entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
5330
|
+
});
|
|
5331
|
+
},
|
|
5332
|
+
cancelInputHandoff(value) {
|
|
5333
|
+
const { receipt, recipientOwner, handoffId } = normalizeInputHandoffCancel(value);
|
|
5334
|
+
return runInputMutation("cancel", read => {
|
|
5335
|
+
const context = currentInputContext();
|
|
5336
|
+
const current = read();
|
|
5337
|
+
const entry = ownedInput(current, receipt);
|
|
5338
|
+
const claim = entry.inputClaim!;
|
|
5339
|
+
if (claim.phase !== "ready" || !inputOwnerMatchesIdentity(receipt.owner, context.owner) ||
|
|
5340
|
+
claim.recipientBindingKey !== context.recipientBindingKey) {
|
|
5341
|
+
throw createJournalError("conflict", path, "input handoff donor authority is stale or running");
|
|
5342
|
+
}
|
|
5343
|
+
if (!claim.handoff) return { handoffId, previousOwner: cloneJournalQueueOwner(receipt.owner),
|
|
5344
|
+
cancelled: false, entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
5345
|
+
if (claim.handoff.handoffId !== handoffId ||
|
|
5346
|
+
!isDeepStrictEqual(claim.handoff.recipientOwner, recipientOwner)) {
|
|
5347
|
+
throw createJournalError("conflict", path, "cannot cancel another input handoff offer");
|
|
5348
|
+
}
|
|
5349
|
+
const cancelledClaim = structuredClone(claim);
|
|
5350
|
+
delete cancelledClaim.handoff;
|
|
5351
|
+
const publishedEntries = current.file.entries.map(candidate =>
|
|
5352
|
+
candidate.updateId === receipt.updateId ? { ...candidate, inputClaim: cancelledClaim } : candidate);
|
|
5353
|
+
const published = publishInputMutation(current, publishedEntries, receipt.updateId, context);
|
|
5354
|
+
return { handoffId, previousOwner: cloneJournalQueueOwner(receipt.owner), cancelled: true,
|
|
5355
|
+
entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
5356
|
+
});
|
|
5357
|
+
},
|
|
5358
|
+
queueInputs(value) {
|
|
5359
|
+
const { queueKind, receiptId, receipts } = normalizeInputQueue(value);
|
|
5360
|
+
return runInputMutation("queue", read => {
|
|
5361
|
+
const context = currentInputContext();
|
|
5362
|
+
if (receipts.some(receipt => !inputOwnerMatchesIdentity(receipt.owner, context.owner))) {
|
|
5363
|
+
throw createJournalError("conflict", path, "input queue transition belongs to another session");
|
|
5364
|
+
}
|
|
5365
|
+
const current = read();
|
|
5366
|
+
const receiptsById = new Map(receipts.map(receipt => [receipt.updateId, receipt]));
|
|
5367
|
+
const requestedIds = receipts.map(receipt => receipt.updateId);
|
|
5368
|
+
const existingReceiptEntries = current.file.entries.filter(entry => entry.queueReceiptId === receiptId);
|
|
5369
|
+
if (existingReceiptEntries.length > 0) {
|
|
5370
|
+
const queueOwner = existingReceiptEntries[0]?.queueOwner;
|
|
5371
|
+
if (existingReceiptEntries.length !== receipts.length || !queueOwner ||
|
|
5372
|
+
!inputOwnerMatchesIdentity(queueOwner, context.owner) ||
|
|
5373
|
+
existingReceiptEntries.some(entry => {
|
|
5374
|
+
const receipt = receiptsById.get(entry.updateId);
|
|
5375
|
+
return !receipt || entry.state !== "queued" || entry.queueKind !== queueKind ||
|
|
5376
|
+
entry.queueHandoff !== undefined || !entry.inputProvenance ||
|
|
5377
|
+
!areTelegramUpdateJournalQueueOwnersEqual(entry.inputProvenance.owner, receipt.owner) ||
|
|
5378
|
+
entry.inputProvenance.recipientBindingKey !== context.recipientBindingKey;
|
|
5379
|
+
})) {
|
|
5380
|
+
throw createJournalError("conflict", path, "input queue transition conflicts with queued authority");
|
|
5381
|
+
}
|
|
5382
|
+
return { queued: false, queueReceipt: { queueKind, receiptId,
|
|
5383
|
+
sourceUpdateIds: requestedIds, queueOwner: cloneJournalQueueOwner(queueOwner) },
|
|
5384
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
5385
|
+
}
|
|
5386
|
+
for (const receipt of receipts) {
|
|
5387
|
+
const entry = current.file.entries.find(candidate => candidate.updateId === receipt.updateId);
|
|
5388
|
+
if (!entry || entry.state !== "pending" || entry.preApprovalExcluded !== false ||
|
|
5389
|
+
!entry.inputClaim || entry.inputClaim.phase !== "running" || entry.inputClaim.handoff ||
|
|
5390
|
+
!areTelegramUpdateJournalQueueOwnersEqual(entry.inputClaim.owner, receipt.owner) ||
|
|
5391
|
+
entry.inputClaim.recipientBindingKey !== context.recipientBindingKey) {
|
|
5392
|
+
throw createJournalError("conflict", path, "input queue transition lost exact running authority");
|
|
5393
|
+
}
|
|
5394
|
+
}
|
|
5395
|
+
const acquiredAtMs = getNowMs();
|
|
5396
|
+
if (!isSafeNonNegativeInteger(acquiredAtMs)) {
|
|
5397
|
+
throw createJournalError("invalid", path, "received an invalid input queue acquisition timestamp");
|
|
5398
|
+
}
|
|
5399
|
+
const queueOwner: TelegramUpdateJournalQueueOwner = { ...context.owner,
|
|
5400
|
+
acquisitionId: randomUUID(), acquiredAtMs };
|
|
5401
|
+
const queuedEntries = current.file.entries.map(entry => {
|
|
5402
|
+
const receipt = receiptsById.get(entry.updateId);
|
|
5403
|
+
if (!receipt) return entry;
|
|
5404
|
+
const claim = entry.inputClaim!;
|
|
5405
|
+
return { updateId: entry.updateId, update: entry.update,
|
|
5406
|
+
admittedAtMs: entry.admittedAtMs, preApprovalExcluded: false, state: "queued" as const,
|
|
5407
|
+
queueKind, queueReceiptId: receiptId, queueOwner: cloneJournalQueueOwner(queueOwner),
|
|
5408
|
+
inputProvenance: { owner: cloneJournalQueueOwner(claim.owner),
|
|
5409
|
+
recipientBindingKey: claim.recipientBindingKey,
|
|
5410
|
+
...(claim.executionUpdate
|
|
5411
|
+
? { executionUpdate: structuredClone(claim.executionUpdate) } : {}) } };
|
|
5412
|
+
});
|
|
5413
|
+
const published = publishInputMutation(current, queuedEntries, requestedIds, context);
|
|
5414
|
+
return { queued: true, queueReceipt: { queueKind, receiptId,
|
|
5415
|
+
sourceUpdateIds: requestedIds, queueOwner: cloneJournalQueueOwner(queueOwner) },
|
|
5416
|
+
entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
5417
|
+
});
|
|
5418
|
+
},
|
|
5419
|
+
acquireInput(value) {
|
|
5420
|
+
const updateId = value.updateId;
|
|
5421
|
+
const recipientBindingKey = value.recipientBindingKey;
|
|
5422
|
+
const executionUpdate = value.executionUpdate === undefined ? undefined : normalizeIncomingJournaledUpdate(value.executionUpdate, path);
|
|
5423
|
+
if (!isSafeNonNegativeInteger(updateId) ||
|
|
5424
|
+
!isBoundedString(recipientBindingKey, TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH) ||
|
|
5425
|
+
!recipientBindingKey.trim() || (executionUpdate && executionUpdate.update_id !== updateId)) {
|
|
5426
|
+
throw createJournalError("invalid", path, "received invalid input acquisition identity");
|
|
5427
|
+
}
|
|
5428
|
+
return runInputMutation("acquire", read => {
|
|
5429
|
+
const context = currentInputContext();
|
|
5430
|
+
const identity = context.owner;
|
|
5431
|
+
if (recipientBindingKey !== context.recipientBindingKey) {
|
|
5432
|
+
throw createJournalError("conflict", path, "input acquisition targets another execution binding");
|
|
5433
|
+
}
|
|
5434
|
+
const current = read();
|
|
5435
|
+
const entry = current.file.entries.find(candidate => candidate.updateId === updateId);
|
|
5436
|
+
if (!entry || entry.state !== "pending" || entry.preApprovalExcluded !== false) {
|
|
5437
|
+
throw createJournalError("conflict", path, "input is not eligible for acquisition");
|
|
5438
|
+
}
|
|
5439
|
+
const projected = executionUpdate ?? entry.update;
|
|
5440
|
+
const existing = entry.inputClaim;
|
|
5441
|
+
if (existing) {
|
|
5442
|
+
if (!isTelegramUpdateJournalQueueOwnerProcess(existing.owner, identity) ||
|
|
5443
|
+
existing.owner.sessionGeneration !== identity.sessionGeneration || existing.recipientBindingKey !== recipientBindingKey ||
|
|
5444
|
+
!isDeepStrictEqual(existing.executionUpdate ?? entry.update, projected)) {
|
|
5445
|
+
throw createJournalError("conflict", path, "input already has another owner or execution projection");
|
|
5446
|
+
}
|
|
5447
|
+
return { acquired: false, receipt: createInputReceipt(updateId, existing.owner) };
|
|
5448
|
+
}
|
|
5449
|
+
const claim: TelegramUpdateJournalInputClaim = { phase: "ready", recipientBindingKey,
|
|
5450
|
+
owner: { ...identity, acquisitionId: randomUUID(), acquiredAtMs: getNowMs() },
|
|
5451
|
+
...(!isDeepStrictEqual(projected, entry.update) ? { executionUpdate: projected } : {}) };
|
|
5452
|
+
const claimedEntries = current.file.entries.map(candidate =>
|
|
5453
|
+
candidate.updateId === updateId ? { ...candidate, inputClaim: claim } : candidate);
|
|
5454
|
+
const reservedEntry = { ...entry, inputClaim: createInputHeadroomClaim(entry, "ready") };
|
|
5455
|
+
const reservedEntries = replaceInputHeadroomEntry(current.file, updateId, reservedEntry);
|
|
5456
|
+
const actualBytes = Buffer.byteLength(serializeJournalFile({ ...current.file, entries: claimedEntries }));
|
|
5457
|
+
const reservedBytes = Buffer.byteLength(serializeJournalFile({ ...current.file, entries: reservedEntries })) +
|
|
5458
|
+
TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES;
|
|
5459
|
+
if (actualBytes > reservedBytes) {
|
|
5460
|
+
throw createJournalError("capacity", path, "execution projection exceeds reserved input headroom");
|
|
5461
|
+
}
|
|
5462
|
+
publishInputMutation(current, claimedEntries, updateId, context);
|
|
5463
|
+
return { acquired: true, receipt: createInputReceipt(updateId, claim.owner) };
|
|
5464
|
+
});
|
|
5465
|
+
},
|
|
5466
|
+
startInput(value) {
|
|
5467
|
+
const receipt = normalizeInputReceipt(value);
|
|
5468
|
+
return runInputMutation("start", read => {
|
|
5469
|
+
const context = currentInputContext();
|
|
5470
|
+
const current = read();
|
|
5471
|
+
const entry = ownedInput(current, receipt);
|
|
5472
|
+
const claim = entry.inputClaim!;
|
|
5473
|
+
if (context.owner.sessionGeneration !== receipt.owner.sessionGeneration ||
|
|
5474
|
+
context.recipientBindingKey !== claim.recipientBindingKey) {
|
|
5475
|
+
throw createJournalError("conflict", path, "input belongs to another execution session or binding");
|
|
5476
|
+
}
|
|
5477
|
+
if (claim.phase === "running") return { started: false as const };
|
|
5478
|
+
if (claim.handoff) throw createJournalError("conflict", path, "input handoff freezes donor execution");
|
|
5479
|
+
publishInputMutation(current, current.file.entries.map(candidate =>
|
|
5480
|
+
candidate.updateId === receipt.updateId
|
|
5481
|
+
? { ...candidate, inputClaim: { ...claim, phase: "running" as const } } : candidate),
|
|
5482
|
+
receipt.updateId, context);
|
|
5483
|
+
return { started: true as const, update: structuredClone(claim.executionUpdate ?? entry.update) };
|
|
5484
|
+
});
|
|
5485
|
+
},
|
|
5486
|
+
completeInput(value) {
|
|
5487
|
+
const receipt = normalizeInputReceipt(value);
|
|
5488
|
+
return runInputMutation("complete", read => {
|
|
5489
|
+
const current = read();
|
|
5490
|
+
const exists = current.file.entries.some(entry => entry.updateId === receipt.updateId);
|
|
5491
|
+
if (!exists && current.exists && receipt.updateId <= (current.file.acceptedThroughUpdateId ?? -1)) {
|
|
5492
|
+
return { removedUpdateIds: [], entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
5493
|
+
}
|
|
5494
|
+
const entry = ownedInput(current, receipt);
|
|
5495
|
+
if (entry.inputClaim!.phase !== "running") throw createJournalError("conflict", path, "input has not started");
|
|
5496
|
+
const published = publishInputSettlement(current,
|
|
5497
|
+
current.file.entries.filter(candidate => candidate.updateId !== receipt.updateId), true);
|
|
5498
|
+
return { removedUpdateIds: [receipt.updateId], entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
5499
|
+
});
|
|
5500
|
+
},
|
|
5501
|
+
};
|
|
5502
|
+
return { journal, input };
|
|
3445
5503
|
}
|