@llblab/pi-telegram 0.51.6 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -12
- package/BACKLOG.md +16 -9
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +17 -6
- package/dist/lib/activity-verbosity.d.ts +1 -5
- package/dist/lib/activity-verbosity.js +5 -7
- package/dist/lib/bindings.d.ts +5 -1
- package/dist/lib/bindings.js +7 -1
- package/dist/lib/bus-api.js +8 -5
- package/dist/lib/bus-follower.d.ts +54 -56
- package/dist/lib/bus-follower.js +206 -139
- package/dist/lib/bus-leader.d.ts +21 -2
- package/dist/lib/bus-leader.js +181 -25
- package/dist/lib/bus-transport.d.ts +4 -1
- package/dist/lib/bus-transport.js +28 -4
- package/dist/lib/bus.d.ts +52 -56
- package/dist/lib/bus.js +78 -246
- package/dist/lib/channel-posts.d.ts +1 -3
- package/dist/lib/channel-posts.js +23 -26
- package/dist/lib/command-templates.d.ts +0 -5
- package/dist/lib/command-templates.js +5 -5
- package/dist/lib/commands.d.ts +0 -23
- package/dist/lib/commands.js +15 -15
- package/dist/lib/config.d.ts +0 -17
- package/dist/lib/config.js +12 -14
- package/dist/lib/delivery.d.ts +0 -4
- package/dist/lib/delivery.js +2 -5
- package/dist/lib/extension.js +258 -61
- package/dist/lib/generative-apps.d.ts +0 -19
- package/dist/lib/inbound.d.ts +0 -2
- package/dist/lib/inbound.js +2 -2
- package/dist/lib/journal.d.ts +183 -16
- package/dist/lib/journal.js +1175 -248
- package/dist/lib/keyboard.d.ts +0 -2
- package/dist/lib/keyboard.js +2 -2
- package/dist/lib/lifecycle.js +1 -1
- package/dist/lib/locks.d.ts +100 -12
- package/dist/lib/locks.js +380 -14
- package/dist/lib/logging.d.ts +30 -12
- package/dist/lib/logging.js +129 -72
- package/dist/lib/media.d.ts +28 -5
- package/dist/lib/media.js +26 -8
- package/dist/lib/menu-model.d.ts +0 -11
- package/dist/lib/menu-model.js +8 -8
- package/dist/lib/menu-settings.d.ts +0 -18
- package/dist/lib/menu-settings.js +18 -18
- package/dist/lib/menu-status.d.ts +4 -4
- package/dist/lib/menu-status.js +12 -7
- package/dist/lib/menu-thinking.d.ts +1 -1
- package/dist/lib/menu-thinking.js +1 -1
- package/dist/lib/menu.d.ts +1 -0
- package/dist/lib/menu.js +4 -3
- package/dist/lib/outbound-attachments.d.ts +0 -1
- package/dist/lib/outbound-attachments.js +1 -1
- package/dist/lib/outbound-buttons.js +1 -8
- package/dist/lib/outbound-markup.d.ts +1 -6
- package/dist/lib/outbound-markup.js +4 -21
- package/dist/lib/outbound.d.ts +0 -8
- package/dist/lib/outbound.js +5 -5
- package/dist/lib/paths.d.ts +50 -7
- package/dist/lib/paths.js +155 -17
- package/dist/lib/pi.d.ts +1 -0
- package/dist/lib/pi.js +2 -1
- package/dist/lib/polling.d.ts +0 -5
- package/dist/lib/polling.js +5 -5
- package/dist/lib/preview.d.ts +0 -3
- package/dist/lib/preview.js +3 -3
- package/dist/lib/prompt-templates.d.ts +0 -1
- package/dist/lib/prompt-templates.js +1 -1
- package/dist/lib/queue.d.ts +5 -15
- package/dist/lib/queue.js +94 -11
- package/dist/lib/recovery.d.ts +29 -9
- package/dist/lib/recovery.js +104 -33
- package/dist/lib/replies.d.ts +0 -4
- package/dist/lib/replies.js +1 -1
- package/dist/lib/routing.d.ts +74 -8
- package/dist/lib/routing.js +2280 -307
- package/dist/lib/sections.d.ts +0 -2
- package/dist/lib/sections.js +1 -1
- package/dist/lib/status.d.ts +44 -9
- package/dist/lib/status.js +149 -27
- package/dist/lib/sync.d.ts +0 -5
- package/dist/lib/sync.js +5 -4
- package/dist/lib/telegram-api.d.ts +13 -3
- package/dist/lib/telegram-api.js +38 -23
- package/dist/lib/thread-cleanup-manager.d.ts +1 -0
- package/dist/lib/thread-cleanup-manager.js +18 -21
- package/dist/lib/thread-naming.d.ts +32 -5
- package/dist/lib/thread-naming.js +191 -7
- package/dist/lib/thread-reconciler.d.ts +2 -0
- package/dist/lib/thread-reconciler.js +1 -1
- package/dist/lib/threads.d.ts +293 -59
- package/dist/lib/threads.js +1689 -369
- package/dist/lib/turns.d.ts +0 -1
- package/dist/lib/turns.js +1 -1
- package/dist/lib/updates.d.ts +191 -28
- package/dist/lib/updates.js +1048 -114
- package/dist/lib/wire.d.ts +12 -0
- package/dist/lib/wire.js +21 -0
- package/dist/lib/workspace-admission.d.ts +25 -17
- package/dist/lib/workspace-admission.js +95 -20
- package/dist/lib/workspace-identity.d.ts +20 -0
- package/dist/lib/workspace-identity.js +103 -0
- package/dist/lib/workspace-retirement.d.ts +43 -8
- package/dist/lib/workspace-retirement.js +148 -52
- package/dist/package.json +6 -6
- package/dist/skills/generated-control-surface/SKILL.md +3 -3
- package/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/docs/README.md +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +156 -36
- package/docs/callback-namespaces.md +1 -1
- package/docs/delivery.md +1 -1
- package/docs/generative-apps.md +2 -2
- package/docs/inbound.md +1 -1
- package/docs/multi-instance-bus.md +125 -19
- package/docs/public-api.md +3 -1
- package/docs/ui-style.md +23 -9
- package/docs/updates.md +1 -1
- package/lib/activity-verbosity.ts +5 -9
- package/lib/bindings.ts +7 -0
- package/lib/bus-api.ts +9 -4
- package/lib/bus-follower.ts +243 -224
- package/lib/bus-leader.ts +178 -31
- package/lib/bus-transport.ts +30 -8
- package/lib/bus.ts +110 -334
- package/lib/channel-posts.ts +29 -29
- package/lib/command-templates.ts +5 -5
- package/lib/commands.ts +15 -15
- package/lib/config.ts +12 -17
- package/lib/delivery.ts +2 -8
- package/lib/extension.ts +261 -72
- package/lib/generative-apps.ts +0 -22
- package/lib/inbound.ts +2 -2
- package/lib/journal.ts +1250 -325
- package/lib/keyboard.ts +2 -2
- package/lib/lifecycle.ts +1 -1
- package/lib/locks.ts +402 -23
- package/lib/logging.ts +151 -101
- package/lib/media.ts +51 -9
- package/lib/menu-model.ts +8 -8
- package/lib/menu-settings.ts +18 -18
- package/lib/menu-status.ts +11 -0
- package/lib/menu-thinking.ts +2 -2
- package/lib/menu.ts +5 -1
- package/lib/outbound-attachments.ts +1 -1
- package/lib/outbound-buttons.ts +1 -10
- package/lib/outbound-markup.ts +5 -24
- package/lib/outbound.ts +5 -5
- package/lib/paths.ts +177 -17
- package/lib/pi.ts +5 -2
- package/lib/polling.ts +5 -5
- package/lib/preview.ts +3 -3
- package/lib/prompt-templates.ts +1 -1
- package/lib/queue.ts +93 -12
- package/lib/recovery.ts +104 -44
- package/lib/replies.ts +1 -1
- package/lib/routing.ts +2010 -375
- package/lib/sections.ts +1 -1
- package/lib/status.ts +156 -36
- package/lib/sync.ts +4 -4
- package/lib/telegram-api.ts +53 -33
- package/lib/thread-cleanup-manager.ts +24 -21
- package/lib/thread-naming.ts +267 -9
- package/lib/thread-reconciler.ts +3 -1
- package/lib/threads.ts +1736 -488
- package/lib/turns.ts +1 -1
- package/lib/updates.ts +1094 -151
- package/lib/wire.ts +28 -0
- package/lib/workspace-admission.ts +97 -47
- package/lib/workspace-identity.ts +147 -0
- package/lib/workspace-retirement.ts +188 -89
- package/package.json +6 -6
- package/scripts/audit-exports.mjs +100 -0
- package/scripts/check-downgrade.mjs +80 -43
- package/skills/generated-control-surface/SKILL.md +3 -3
- package/skills/telegram-bridge/references/diagnosis.md +3 -3
package/lib/journal.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
readFileSync,
|
|
23
23
|
readdirSync,
|
|
24
24
|
rmdirSync,
|
|
25
|
+
rmSync,
|
|
25
26
|
statSync,
|
|
26
27
|
unlinkSync,
|
|
27
28
|
writeFileSync,
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
type TelegramProcessLiveness,
|
|
40
41
|
} from "./bus.ts";
|
|
41
42
|
import {
|
|
43
|
+
decodeTelegramSessionDirectoryName,
|
|
42
44
|
getTelegramProfilePathSuffix,
|
|
43
45
|
TELEGRAM_DEFAULT_PROFILE_NAME,
|
|
44
46
|
} from "./paths.ts";
|
|
@@ -47,22 +49,28 @@ import {
|
|
|
47
49
|
type TelegramWorkspaceAdmissionLedger,
|
|
48
50
|
type TelegramWorkspaceAdmissionScope,
|
|
49
51
|
} from "./workspace-admission.ts";
|
|
52
|
+
import {
|
|
53
|
+
isWireRecord as isRecord,
|
|
54
|
+
hasOnlyWireKeys as hasOnlyKeys,
|
|
55
|
+
isNonNegativeWireInteger as isSafeNonNegativeInteger,
|
|
56
|
+
isNonEmptyWireString as isNonEmptyString,
|
|
57
|
+
} from "./wire.ts";
|
|
50
58
|
|
|
51
59
|
export const TELEGRAM_UPDATE_JOURNAL_VERSION = 1 as const;
|
|
52
60
|
export const TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION = 2 as const;
|
|
53
61
|
export const TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION = 3 as const;
|
|
54
62
|
// Receipt and binding identities survive storage-schema upgrades.
|
|
55
63
|
const TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION = 1 as const;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
const TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES = 10_000;
|
|
65
|
+
const TELEGRAM_UPDATE_JOURNAL_MAX_BYTES = 32 * 1024 * 1024;
|
|
66
|
+
const TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH = 128;
|
|
59
67
|
export const TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH = 256;
|
|
60
|
-
|
|
68
|
+
const TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH = 256;
|
|
61
69
|
const TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES = 4 * 1024;
|
|
62
70
|
const TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES = 64;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH = 128;
|
|
72
|
+
const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH = 32;
|
|
73
|
+
const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH = 256;
|
|
66
74
|
export const TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH = 128;
|
|
67
75
|
export const TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH = 512;
|
|
68
76
|
|
|
@@ -115,7 +123,78 @@ export function discoverTelegramFollowerJournalPaths(input: {
|
|
|
115
123
|
}
|
|
116
124
|
return { paths: Array.from(paths).sort(), complete };
|
|
117
125
|
}
|
|
118
|
-
|
|
126
|
+
|
|
127
|
+
/** Bounded read-only session-family discovery, not writer closure or deletion authority. */
|
|
128
|
+
export function discoverTelegramSessionJournalPaths(input: {
|
|
129
|
+
directory: string;
|
|
130
|
+
profileName?: string;
|
|
131
|
+
maxDirectoryEntries?: number;
|
|
132
|
+
}): TelegramFollowerJournalDiscovery {
|
|
133
|
+
const maxEntries = input.maxDirectoryEntries ?? 10_000;
|
|
134
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) return { paths: [], complete: false };
|
|
135
|
+
const root = join(input.directory, "sessions");
|
|
136
|
+
const suffix = getTelegramProfilePathSuffix(input.profileName);
|
|
137
|
+
const paths = new Set<string>();
|
|
138
|
+
let remaining = maxEntries;
|
|
139
|
+
let complete = true;
|
|
140
|
+
const scan = (directory: string): Dirent[] => {
|
|
141
|
+
const entries: Dirent[] = [];
|
|
142
|
+
const handle = opendirSync(directory);
|
|
143
|
+
try {
|
|
144
|
+
for (;;) {
|
|
145
|
+
const entry = handle.readSync();
|
|
146
|
+
if (!entry) break;
|
|
147
|
+
if (--remaining < 0) { complete = false; break; }
|
|
148
|
+
entries.push(entry);
|
|
149
|
+
}
|
|
150
|
+
} finally { handle.closeSync(); }
|
|
151
|
+
return entries;
|
|
152
|
+
};
|
|
153
|
+
try {
|
|
154
|
+
const stat = lstatSync(root);
|
|
155
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return { paths: [], complete: false };
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return { paths: [], complete: (error as NodeJS.ErrnoException).code === "ENOENT" };
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
for (const session of scan(root)) {
|
|
161
|
+
if (!complete) break;
|
|
162
|
+
if (!session.isDirectory() || decodeTelegramSessionDirectoryName(session.name) === undefined) {
|
|
163
|
+
complete = false; break;
|
|
164
|
+
}
|
|
165
|
+
const directory = join(root, session.name);
|
|
166
|
+
const sessionStat = lstatSync(directory);
|
|
167
|
+
if (!sessionStat.isDirectory() || sessionStat.isSymbolicLink()) { complete = false; break; }
|
|
168
|
+
for (const entry of scan(directory)) {
|
|
169
|
+
if (!complete) break;
|
|
170
|
+
// The leader polling journal and legacy recovery folders are not recipient custody.
|
|
171
|
+
if (entry.name === "recovery" || /^inbox(?:\.[a-zA-Z0-9._-]+)?\.json(?:\.segments|\.retained)?$/u.test(entry.name)) continue;
|
|
172
|
+
const match = /^journal\.([a-f0-9]{16})(\.[a-zA-Z0-9._-]+)?\.json(\.segments|\.retained)?$/u.exec(entry.name);
|
|
173
|
+
if (!match) { complete = false; break; }
|
|
174
|
+
const stat = lstatSync(join(directory, entry.name));
|
|
175
|
+
if (stat.isSymbolicLink() || (match[3] ? !stat.isDirectory() : !stat.isFile())) {
|
|
176
|
+
complete = false; break;
|
|
177
|
+
}
|
|
178
|
+
if ((match[2] ?? "") !== suffix) continue;
|
|
179
|
+
paths.add(join(directory, entry.name.replace(/\.(?:segments|retained)$/u, "")));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch { complete = false; }
|
|
183
|
+
return { paths: Array.from(paths).sort(), complete };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Transitional discovery covers both retained flat recipients and session-owned families. */
|
|
187
|
+
export function discoverTelegramRecipientJournalPaths(input: {
|
|
188
|
+
directory: string;
|
|
189
|
+
profileName?: string;
|
|
190
|
+
}): TelegramFollowerJournalDiscovery {
|
|
191
|
+
const legacy = discoverTelegramFollowerJournalPaths(input);
|
|
192
|
+
const sessions = discoverTelegramSessionJournalPaths(input);
|
|
193
|
+
return { paths: Array.from(new Set([...legacy.paths, ...sessions.paths])).sort(),
|
|
194
|
+
complete: legacy.complete && sessions.complete };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH = 256;
|
|
119
198
|
export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_COUNT = 256;
|
|
120
199
|
export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_BYTES = 4 * 1024 * 1024;
|
|
121
200
|
|
|
@@ -329,7 +408,7 @@ export interface TelegramUpdateJournalLegacyCustodyCandidate {
|
|
|
329
408
|
evidenceSha256: string;
|
|
330
409
|
}
|
|
331
410
|
|
|
332
|
-
|
|
411
|
+
function listTelegramUpdateJournalLegacyCustodyCandidates(
|
|
333
412
|
snapshot: Pick<TelegramUpdateJournalSnapshot, "entries">,
|
|
334
413
|
): TelegramUpdateJournalLegacyCustodyCandidate[] {
|
|
335
414
|
return snapshot.entries.flatMap(entry => {
|
|
@@ -409,12 +488,23 @@ export type TelegramUpdateJournalOperatorDisposition =
|
|
|
409
488
|
| TelegramUpdateJournalTerminalOperatorDisposition
|
|
410
489
|
| TelegramUpdateJournalLegacyCustodyDisposition;
|
|
411
490
|
|
|
491
|
+
export const TELEGRAM_ROUTING_INPUT_TTL_MS = 60 * 60 * 1000;
|
|
492
|
+
|
|
493
|
+
/** A source-only choice deadline, never a queue lease or a Thread deletion grant. */
|
|
494
|
+
export interface TelegramUpdateJournalRoutingInput {
|
|
495
|
+
operatorUserId: number;
|
|
496
|
+
publishedAtMs: number;
|
|
497
|
+
expiresAtMs: number;
|
|
498
|
+
phase: "waiting" | "selected";
|
|
499
|
+
}
|
|
500
|
+
|
|
412
501
|
export interface TelegramUpdateJournalEntry {
|
|
413
502
|
updateId: number;
|
|
414
503
|
update: TelegramJournaledUpdate;
|
|
415
504
|
/** Mandatory in v2/v3; immutable veto, never sender authorization. Absent only in legacy v1. */
|
|
416
505
|
preApprovalExcluded?: boolean;
|
|
417
506
|
admittedAtMs: number;
|
|
507
|
+
routingInput?: TelegramUpdateJournalRoutingInput;
|
|
418
508
|
state: TelegramUpdateJournalEntryState;
|
|
419
509
|
queueKind?: TelegramUpdateJournalQueueKind;
|
|
420
510
|
queueReceiptId?: string;
|
|
@@ -429,6 +519,19 @@ export interface TelegramUpdateJournalEntry {
|
|
|
429
519
|
terminalFailureId?: string;
|
|
430
520
|
}
|
|
431
521
|
|
|
522
|
+
/** Exact parsed journal entry identity, checked inside the source owner's mutation transaction. */
|
|
523
|
+
export interface TelegramUpdateJournalEntryDigest {
|
|
524
|
+
updateId: number;
|
|
525
|
+
sourceSha256: string;
|
|
526
|
+
}
|
|
527
|
+
/** Journal-owned removal ACK bound to an opaque, immutable caller acceptance scope. */
|
|
528
|
+
export interface TelegramUpdateJournalSourceCompletion extends TelegramUpdateJournalEntryDigest {
|
|
529
|
+
completionSha256: string;
|
|
530
|
+
}
|
|
531
|
+
export function createTelegramUpdateJournalEntryDigest(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEntryDigest {
|
|
532
|
+
return { updateId: entry.updateId, sourceSha256: createHash("sha256").update(JSON.stringify(entry)).digest("hex") };
|
|
533
|
+
}
|
|
534
|
+
|
|
432
535
|
export interface TelegramUpdateJournalFile {
|
|
433
536
|
version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION | typeof TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION | typeof TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
|
|
434
537
|
revision?: number;
|
|
@@ -437,6 +540,7 @@ export interface TelegramUpdateJournalFile {
|
|
|
437
540
|
botIdentity: TelegramUpdateJournalBotIdentity;
|
|
438
541
|
entries: TelegramUpdateJournalEntry[];
|
|
439
542
|
operatorDispositions?: TelegramUpdateJournalOperatorDisposition[];
|
|
543
|
+
sourceCompletions?: TelegramUpdateJournalSourceCompletion[];
|
|
440
544
|
}
|
|
441
545
|
|
|
442
546
|
export interface TelegramUpdateJournalSnapshot
|
|
@@ -455,6 +559,7 @@ export interface TelegramUpdateJournalAppendResult {
|
|
|
455
559
|
}
|
|
456
560
|
|
|
457
561
|
export interface TelegramUpdateJournalRemoveResult {
|
|
562
|
+
sourceCompletions?: TelegramUpdateJournalSourceCompletion[];
|
|
458
563
|
removedUpdateIds: number[];
|
|
459
564
|
entryCount: number;
|
|
460
565
|
serializedBytes: number;
|
|
@@ -475,6 +580,12 @@ export interface TelegramUpdateJournalQueueResult {
|
|
|
475
580
|
serializedBytes: number;
|
|
476
581
|
}
|
|
477
582
|
|
|
583
|
+
export interface TelegramUpdateJournalQueuedReceiptEvidence {
|
|
584
|
+
receipt: TelegramUpdateJournalQueuedCompletion;
|
|
585
|
+
sources: TelegramUpdateJournalEntryDigest[];
|
|
586
|
+
queueOwnerSha256: string;
|
|
587
|
+
}
|
|
588
|
+
|
|
478
589
|
export interface TelegramUpdateJournalQueuedCompletion {
|
|
479
590
|
queueKind: TelegramUpdateJournalQueueKind;
|
|
480
591
|
receiptId: string;
|
|
@@ -593,8 +704,94 @@ export interface TelegramUpdateJournalOperatorDispositionResult {
|
|
|
593
704
|
serializedBytes: number;
|
|
594
705
|
}
|
|
595
706
|
|
|
707
|
+
export interface TelegramUpdateJournalPendingAbandonmentInput {
|
|
708
|
+
journalBindingKey: string;
|
|
709
|
+
entry: TelegramUpdateJournalEntry;
|
|
710
|
+
operatorAuthorityId: string;
|
|
711
|
+
/** Caller proves owner authorization and excludes live chooser/worker dispatch through publication. */
|
|
712
|
+
isCurrent: () => boolean;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export interface TelegramUpdateJournalPendingAbandonmentResult {
|
|
716
|
+
disposition: TelegramUpdateJournalLegacyCustodyDisposition;
|
|
717
|
+
retainedPath: string;
|
|
718
|
+
duplicate: boolean;
|
|
719
|
+
entryCount: number;
|
|
720
|
+
serializedBytes: number;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Private evidence copy, not execution authority or proof that abandonment committed. */
|
|
724
|
+
export interface TelegramUpdateJournalRetainedInput {
|
|
725
|
+
version: 1;
|
|
726
|
+
kind: "pending-input-retention";
|
|
727
|
+
journalBindingKey: string;
|
|
728
|
+
entry: TelegramUpdateJournalEntry;
|
|
729
|
+
requestedDisposition: TelegramUpdateJournalLegacyCustodyDisposition;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export interface TelegramUpdateJournalPendingRetentionEvidence {
|
|
733
|
+
journalBindingKey: string;
|
|
734
|
+
retainedPath: string;
|
|
735
|
+
requestedDisposition: TelegramUpdateJournalLegacyCustodyDisposition;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Durable proof that an owner abandoned this exact pending source; never delivery or completion proof. */
|
|
739
|
+
export interface TelegramUpdateJournalAbandonedPendingEvidence {
|
|
740
|
+
journalBindingKey: string;
|
|
741
|
+
updateId: number;
|
|
742
|
+
retainedPath: string;
|
|
743
|
+
operatorAuthorityId: string;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export interface TelegramRoutingInputAuthority {
|
|
747
|
+
journalBindingKey: string;
|
|
748
|
+
entries: readonly TelegramUpdateJournalEntry[];
|
|
749
|
+
operatorUserId: number;
|
|
750
|
+
isCurrent(): boolean;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Expiry drops a donor attempt without retaining its body or claiming recipient execution/cancellation. */
|
|
754
|
+
export type TelegramRoutingInputExpiryResult = Omit<TelegramUpdateJournalPendingAbandonmentResult, "retainedPath">;
|
|
755
|
+
export type TelegramRoutingInputExpiryEvidence = Omit<TelegramUpdateJournalAbandonedPendingEvidence, "retainedPath">;
|
|
756
|
+
|
|
757
|
+
function inspectRoutingInputExpiry(file: TelegramUpdateJournalFile, journalBindingKey: string, updateId: number): TelegramRoutingInputExpiryEvidence | undefined {
|
|
758
|
+
const disposition = file.operatorDispositions?.find(value => value.updateId === updateId &&
|
|
759
|
+
"dispositionKind" in value && value.dispositionKind === "legacy-custody" && value.action === "discard" &&
|
|
760
|
+
value.failureId === `routing-expiry:${value.evidenceSha256}`);
|
|
761
|
+
if (!disposition || !("operatorAuthorityId" in disposition) || file.entries.some(entry => entry.updateId === updateId)) return undefined;
|
|
762
|
+
return { journalBindingKey, updateId, operatorAuthorityId: disposition.operatorAuthorityId };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function inspectRoutingGroupExpiry(file: TelegramUpdateJournalFile, journalBindingKey: string, updateIds: readonly number[]): TelegramRoutingInputExpiryEvidence[] | undefined {
|
|
766
|
+
if (!Array.isArray(updateIds) || !updateIds.length || updateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
|
|
767
|
+
updateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= updateIds[index - 1]!))) return undefined;
|
|
768
|
+
if (file.entries.some(entry => updateIds.includes(entry.updateId))) return undefined;
|
|
769
|
+
const expired = updateIds.map(id => inspectRoutingInputExpiry(file, journalBindingKey, id)).filter(value => value !== undefined);
|
|
770
|
+
if (!expired.length || expired.some(value => value.operatorAuthorityId !== expired[0]!.operatorAuthorityId)) return undefined;
|
|
771
|
+
// Some members may have been acknowledged earlier. Whole donor absence plus one exact expiry ends this known cohort,
|
|
772
|
+
// not recipient custody; fresh Thread protection still independently gates deletion.
|
|
773
|
+
return updateIds.map(updateId => ({ journalBindingKey, updateId, operatorAuthorityId: expired[0]!.operatorAuthorityId }));
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Package-private v1 capability; raw input custody does not expose it. */
|
|
777
|
+
export interface TelegramRoutingInputJournal {
|
|
778
|
+
arm(input: TelegramRoutingInputAuthority & { publishedAtMs: number }): TelegramUpdateJournalEntry[];
|
|
779
|
+
select(input: TelegramRoutingInputAuthority): { issued: boolean; entries: TelegramUpdateJournalEntry[] };
|
|
780
|
+
expire(input: Omit<TelegramRoutingInputAuthority, "entries"> & { entry: TelegramUpdateJournalEntry }): TelegramRoutingInputExpiryResult;
|
|
781
|
+
inspectExpiry(updateId: number): TelegramRoutingInputExpiryEvidence | undefined;
|
|
782
|
+
inspectGroupExpiry(updateIds: readonly number[]): TelegramRoutingInputExpiryEvidence[] | undefined;
|
|
783
|
+
}
|
|
784
|
+
|
|
596
785
|
export interface TelegramUpdateJournalStore {
|
|
786
|
+
routingInputs?: TelegramRoutingInputJournal;
|
|
597
787
|
read(): TelegramUpdateJournalSnapshot;
|
|
788
|
+
/** Read-only: the committed abandonment tombstone plus its matching private retention, or undefined. */
|
|
789
|
+
inspectAbandonedPending(updateId: number): TelegramUpdateJournalAbandonedPendingEvidence | undefined;
|
|
790
|
+
/** Read-only protection evidence for this snapshot entry, never commit/execution authority. */
|
|
791
|
+
inspectPendingRetention(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalPendingRetentionEvidence | undefined;
|
|
792
|
+
abandonPending(
|
|
793
|
+
input: TelegramUpdateJournalPendingAbandonmentInput,
|
|
794
|
+
): TelegramUpdateJournalPendingAbandonmentResult;
|
|
598
795
|
appendBatch<TUpdate extends TelegramUpdateJournalInput>(
|
|
599
796
|
updates: readonly TUpdate[],
|
|
600
797
|
acceptedThroughUpdateId?: number,
|
|
@@ -623,6 +820,11 @@ export interface TelegramUpdateJournalStore {
|
|
|
623
820
|
completeQueued(
|
|
624
821
|
receipts: readonly TelegramUpdateJournalQueuedCompletion[],
|
|
625
822
|
): TelegramUpdateJournalRemoveResult;
|
|
823
|
+
/** Strict v1 whole-receipt disposal co-publishes caller-scoped ACKs; never a readiness grant. */
|
|
824
|
+
completeQueuedExact(
|
|
825
|
+
receipts: readonly TelegramUpdateJournalQueuedCompletion[],
|
|
826
|
+
completions: readonly TelegramUpdateJournalSourceCompletion[],
|
|
827
|
+
): TelegramUpdateJournalRemoveResult;
|
|
626
828
|
discardQueued(
|
|
627
829
|
input: TelegramUpdateJournalQueueDiscardInput,
|
|
628
830
|
): TelegramUpdateJournalQueueDiscardResult;
|
|
@@ -632,6 +834,15 @@ export interface TelegramUpdateJournalStore {
|
|
|
632
834
|
removeCompleted(
|
|
633
835
|
updateIds: readonly number[],
|
|
634
836
|
): TelegramUpdateJournalRemoveResult;
|
|
837
|
+
/** Guarded completion; every supplied source must still match, and absence is a conflict, not an ACK. */
|
|
838
|
+
removeCompletedExact(updateIds: readonly number[], expectedSources: readonly TelegramUpdateJournalEntryDigest[],
|
|
839
|
+
completions?: readonly TelegramUpdateJournalSourceCompletion[]): TelegramUpdateJournalRemoveResult;
|
|
840
|
+
/** Strict read-only observation; neither source absence nor a different scope is completion evidence. */
|
|
841
|
+
inspectSourceCompletion(expected: TelegramUpdateJournalSourceCompletion): TelegramUpdateJournalSourceCompletion | undefined;
|
|
842
|
+
/** Complete strict observation of a retained, unoffered v1 queue receipt; never acquires readiness. */
|
|
843
|
+
inspectQueuedReceipt(expected: TelegramUpdateJournalQueuedCompletion): TelegramUpdateJournalQueuedReceiptEvidence | undefined;
|
|
844
|
+
isQueueReceiptCurrent?: (receipt: Pick<TelegramUpdateJournalQueuedCompletion, "queueKind" | "receiptId" | "sourceUpdateIds"> &
|
|
845
|
+
{ journalBindingKey?: string }, owner: TelegramUpdateJournalQueueOwner) => boolean;
|
|
635
846
|
}
|
|
636
847
|
|
|
637
848
|
export type TelegramUpdateJournalPublicationBoundary =
|
|
@@ -642,7 +853,8 @@ export interface TelegramUpdateJournalRecoveryEvent {
|
|
|
642
853
|
kind: "repaired" | "reset";
|
|
643
854
|
path: string;
|
|
644
855
|
revision?: number;
|
|
645
|
-
|
|
856
|
+
/** Damaged snapshot/segment paths deleted or atomically replaced; nothing is retained for recovery. */
|
|
857
|
+
deletedPaths?: string[];
|
|
646
858
|
reason: string;
|
|
647
859
|
}
|
|
648
860
|
|
|
@@ -831,6 +1043,7 @@ interface JournalSourceAcquisition {
|
|
|
831
1043
|
evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily>;
|
|
832
1044
|
snapshotRevision: number;
|
|
833
1045
|
segments: { name: string; bytes: number; work: number; botId?: number }[];
|
|
1046
|
+
retainedInputs?: TelegramUpdateJournalRetentionInspection["retainedInputs"];
|
|
834
1047
|
}
|
|
835
1048
|
|
|
836
1049
|
export interface TelegramUpdateJournalSegment {
|
|
@@ -843,6 +1056,7 @@ export interface TelegramUpdateJournalSegment {
|
|
|
843
1056
|
upsertedEntries: TelegramUpdateJournalEntry[];
|
|
844
1057
|
removedUpdateIds: number[];
|
|
845
1058
|
operatorDispositions?: TelegramUpdateJournalOperatorDisposition[];
|
|
1059
|
+
sourceCompletions?: TelegramUpdateJournalSourceCompletion[];
|
|
846
1060
|
}
|
|
847
1061
|
|
|
848
1062
|
export interface TelegramUpdateJournalSegmentPublicationResult {
|
|
@@ -865,30 +1079,10 @@ function createJournalError(
|
|
|
865
1079
|
);
|
|
866
1080
|
}
|
|
867
1081
|
|
|
868
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
869
|
-
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
function hasOnlyKeys(
|
|
873
|
-
value: Record<string, unknown>,
|
|
874
|
-
allowedKeys: readonly string[],
|
|
875
|
-
): boolean {
|
|
876
|
-
const allowed = new Set(allowedKeys);
|
|
877
|
-
return Object.keys(value).every((key) => allowed.has(key));
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
function isSafeNonNegativeInteger(value: unknown): value is number {
|
|
881
|
-
return Number.isSafeInteger(value) && (value as number) >= 0;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
1082
|
function isSafePositiveInteger(value: unknown): value is number {
|
|
885
1083
|
return Number.isSafeInteger(value) && (value as number) > 0;
|
|
886
1084
|
}
|
|
887
1085
|
|
|
888
|
-
function isNonEmptyString(value: unknown): value is string {
|
|
889
|
-
return typeof value === "string" && value.length > 0;
|
|
890
|
-
}
|
|
891
|
-
|
|
892
1086
|
function isBoundedString(
|
|
893
1087
|
value: unknown,
|
|
894
1088
|
maxLength: number,
|
|
@@ -1255,8 +1449,16 @@ function validateJournalOperatorDisposition(
|
|
|
1255
1449
|
}
|
|
1256
1450
|
|
|
1257
1451
|
function journalRequiresExclusion(version: TelegramUpdateJournalFile["version"]): boolean {
|
|
1258
|
-
return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION ||
|
|
1259
|
-
|
|
1452
|
+
return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION || journalHasCustodyFields(version);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function journalHasCustodyFields(version: TelegramUpdateJournalFile["version"]): boolean {
|
|
1456
|
+
return version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/** Only legacy v1 carries routing-lifetime, scoped-ACK and pairing-free queue-receipt semantics. */
|
|
1460
|
+
export function isTelegramUpdateJournalLegacyFamilyVersion(version: unknown): boolean {
|
|
1461
|
+
return version === TELEGRAM_UPDATE_JOURNAL_VERSION;
|
|
1260
1462
|
}
|
|
1261
1463
|
|
|
1262
1464
|
function validateJournalInputClaim(
|
|
@@ -1328,6 +1530,7 @@ function validateJournalEntry(
|
|
|
1328
1530
|
"updateId",
|
|
1329
1531
|
"update",
|
|
1330
1532
|
"admittedAtMs",
|
|
1533
|
+
...(isTelegramUpdateJournalLegacyFamilyVersion(version) ? ["routingInput"] : []),
|
|
1331
1534
|
"state",
|
|
1332
1535
|
"queueKind",
|
|
1333
1536
|
"queueReceiptId",
|
|
@@ -1339,7 +1542,7 @@ function validateJournalEntry(
|
|
|
1339
1542
|
"terminalReason",
|
|
1340
1543
|
"terminalFailureId",
|
|
1341
1544
|
...(journalRequiresExclusion(version) ? ["preApprovalExcluded"] : []),
|
|
1342
|
-
...(version
|
|
1545
|
+
...(journalHasCustodyFields(version)
|
|
1343
1546
|
? ["inputClaim", "inputProvenance"] : []),
|
|
1344
1547
|
]) ||
|
|
1345
1548
|
!isSafeNonNegativeInteger(value.updateId) ||
|
|
@@ -1359,6 +1562,20 @@ function validateJournalEntry(
|
|
|
1359
1562
|
"contains an entry/update id mismatch",
|
|
1360
1563
|
);
|
|
1361
1564
|
}
|
|
1565
|
+
let routingInput: TelegramUpdateJournalRoutingInput | undefined;
|
|
1566
|
+
if (value.routingInput !== undefined) {
|
|
1567
|
+
const routing = value.routingInput;
|
|
1568
|
+
if (!isRecord(routing) || !hasOnlyKeys(routing, ["operatorUserId", "publishedAtMs", "expiresAtMs", "phase"]) ||
|
|
1569
|
+
!isSafePositiveInteger(routing.operatorUserId) || !isSafeNonNegativeInteger(routing.publishedAtMs) ||
|
|
1570
|
+
routing.publishedAtMs < value.admittedAtMs || !isSafeNonNegativeInteger(routing.expiresAtMs) ||
|
|
1571
|
+
routing.expiresAtMs !== routing.publishedAtMs + TELEGRAM_ROUTING_INPUT_TTL_MS ||
|
|
1572
|
+
(routing.phase !== "waiting" && routing.phase !== "selected") ||
|
|
1573
|
+
(routing.phase === "waiting" && value.state === "queued")) {
|
|
1574
|
+
throw createJournalError("invalid", path, "contains invalid routing input lifetime");
|
|
1575
|
+
}
|
|
1576
|
+
routingInput = { operatorUserId: routing.operatorUserId, publishedAtMs: routing.publishedAtMs,
|
|
1577
|
+
expiresAtMs: routing.expiresAtMs, phase: routing.phase };
|
|
1578
|
+
}
|
|
1362
1579
|
const queueKind = value.queueKind;
|
|
1363
1580
|
const queueReceiptId = value.queueReceiptId;
|
|
1364
1581
|
const hasQueueMetadata =
|
|
@@ -1474,6 +1691,7 @@ function validateJournalEntry(
|
|
|
1474
1691
|
...(inputClaim ? { inputClaim } : {}),
|
|
1475
1692
|
...(inputProvenance ? { inputProvenance } : {}),
|
|
1476
1693
|
admittedAtMs: value.admittedAtMs,
|
|
1694
|
+
...(routingInput ? { routingInput } : {}),
|
|
1477
1695
|
state: value.state,
|
|
1478
1696
|
...(queueKind === "prompt" || queueKind === "control"
|
|
1479
1697
|
? { queueKind }
|
|
@@ -1522,6 +1740,32 @@ function assertSupportedJournalVersion(
|
|
|
1522
1740
|
}
|
|
1523
1741
|
}
|
|
1524
1742
|
|
|
1743
|
+
function validateJournalSourceCompletion(value: unknown, path: string): TelegramUpdateJournalSourceCompletion {
|
|
1744
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["updateId", "sourceSha256", "completionSha256"]) ||
|
|
1745
|
+
!isSafeNonNegativeInteger(value.updateId) || typeof value.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.sourceSha256) ||
|
|
1746
|
+
typeof value.completionSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.completionSha256)) {
|
|
1747
|
+
throw createJournalError("invalid", path, "has invalid source completion evidence");
|
|
1748
|
+
}
|
|
1749
|
+
return { updateId: value.updateId, sourceSha256: value.sourceSha256, completionSha256: value.completionSha256 };
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function validateJournalSourceCompletions(value: unknown, path: string, version: TelegramUpdateJournalFile["version"],
|
|
1753
|
+
allowEmpty = false): TelegramUpdateJournalSourceCompletion[] {
|
|
1754
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version) || !Array.isArray(value) || !allowEmpty && !value.length) {
|
|
1755
|
+
throw createJournalError("invalid", path, "has invalid source completion collection");
|
|
1756
|
+
}
|
|
1757
|
+
const completions = value.map(item => validateJournalSourceCompletion(item, path));
|
|
1758
|
+
const scopes = new Set<string>();
|
|
1759
|
+
for (let index = 0; index < completions.length; index++) {
|
|
1760
|
+
const completion = completions[index]!;
|
|
1761
|
+
if ((index > 0 && completions[index - 1]!.updateId >= completion.updateId) || scopes.has(completion.completionSha256)) {
|
|
1762
|
+
throw createJournalError("invalid", path, "has duplicate or unordered source completion evidence");
|
|
1763
|
+
}
|
|
1764
|
+
scopes.add(completion.completionSha256);
|
|
1765
|
+
}
|
|
1766
|
+
return completions;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1525
1769
|
function parseJournalFile(
|
|
1526
1770
|
value: unknown,
|
|
1527
1771
|
path: string,
|
|
@@ -1542,6 +1786,7 @@ function parseJournalFile(
|
|
|
1542
1786
|
"botIdentity",
|
|
1543
1787
|
"entries",
|
|
1544
1788
|
"operatorDispositions",
|
|
1789
|
+
"sourceCompletions",
|
|
1545
1790
|
]) ||
|
|
1546
1791
|
!Number.isSafeInteger(value.version) ||
|
|
1547
1792
|
(value.revision !== undefined &&
|
|
@@ -1635,6 +1880,14 @@ function parseJournalFile(
|
|
|
1635
1880
|
).map((disposition) =>
|
|
1636
1881
|
validateJournalOperatorDisposition(disposition, path),
|
|
1637
1882
|
);
|
|
1883
|
+
const sourceCompletions = value.sourceCompletions === undefined ? [] : validateJournalSourceCompletions(value.sourceCompletions, path, version);
|
|
1884
|
+
const botIdentity = validateBotIdentity(value.botIdentity, path);
|
|
1885
|
+
const activeIds = new Set(entries.map(entry => entry.updateId));
|
|
1886
|
+
const discardedIds = new Set(operatorDispositions.filter(disposition => disposition.action === "discard").map(disposition => disposition.updateId));
|
|
1887
|
+
if (sourceCompletions.some(completion => activeIds.has(completion.updateId) || discardedIds.has(completion.updateId) ||
|
|
1888
|
+
value.acceptedThroughUpdateId !== undefined && completion.updateId > (value.acceptedThroughUpdateId as number))) {
|
|
1889
|
+
throw createJournalError("invalid", path, "has contradictory source completion evidence");
|
|
1890
|
+
}
|
|
1638
1891
|
const dispositionFailureIds = new Set<string>();
|
|
1639
1892
|
const entriesByUpdateId = new Map(
|
|
1640
1893
|
entries.map((entry) => [entry.updateId, entry]),
|
|
@@ -1669,10 +1922,60 @@ function parseJournalFile(
|
|
|
1669
1922
|
? { acceptedThroughUpdateId: value.acceptedThroughUpdateId as number }
|
|
1670
1923
|
: {}),
|
|
1671
1924
|
profile: value.profile,
|
|
1672
|
-
botIdentity
|
|
1925
|
+
botIdentity,
|
|
1673
1926
|
entries,
|
|
1674
1927
|
...(operatorDispositions.length > 0 ? { operatorDispositions } : {}),
|
|
1928
|
+
...(sourceCompletions.length > 0 ? { sourceCompletions } : {}),
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
function assertJournalRoutingInputContinuity(file: TelegramUpdateJournalFile, entries: readonly TelegramUpdateJournalEntry[], path: string): void {
|
|
1933
|
+
const previous = new Map(file.entries.map(entry => [entry.updateId, entry.routingInput]));
|
|
1934
|
+
for (const entry of entries) {
|
|
1935
|
+
const retained = previous.get(entry.updateId), next = entry.routingInput;
|
|
1936
|
+
if (retained && (!next || next.operatorUserId !== retained.operatorUserId || next.publishedAtMs !== retained.publishedAtMs ||
|
|
1937
|
+
next.expiresAtMs !== retained.expiresAtMs || (retained.phase === "selected" && next.phase !== "selected"))) {
|
|
1938
|
+
throw createJournalError("invalid", path, "regresses retained routing input lifetime");
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function assertJournalSourceCompletionContinuity(file: TelegramUpdateJournalFile, segment: TelegramUpdateJournalSegment, path: string): void {
|
|
1944
|
+
assertJournalRoutingInputContinuity(file, segment.upsertedEntries, path);
|
|
1945
|
+
if (segment.sourceCompletions === undefined) return;
|
|
1946
|
+
const previous = new Map((file.sourceCompletions ?? []).map(completion => [completion.updateId, completion]));
|
|
1947
|
+
const next = new Map(segment.sourceCompletions.map(completion => [completion.updateId, completion]));
|
|
1948
|
+
if ([...previous].some(([id, completion]) => !isDeepStrictEqual(completion, next.get(id)))) {
|
|
1949
|
+
throw createJournalError("invalid", path, "regresses retained source completion evidence");
|
|
1950
|
+
}
|
|
1951
|
+
const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
|
|
1952
|
+
const removed = new Set(segment.removedUpdateIds), upserted = new Set(segment.upsertedEntries.map(entry => entry.updateId));
|
|
1953
|
+
const upsertedReceipts = new Set(segment.upsertedEntries.map(entry => entry.queueReceiptId));
|
|
1954
|
+
// Grouped markers share one whole-receipt check rather than rescanning the group per source.
|
|
1955
|
+
const queueGroups = new Map<string, TelegramUpdateJournalEntry[]>(), queueRemovals = new Map<string, boolean>();
|
|
1956
|
+
for (const entry of file.entries) {
|
|
1957
|
+
if (!entry.queueReceiptId) continue;
|
|
1958
|
+
const group = queueGroups.get(entry.queueReceiptId) ?? [];
|
|
1959
|
+
group.push(entry); queueGroups.set(entry.queueReceiptId, group);
|
|
1960
|
+
}
|
|
1961
|
+
const queueWasRemoved = (source: TelegramUpdateJournalEntry): boolean => {
|
|
1962
|
+
const receiptId = source.queueReceiptId;
|
|
1963
|
+
if (!receiptId || !source.queueOwner) return false;
|
|
1964
|
+
if (queueRemovals.has(receiptId)) return queueRemovals.get(receiptId)!;
|
|
1965
|
+
const group = queueGroups.get(receiptId);
|
|
1966
|
+
const valid = !!group?.length && !upsertedReceipts.has(receiptId) && group.every(entry =>
|
|
1967
|
+
removed.has(entry.updateId) && !upserted.has(entry.updateId) && entry.state === "queued" && entry.queueKind === source.queueKind &&
|
|
1968
|
+
entry.queueHandoff === undefined && !!entry.queueOwner && areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, source.queueOwner!));
|
|
1969
|
+
queueRemovals.set(receiptId, valid); return valid;
|
|
1675
1970
|
};
|
|
1971
|
+
for (const completion of segment.sourceCompletions) {
|
|
1972
|
+
if (previous.has(completion.updateId)) continue;
|
|
1973
|
+
const source = entries.get(completion.updateId);
|
|
1974
|
+
if (!removed.has(completion.updateId) || !source || (source.state === "queued" && !queueWasRemoved(source)) || source.state === "failed" ||
|
|
1975
|
+
createTelegramUpdateJournalEntryDigest(source).sourceSha256 !== completion.sourceSha256) {
|
|
1976
|
+
throw createJournalError("invalid", path, "source completion does not match its removal revision");
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1676
1979
|
}
|
|
1677
1980
|
|
|
1678
1981
|
function parseJournalSegment(
|
|
@@ -1697,6 +2000,7 @@ function parseJournalSegment(
|
|
|
1697
2000
|
"upsertedEntries",
|
|
1698
2001
|
"removedUpdateIds",
|
|
1699
2002
|
"operatorDispositions",
|
|
2003
|
+
"sourceCompletions",
|
|
1700
2004
|
]) ||
|
|
1701
2005
|
value.version !== version ||
|
|
1702
2006
|
!isSafePositiveInteger(value.revision) ||
|
|
@@ -1751,6 +2055,7 @@ function parseJournalSegment(
|
|
|
1751
2055
|
).map((disposition) =>
|
|
1752
2056
|
validateJournalOperatorDisposition(disposition, path),
|
|
1753
2057
|
);
|
|
2058
|
+
const segmentBotIdentity = validateBotIdentity(value.botIdentity, path);
|
|
1754
2059
|
return {
|
|
1755
2060
|
version,
|
|
1756
2061
|
revision: value.revision,
|
|
@@ -1759,12 +2064,13 @@ function parseJournalSegment(
|
|
|
1759
2064
|
? { acceptedThroughUpdateId: value.acceptedThroughUpdateId as number }
|
|
1760
2065
|
: {}),
|
|
1761
2066
|
profile: value.profile,
|
|
1762
|
-
botIdentity:
|
|
2067
|
+
botIdentity: segmentBotIdentity,
|
|
1763
2068
|
upsertedEntries,
|
|
1764
2069
|
removedUpdateIds,
|
|
1765
2070
|
...(value.operatorDispositions !== undefined
|
|
1766
2071
|
? { operatorDispositions }
|
|
1767
2072
|
: {}),
|
|
2073
|
+
...(value.sourceCompletions !== undefined ? { sourceCompletions: validateJournalSourceCompletions(value.sourceCompletions, path, version, true) } : {}),
|
|
1768
2074
|
};
|
|
1769
2075
|
}
|
|
1770
2076
|
|
|
@@ -1807,6 +2113,19 @@ export function inspectTelegramUpdateJournalFamily(input: {
|
|
|
1807
2113
|
return acquireTelegramUpdateJournalFamily(input).evidence;
|
|
1808
2114
|
}
|
|
1809
2115
|
|
|
2116
|
+
export interface TelegramUpdateJournalRetentionInspection {
|
|
2117
|
+
evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily>;
|
|
2118
|
+
retainedInputs: { path: string; journalBindingKey: string; failureId: string; updateId: number; state: "committed" | "uncommitted" }[];
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/** Classifies private originals against exact discard tombstones; never replay or deletion authority. */
|
|
2122
|
+
export function inspectTelegramUpdateJournalRetention(
|
|
2123
|
+
input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0],
|
|
2124
|
+
): TelegramUpdateJournalRetentionInspection {
|
|
2125
|
+
const acquired = acquireTelegramUpdateJournalFamily(input, undefined, true);
|
|
2126
|
+
return { evidence: acquired.evidence, retainedInputs: acquired.retainedInputs ?? [] };
|
|
2127
|
+
}
|
|
2128
|
+
|
|
1810
2129
|
/**
|
|
1811
2130
|
* Read-only source evidence for cooperating writers serialized by the caller through
|
|
1812
2131
|
* consumption; never readiness, recovery, or permission to publish. Ancestors retain
|
|
@@ -1821,7 +2140,7 @@ export function readTelegramUpdateJournalSource(
|
|
|
1821
2140
|
input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0] & { version: TelegramUpdateJournalFile["version"] },
|
|
1822
2141
|
): ReturnType<typeof inspectTelegramUpdateJournalFamily> {
|
|
1823
2142
|
const version = input.version;
|
|
1824
|
-
if (version !== 1 && version !== 2 && version !== 3) {
|
|
2143
|
+
if (version !== 1 && version !== 2 && version !== 3 && version !== 4) {
|
|
1825
2144
|
throw createJournalError("unsupported-version", input.path, "requires a selected source version");
|
|
1826
2145
|
}
|
|
1827
2146
|
return acquireTelegramUpdateJournalFamily({
|
|
@@ -1834,6 +2153,7 @@ export function readTelegramUpdateJournalSource(
|
|
|
1834
2153
|
function acquireTelegramUpdateJournalFamily(
|
|
1835
2154
|
input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0],
|
|
1836
2155
|
selectedVersion?: TelegramUpdateJournalFile["version"],
|
|
2156
|
+
inspectRetention = false,
|
|
1837
2157
|
): JournalSourceAcquisition {
|
|
1838
2158
|
const { path, limits } = input;
|
|
1839
2159
|
const acquiredSegments: JournalSourceAcquisition["segments"] = [];
|
|
@@ -1878,7 +2198,7 @@ function acquireTelegramUpdateJournalFamily(
|
|
|
1878
2198
|
};
|
|
1879
2199
|
const charge = (value: unknown) => {
|
|
1880
2200
|
if (!isRecord(value)) return;
|
|
1881
|
-
for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions"]) {
|
|
2201
|
+
for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions", "sourceCompletions"]) {
|
|
1882
2202
|
const collection = value[key];
|
|
1883
2203
|
if (!Array.isArray(collection)) continue;
|
|
1884
2204
|
if (collection.length > limits.maxEntries || collection.length > limits.maxWork - work) capacity();
|
|
@@ -1910,15 +2230,15 @@ function acquireTelegramUpdateJournalFamily(
|
|
|
1910
2230
|
} finally { closeSync(fd); }
|
|
1911
2231
|
};
|
|
1912
2232
|
const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
|
|
1913
|
-
const enumerate = (): string[] => {
|
|
2233
|
+
const enumerate = (target = segmentDirectory, pattern = /^\d{16}\.json$/u, usedFiles = files): string[] => {
|
|
1914
2234
|
const names: string[] = [];
|
|
1915
|
-
const directory = opendirSync(
|
|
2235
|
+
const directory = opendirSync(target, { bufferSize: 1 });
|
|
1916
2236
|
try {
|
|
1917
2237
|
for (;;) {
|
|
1918
2238
|
const entry = directory.readSync();
|
|
1919
2239
|
if (!entry) break;
|
|
1920
|
-
if (names.length >= limits.maxFiles -
|
|
1921
|
-
if (
|
|
2240
|
+
if (names.length >= limits.maxFiles - usedFiles) capacity();
|
|
2241
|
+
if (!pattern.test(entry.name) || !entry.isFile() || entry.isSymbolicLink()) fail("has an unexpected journal evidence entry");
|
|
1922
2242
|
names.push(entry.name);
|
|
1923
2243
|
}
|
|
1924
2244
|
} finally { directory.closeSync(); }
|
|
@@ -1976,6 +2296,7 @@ function acquireTelegramUpdateJournalFamily(
|
|
|
1976
2296
|
if (segment.revision !== revision + 1 || segment.previousRevision !== revision) fail("has a newer revision gap");
|
|
1977
2297
|
if (segment.acceptedThroughUpdateId !== undefined && file.acceptedThroughUpdateId !== undefined &&
|
|
1978
2298
|
segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId) fail("regresses the admission cursor");
|
|
2299
|
+
assertJournalSourceCompletionContinuity(file, segment, target);
|
|
1979
2300
|
const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
|
|
1980
2301
|
for (const id of segment.removedUpdateIds) entries.delete(id);
|
|
1981
2302
|
for (const entry of segment.upsertedEntries) {
|
|
@@ -1993,15 +2314,63 @@ function acquireTelegramUpdateJournalFamily(
|
|
|
1993
2314
|
...(segment.acceptedThroughUpdateId !== undefined ? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId } : {}),
|
|
1994
2315
|
entries: [...entries.values()].sort((a, b) => a.updateId - b.updateId),
|
|
1995
2316
|
operatorDispositions: segment.operatorDispositions ?? file.operatorDispositions,
|
|
2317
|
+
sourceCompletions: segment.sourceCompletions ?? file.sourceCompletions,
|
|
1996
2318
|
};
|
|
1997
2319
|
charge(next);
|
|
1998
2320
|
file = parseJournalFile(next, target, file.version);
|
|
1999
2321
|
}
|
|
2000
2322
|
}
|
|
2323
|
+
const retainedInputs: TelegramUpdateJournalRetentionInspection["retainedInputs"] = [];
|
|
2324
|
+
const retentionDirectory = `${path}.retained`;
|
|
2325
|
+
const retention = inspectRetention ? observe(retentionDirectory, true, true) : undefined;
|
|
2326
|
+
const retentionPattern = /^abandon-[a-f0-9]{64}\.json$/u;
|
|
2327
|
+
const retentionNames = retention ? enumerate(retentionDirectory, retentionPattern) : [];
|
|
2328
|
+
if (retentionNames.length > limits.maxEntries) capacity();
|
|
2329
|
+
if (retention) {
|
|
2330
|
+
if (!file || file.version !== 1) fail("private retention requires its exact v1 journal snapshot");
|
|
2331
|
+
const current = file!;
|
|
2332
|
+
const bindingKeys = new Set([expected, current.botIdentity].map(botIdentity =>
|
|
2333
|
+
createTelegramUpdateJournalBindingKey({ path, profileName: input.profile, botIdentity })));
|
|
2334
|
+
const updateIds = new Set<number>();
|
|
2335
|
+
for (const name of retentionNames) {
|
|
2336
|
+
const target = join(retentionDirectory, name);
|
|
2337
|
+
const before = observe(target, false)!;
|
|
2338
|
+
if (before.nlink !== 1n) fail("has linked private retention evidence");
|
|
2339
|
+
const raw = read(target, before);
|
|
2340
|
+
files += 1;
|
|
2341
|
+
if (limits.maxWork - work < 2) capacity();
|
|
2342
|
+
work += 2;
|
|
2343
|
+
if (!isRecord(raw)) throw createJournalError("invalid", target, "private retention is not an evidence record");
|
|
2344
|
+
if (typeof raw.journalBindingKey !== "string" || !bindingKeys.has(raw.journalBindingKey))
|
|
2345
|
+
throw createJournalError("invalid", target, "has foreign private retention evidence");
|
|
2346
|
+
const entry = validateJournalEntry(raw.entry, target, 1);
|
|
2347
|
+
const disposition = validateJournalOperatorDisposition(raw.requestedDisposition, target);
|
|
2348
|
+
const digest = createHash("sha256").update(JSON.stringify({ journalBindingKey: raw.journalBindingKey, entry })).digest("hex");
|
|
2349
|
+
if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
|
|
2350
|
+
disposition.action !== "discard" || entry.state !== "pending" || disposition.updateId !== entry.updateId ||
|
|
2351
|
+
disposition.evidenceSha256 !== digest || disposition.failureId !== `abandon-${digest}` ||
|
|
2352
|
+
name !== `${disposition.failureId}.json` || !isDeepStrictEqual(raw, { version: 1, kind: "pending-input-retention",
|
|
2353
|
+
journalBindingKey: raw.journalBindingKey, entry, requestedDisposition: disposition })) fail("has inconsistent private retention evidence");
|
|
2354
|
+
if (updateIds.has(entry.updateId)) fail("has duplicate private retention sources");
|
|
2355
|
+
updateIds.add(entry.updateId);
|
|
2356
|
+
const committed = current.operatorDispositions?.find(value => value.failureId === disposition.failureId || value.updateId === entry.updateId);
|
|
2357
|
+
if (committed && (!isDeepStrictEqual(committed, disposition) || current.entries.some(value => value.updateId === entry.updateId)))
|
|
2358
|
+
fail("private retention contradicts its journal disposition");
|
|
2359
|
+
retainedInputs.push({ path: target, journalBindingKey: raw.journalBindingKey,
|
|
2360
|
+
failureId: disposition.failureId, updateId: entry.updateId, state: committed ? "committed" : "uncommitted" });
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
if (inspectRetention && file?.operatorDispositions?.some(disposition =>
|
|
2364
|
+
"dispositionKind" in disposition && disposition.dispositionKind === "legacy-custody" &&
|
|
2365
|
+
disposition.action === "discard" && /^abandon-[a-f0-9]{64}$/u.test(disposition.failureId) &&
|
|
2366
|
+
!retainedInputs.some(original => original.updateId === disposition.updateId && original.state === "committed")))
|
|
2367
|
+
fail("committed abandonment lost its private original");
|
|
2001
2368
|
// Re-enumeration has the same bound, rather than allocating an unbounded census.
|
|
2002
2369
|
files = snapshot ? 1 : 0;
|
|
2003
2370
|
if (segments && !isDeepStrictEqual(names, enumerate())) fail("changed segment namespace");
|
|
2004
2371
|
files += names.length;
|
|
2372
|
+
if (retention && !isDeepStrictEqual(retentionNames, enumerate(retentionDirectory, retentionPattern))) fail("changed private retention namespace");
|
|
2373
|
+
files += retentionNames.length;
|
|
2005
2374
|
for (const [target, before] of observed) {
|
|
2006
2375
|
const after = status(target);
|
|
2007
2376
|
const compare = selectedVersion !== undefined && ancestors.has(target) ? sameAncestor : same;
|
|
@@ -2023,6 +2392,7 @@ function acquireTelegramUpdateJournalFamily(
|
|
|
2023
2392
|
evidence: file ? { kind: "present", file, ...(knownBotId !== undefined ? { knownBotId } : {}), accounting: { files, bytes, work } } : { kind: "absent" },
|
|
2024
2393
|
snapshotRevision,
|
|
2025
2394
|
segments: acquiredSegments,
|
|
2395
|
+
...(inspectRetention ? { retainedInputs } : {}),
|
|
2026
2396
|
};
|
|
2027
2397
|
} catch (error) {
|
|
2028
2398
|
if (error instanceof TelegramUpdateJournalError) throw error;
|
|
@@ -2036,16 +2406,35 @@ function acquireTelegramUpdateJournalFamily(
|
|
|
2036
2406
|
* Recensus detects observable changes, not hostile same-user path swaps; arbitrary
|
|
2037
2407
|
* consumer references and archive consumption require separate audits.
|
|
2038
2408
|
*/
|
|
2039
|
-
export
|
|
2409
|
+
export interface TelegramJournalNamespaceInspectionInput {
|
|
2040
2410
|
directory: string;
|
|
2041
2411
|
profile: string;
|
|
2042
2412
|
botIdentity: TelegramUpdateJournalBotIdentity;
|
|
2043
2413
|
limits: { maxDirectoryEntries: number; maxFiles: number; maxBytes: number; maxEntries: number; maxWork: number };
|
|
2044
|
-
|
|
2045
|
-
|
|
2414
|
+
/** Leader polling journal named by owners.json; defaults to the flat root `inbox`. */
|
|
2415
|
+
pollingPath?: string;
|
|
2416
|
+
/** Hold a participating consumer reference during each physical family read. */
|
|
2417
|
+
withSourceReference?: <T>(path: string, operation: () => T) => T;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
export interface TelegramJournalNamespaceInspection {
|
|
2421
|
+
/** Original preservation facts only, never replay/deletion grants. */
|
|
2422
|
+
retainedInputs?: (TelegramUpdateJournalRetentionInspection["retainedInputs"][number] & { journalPath: string })[];
|
|
2423
|
+
sources: { role: "polling" | "follower" | "session"; path: string; evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily> }[];
|
|
2046
2424
|
accounting: { directoryEntries: number; files: number; bytes: number; work: number };
|
|
2047
2425
|
knownBotId?: number;
|
|
2048
|
-
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
export function inspectTelegramProfileJournalNamespace(input: TelegramJournalNamespaceInspectionInput): TelegramJournalNamespaceInspection {
|
|
2429
|
+
return inspectTelegramJournalNamespace(input, false);
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
/** Read-only evidence over polling, retained flat recipients and role-neutral session journals. */
|
|
2433
|
+
export function inspectTelegramSessionJournalNamespace(input: TelegramJournalNamespaceInspectionInput): TelegramJournalNamespaceInspection {
|
|
2434
|
+
return inspectTelegramJournalNamespace(input, true);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
function inspectTelegramJournalNamespace(input: TelegramJournalNamespaceInspectionInput, includeSessions: boolean): TelegramJournalNamespaceInspection {
|
|
2049
2438
|
const { directory, profile, limits } = input;
|
|
2050
2439
|
const fail = (message: string): never => { throw createJournalError("invalid", directory, message); };
|
|
2051
2440
|
const capacity = (): never => { throw createJournalError("capacity", directory, "exceeds namespace inspection resource limits"); };
|
|
@@ -2062,46 +2451,99 @@ export function inspectTelegramProfileJournalNamespace(input: {
|
|
|
2062
2451
|
size: value.size, mtimeNs: value.mtimeNs, ctimeNs: value.ctimeNs };
|
|
2063
2452
|
};
|
|
2064
2453
|
const suffix = profile === "default" ? "" : `.${profile}`;
|
|
2065
|
-
const
|
|
2454
|
+
const pollingPath = input.pollingPath ?? join(directory, `inbox${suffix}.json`);
|
|
2455
|
+
const polling = relative(directory, pollingPath);
|
|
2456
|
+
{
|
|
2457
|
+
const parts = polling.split(sep);
|
|
2458
|
+
if (!isAbsolute(pollingPath) || resolve(pollingPath) !== pollingPath || parts.at(-1) !== `inbox${suffix}.json` ||
|
|
2459
|
+
!(parts.length === 1 || (parts.length === 3 && parts[0] === "sessions" &&
|
|
2460
|
+
decodeTelegramSessionDirectoryName(parts[1]!) !== undefined))) fail("requires a canonical polling journal path");
|
|
2461
|
+
}
|
|
2066
2462
|
try {
|
|
2067
2463
|
const root = lstatSync(directory);
|
|
2068
2464
|
if (!root.isDirectory() || root.isSymbolicLink() || realpathSync(directory) !== directory) fail("requires a canonical directory anchor");
|
|
2069
2465
|
const beforeRoot = metadata(directory);
|
|
2070
2466
|
const census = () => {
|
|
2071
2467
|
const entries = new Map<string, ReturnType<typeof metadata>>();
|
|
2072
|
-
const
|
|
2073
|
-
const
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2468
|
+
const families = new Map<string, "follower" | "session">(), profiles = new Set<string>();
|
|
2469
|
+
const scan = (parent: string, visit: (name: string, target: string) => void): void => {
|
|
2470
|
+
const handle = opendirSync(parent, { bufferSize: 1 });
|
|
2471
|
+
try {
|
|
2472
|
+
for (;;) {
|
|
2473
|
+
const entry = handle.readSync();
|
|
2474
|
+
if (!entry) break;
|
|
2475
|
+
if (entries.size >= limits.maxDirectoryEntries) capacity();
|
|
2476
|
+
const target = join(parent, entry.name);
|
|
2477
|
+
const key = relative(directory, target);
|
|
2478
|
+
if (entries.has(key)) fail("repeated an observed namespace entry");
|
|
2479
|
+
entries.set(key, metadata(target));
|
|
2480
|
+
visit(entry.name, target);
|
|
2481
|
+
}
|
|
2482
|
+
} finally { handle.closeSync(); }
|
|
2483
|
+
};
|
|
2484
|
+
const requireType = (target: string, isDirectory: boolean): void => {
|
|
2485
|
+
const value = lstatSync(target);
|
|
2486
|
+
if (value.isSymbolicLink() || !(isDirectory ? value.isDirectory() : value.isFile()) ||
|
|
2487
|
+
(!isDirectory && value.nlink !== 1)) fail("has a linked or unexpected journal type");
|
|
2488
|
+
};
|
|
2489
|
+
const evidenceDirectory = (target: string, retained: boolean): void => scan(target, (name, path) => {
|
|
2490
|
+
if (!(retained ? /^abandon-[a-f0-9]{64}\.json$/u : /^\d{16}\.json$/u).test(name)) fail("has an unclassified journal evidence entry");
|
|
2491
|
+
requireType(path, false);
|
|
2492
|
+
});
|
|
2493
|
+
const session = (target: string): void => scan(target, (name, path) => {
|
|
2494
|
+
if (name === "recovery") return;
|
|
2495
|
+
const match = /^(?:journal\.[a-f0-9]{16}|inbox)(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
|
|
2496
|
+
if (!match || match[1] === "default") fail("has an unclassified session journal entry");
|
|
2497
|
+
requireType(path, !!match![2]);
|
|
2498
|
+
if (match![2]) evidenceDirectory(path, match![2] === ".retained");
|
|
2499
|
+
const base = path.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match![1] ?? "default";
|
|
2500
|
+
profiles.add(familyProfile);
|
|
2501
|
+
if (familyProfile === profile && base !== pollingPath) families.set(base, "session");
|
|
2502
|
+
});
|
|
2503
|
+
scan(directory, (name, target) => {
|
|
2504
|
+
// Legacy quarantine folders are disposable housekeeping, never journal authority.
|
|
2505
|
+
if (name.toLowerCase() === "recovery") return;
|
|
2506
|
+
if (name.toLowerCase() === "sessions") {
|
|
2507
|
+
if (!includeSessions) fail("requires session-qualified namespace inspection");
|
|
2508
|
+
if (name !== "sessions") fail("has a noncanonical session root");
|
|
2509
|
+
requireType(target, true);
|
|
2510
|
+
scan(target, (sessionName, sessionPath) => {
|
|
2511
|
+
if (decodeTelegramSessionDirectoryName(sessionName) === undefined) fail("has a noncanonical session directory");
|
|
2512
|
+
requireType(sessionPath, true);
|
|
2513
|
+
session(sessionPath);
|
|
2514
|
+
});
|
|
2515
|
+
return;
|
|
2089
2516
|
}
|
|
2090
|
-
|
|
2091
|
-
|
|
2517
|
+
if (includeSessions && /^journal\./iu.test(name)) fail("has a misplaced session journal");
|
|
2518
|
+
if (!/inbox/iu.test(name)) return;
|
|
2519
|
+
const match = /^(inbox|follower-inbox-[a-f0-9]{16})(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
|
|
2520
|
+
if (!match || match[2] === "default" || (!includeSessions && match[3] === ".retained")) fail("has a noncanonical journal-like entry");
|
|
2521
|
+
requireType(target, !!match![3]);
|
|
2522
|
+
if (includeSessions && match![3]) evidenceDirectory(target, match![3] === ".retained");
|
|
2523
|
+
const base = target.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match![2] ?? "default";
|
|
2524
|
+
profiles.add(familyProfile);
|
|
2525
|
+
if (familyProfile === profile && base !== pollingPath)
|
|
2526
|
+
families.set(base, match![1] === "inbox" ? "session" : "follower");
|
|
2527
|
+
});
|
|
2528
|
+
return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0),
|
|
2529
|
+
families: [...families].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), profiles: [...profiles].sort() };
|
|
2092
2530
|
};
|
|
2093
2531
|
const before = census();
|
|
2094
|
-
const
|
|
2532
|
+
const sourceInventory = [[pollingPath, "polling"], ...before.families] as const;
|
|
2533
|
+
const sources: TelegramJournalNamespaceInspection["sources"] = [];
|
|
2534
|
+
const retainedInputs: NonNullable<TelegramJournalNamespaceInspection["retainedInputs"]> = [];
|
|
2095
2535
|
const accounting = { directoryEntries: before.entries.length, files: 0, bytes: 0, work: 0 };
|
|
2096
2536
|
let knownBotId = expected.botId;
|
|
2097
|
-
for (const
|
|
2537
|
+
for (const [path, role] of sourceInventory) {
|
|
2098
2538
|
const remaining = { maxFiles: limits.maxFiles - accounting.files, maxBytes: limits.maxBytes - accounting.bytes,
|
|
2099
2539
|
maxWork: limits.maxWork - accounting.work, maxEntries: limits.maxEntries };
|
|
2100
2540
|
if (remaining.maxFiles <= 0 || remaining.maxBytes <= 0 || remaining.maxWork <= 0) capacity();
|
|
2101
|
-
const
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2541
|
+
const acquire = () => acquireTelegramUpdateJournalFamily({ directory, path, profile,
|
|
2542
|
+
botIdentity: { ...expected, ...(knownBotId !== undefined ? { botId: knownBotId } : {}) }, limits: remaining }, undefined, includeSessions);
|
|
2543
|
+
const acquired = input.withSourceReference ? input.withSourceReference(path, acquire) : acquire();
|
|
2544
|
+
const evidence = acquired.evidence;
|
|
2545
|
+
retainedInputs.push(...(acquired.retainedInputs ?? []).map(original => ({ ...original, journalPath: path })));
|
|
2546
|
+
if (evidence.kind === "absent" && (role !== "polling" || before.entries.some(([entry]) => entry === polling || entry === `${polling}.segments`))) {
|
|
2105
2547
|
fail("lost a discovered journal family");
|
|
2106
2548
|
}
|
|
2107
2549
|
if (evidence.kind === "present") {
|
|
@@ -2110,16 +2552,46 @@ export function inspectTelegramProfileJournalNamespace(input: {
|
|
|
2110
2552
|
accounting.bytes += evidence.accounting.bytes;
|
|
2111
2553
|
}
|
|
2112
2554
|
accounting.work += evidence.kind === "present" ? Math.max(1, evidence.accounting.work) : 1;
|
|
2113
|
-
sources.push({ role
|
|
2555
|
+
sources.push({ role, path, evidence });
|
|
2114
2556
|
}
|
|
2115
|
-
|
|
2116
|
-
|
|
2557
|
+
const namespaceCurrent = (): boolean => {
|
|
2558
|
+
try { return isDeepStrictEqual(beforeRoot, metadata(directory)) && isDeepStrictEqual(before, census()) &&
|
|
2559
|
+
isDeepStrictEqual(beforeRoot, metadata(directory)); }
|
|
2560
|
+
catch { return false; }
|
|
2561
|
+
};
|
|
2562
|
+
if (!namespaceCurrent()) fail("changed observed root namespace or identity");
|
|
2563
|
+
return { sources, accounting, ...(includeSessions ? { retainedInputs } : {}), ...(knownBotId !== undefined ? { knownBotId } : {}) };
|
|
2117
2564
|
} catch (error) {
|
|
2118
2565
|
if (error instanceof TelegramUpdateJournalError) throw error;
|
|
2119
2566
|
throw createJournalError("io", directory, "could not establish canonical namespace evidence", error);
|
|
2120
2567
|
}
|
|
2121
2568
|
}
|
|
2122
2569
|
|
|
2570
|
+
/** Complete-empty protection only; caller holds source serialization through consumption. Never deletion authority. */
|
|
2571
|
+
export function isTelegramThreadCleanupJournalNamespaceClear(input: TelegramJournalNamespaceInspectionInput & {
|
|
2572
|
+
requiredJournalBindingKeys: readonly string[];
|
|
2573
|
+
withSourceReference: NonNullable<TelegramJournalNamespaceInspectionInput["withSourceReference"]>;
|
|
2574
|
+
}): boolean {
|
|
2575
|
+
if (!input.requiredJournalBindingKeys.length || input.requiredJournalBindingKeys.length > input.limits.maxDirectoryEntries)
|
|
2576
|
+
throw new Error("Telegram temporary cleanup requires bounded exact journal references.");
|
|
2577
|
+
const requiredPaths = new Set<string>();
|
|
2578
|
+
for (const key of input.requiredJournalBindingKeys) {
|
|
2579
|
+
const path = getTelegramUpdateJournalBindingPath(key);
|
|
2580
|
+
const identities = [input.botIdentity, { tokenSha256: input.botIdentity.tokenSha256 }];
|
|
2581
|
+
if (!path || !identities.some(botIdentity => key === createTelegramUpdateJournalBindingKey({
|
|
2582
|
+
path, profileName: input.profile, botIdentity,
|
|
2583
|
+
}))) throw new Error("Telegram temporary cleanup has foreign or malformed journal references.");
|
|
2584
|
+
requiredPaths.add(path);
|
|
2585
|
+
}
|
|
2586
|
+
const namespace = inspectTelegramSessionJournalNamespace(input);
|
|
2587
|
+
if (namespace.sources.filter(source => source.role === "polling").length !== 1 ||
|
|
2588
|
+
namespace.retainedInputs?.some(original => original.state !== "committed") ||
|
|
2589
|
+
[...requiredPaths].some(path => !namespace.sources.some(source => source.path === path && source.evidence.kind === "present")))
|
|
2590
|
+
return false;
|
|
2591
|
+
// Original Telegram targets do not certify execution ownership. No nonempty family gets a source exemption.
|
|
2592
|
+
return namespace.sources.every(source => source.evidence.kind === "absent" || source.evidence.file.entries.length === 0);
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2123
2595
|
function cloneEntry(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEntry {
|
|
2124
2596
|
return {
|
|
2125
2597
|
...entry,
|
|
@@ -2134,6 +2606,7 @@ function cloneEntry(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEnt
|
|
|
2134
2606
|
? { queueHandoff: cloneJournalQueueHandoff(entry.queueHandoff) }
|
|
2135
2607
|
: {}),
|
|
2136
2608
|
...(entry.failure ? { failure: { ...entry.failure } } : {}),
|
|
2609
|
+
...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
|
|
2137
2610
|
};
|
|
2138
2611
|
}
|
|
2139
2612
|
|
|
@@ -2147,6 +2620,7 @@ function cloneFile(file: TelegramUpdateJournalFile): TelegramUpdateJournalFile {
|
|
|
2147
2620
|
profile: file.profile,
|
|
2148
2621
|
botIdentity: { ...file.botIdentity },
|
|
2149
2622
|
entries: file.entries.map(cloneEntry),
|
|
2623
|
+
...(file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions.map(completion => ({ ...completion })) } : {}),
|
|
2150
2624
|
...(file.operatorDispositions?.length
|
|
2151
2625
|
? {
|
|
2152
2626
|
operatorDispositions: file.operatorDispositions.map(
|
|
@@ -2288,6 +2762,46 @@ function mergeBotIdentity(
|
|
|
2288
2762
|
};
|
|
2289
2763
|
}
|
|
2290
2764
|
|
|
2765
|
+
function createPendingRetentionReference(input: {
|
|
2766
|
+
path: string;
|
|
2767
|
+
journalBindingKey: string;
|
|
2768
|
+
entry: TelegramUpdateJournalEntry;
|
|
2769
|
+
maxBytes: number;
|
|
2770
|
+
}) {
|
|
2771
|
+
const { path, journalBindingKey, entry, maxBytes } = input;
|
|
2772
|
+
const evidenceSha256 = createHash("sha256").update(JSON.stringify({ journalBindingKey, entry })).digest("hex");
|
|
2773
|
+
const failureId = `abandon-${evidenceSha256}`;
|
|
2774
|
+
const retainedDirectory = `${path}.retained`;
|
|
2775
|
+
const retainedPath = join(retainedDirectory, `${failureId}.json`);
|
|
2776
|
+
const read = (): TelegramUpdateJournalRetainedInput | undefined => {
|
|
2777
|
+
let directory;
|
|
2778
|
+
try { directory = lstatSync(retainedDirectory); }
|
|
2779
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
|
|
2780
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) {
|
|
2781
|
+
throw createJournalError("invalid", retainedDirectory, "retention directory is not a regular directory");
|
|
2782
|
+
}
|
|
2783
|
+
let stat;
|
|
2784
|
+
try { stat = lstatSync(retainedPath); }
|
|
2785
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
|
|
2786
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
|
|
2787
|
+
throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
|
|
2788
|
+
}
|
|
2789
|
+
const retained: unknown = JSON.parse(readFileSync(retainedPath, "utf8"));
|
|
2790
|
+
if (!isRecord(retained)) throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
|
|
2791
|
+
const disposition = validateJournalOperatorDisposition(retained.requestedDisposition, retainedPath);
|
|
2792
|
+
if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
|
|
2793
|
+
disposition.failureId !== failureId || disposition.updateId !== entry.updateId ||
|
|
2794
|
+
disposition.action !== "discard" || disposition.evidenceSha256 !== evidenceSha256 ||
|
|
2795
|
+
!isDeepStrictEqual(retained, { version: 1, kind: "pending-input-retention", journalBindingKey,
|
|
2796
|
+
entry, requestedDisposition: disposition })) {
|
|
2797
|
+
throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
|
|
2798
|
+
}
|
|
2799
|
+
return { version: 1, kind: "pending-input-retention", journalBindingKey, entry,
|
|
2800
|
+
requestedDisposition: disposition };
|
|
2801
|
+
};
|
|
2802
|
+
return { evidenceSha256, failureId, retainedPath, read };
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2291
2805
|
function writeJournalFile(
|
|
2292
2806
|
path: string,
|
|
2293
2807
|
serialized: string,
|
|
@@ -2428,7 +2942,7 @@ export interface TelegramUpdateJournalRuntimeBinding {
|
|
|
2428
2942
|
runtimeKey: string;
|
|
2429
2943
|
recoveryKey: string;
|
|
2430
2944
|
journal: TelegramUpdateJournalStore;
|
|
2431
|
-
readForProtection?: () => { entries: readonly TelegramUpdateJournalEntry[] };
|
|
2945
|
+
readForProtection?: () => { entries: readonly TelegramUpdateJournalEntry[]; exists: boolean };
|
|
2432
2946
|
}
|
|
2433
2947
|
|
|
2434
2948
|
export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
|
|
@@ -2438,11 +2952,14 @@ export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
|
|
|
2438
2952
|
getJournalPath: (profileName?: string) => string;
|
|
2439
2953
|
getQueueRuntimeIdentity?: () => TelegramUpdateJournalQueueRuntimeIdentity;
|
|
2440
2954
|
withWriterAdmission?: <T>(operation: () => T) => T;
|
|
2955
|
+
withSourceSerialization?: TelegramUpdateJournalStoreOptions["withSourceSerialization"];
|
|
2441
2956
|
getWorkspaceAdmission?: () => Pick<
|
|
2442
2957
|
TelegramWorkspaceAdmissionLedger,
|
|
2443
2958
|
"acquireAdmission" | "releaseAdmission"
|
|
2444
2959
|
> | undefined;
|
|
2445
2960
|
onRecovery?: (event: TelegramUpdateJournalRecoveryEvent) => void;
|
|
2961
|
+
/** Strict no-follow source handles; defaults to platform support (absent on Windows). */
|
|
2962
|
+
strictSourceAccess?: boolean;
|
|
2446
2963
|
}
|
|
2447
2964
|
|
|
2448
2965
|
export function createTelegramUpdateJournalRuntimeBindingResolver(
|
|
@@ -2460,6 +2977,26 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
|
|
|
2460
2977
|
});
|
|
2461
2978
|
const path = deps.getJournalPath(configuredProfileName);
|
|
2462
2979
|
const workspaceAdmission = deps.getWorkspaceAdmission?.();
|
|
2980
|
+
const sourceAccess = { directory: dirname(path), limits: {
|
|
2981
|
+
maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
|
|
2982
|
+
maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
|
|
2983
|
+
} };
|
|
2984
|
+
const options: TelegramUpdateJournalStoreOptions = { path, profileName, botIdentity,
|
|
2985
|
+
...(deps.getQueueRuntimeIdentity ? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() } : {}),
|
|
2986
|
+
...(workspaceAdmission ? { workspaceAdmission } : {}),
|
|
2987
|
+
...(deps.withWriterAdmission ? { withWriterAdmission: deps.withWriterAdmission } : {}),
|
|
2988
|
+
...(deps.withSourceSerialization ? { withSourceSerialization: deps.withSourceSerialization } : {}),
|
|
2989
|
+
...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}) };
|
|
2990
|
+
const journal = createTelegramUpdateJournalStore(options);
|
|
2991
|
+
// Scoped completion and receipt observation use strict private handles where the platform provides no-follow
|
|
2992
|
+
// evidence; elsewhere (Windows) the same capabilities read through the ordinary journal so queue receipts still work.
|
|
2993
|
+
const strict = deps.strictSourceAccess ?? Boolean(constants.O_NOFOLLOW && constants.O_NONBLOCK);
|
|
2994
|
+
const completionJournal = deps.withSourceSerialization
|
|
2995
|
+
? createTelegramUpdateJournalStore({ ...options, ...(strict ? { sourceAccess } : {}) }) : undefined;
|
|
2996
|
+
// Queue receipt readiness gates every ordinary prompt; without strict handles it uses the ordinary journal read.
|
|
2997
|
+
const observeQueuedReceipt = (expected: TelegramUpdateJournalQueuedCompletion) => strict
|
|
2998
|
+
? completionJournal!.inspectQueuedReceipt(expected)
|
|
2999
|
+
: inspectJournalQueuedReceipt(journal.read(), { ...expected, queueOwner: validateQueuedReceiptObservation(expected, path) }, path);
|
|
2463
3000
|
return {
|
|
2464
3001
|
runtimeKey: JSON.stringify({
|
|
2465
3002
|
path,
|
|
@@ -2474,27 +3011,24 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
|
|
|
2474
3011
|
readForProtection() {
|
|
2475
3012
|
// Protection must never turn corruption/recovery into empty-work evidence.
|
|
2476
3013
|
const evidence = inspectTelegramUpdateJournalFamily({
|
|
2477
|
-
|
|
2478
|
-
limits: {
|
|
2479
|
-
maxFiles: 1024,
|
|
2480
|
-
maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
|
|
2481
|
-
maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES,
|
|
2482
|
-
maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
|
|
2483
|
-
},
|
|
3014
|
+
...sourceAccess, path, profile: profileName, botIdentity,
|
|
2484
3015
|
});
|
|
2485
|
-
return { entries: evidence.kind === "present" ? evidence.file.entries : [] };
|
|
3016
|
+
return { entries: evidence.kind === "present" ? evidence.file.entries : [], exists: evidence.kind === "present" };
|
|
2486
3017
|
},
|
|
2487
|
-
journal:
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
3018
|
+
journal: completionJournal ? { ...journal,
|
|
3019
|
+
removeCompletedExact(updateIds, expectedSources, completions) {
|
|
3020
|
+
return completions !== undefined ? completionJournal.removeCompletedExact(updateIds, expectedSources, completions)
|
|
3021
|
+
: journal.removeCompletedExact(updateIds, expectedSources);
|
|
3022
|
+
},
|
|
3023
|
+
completeQueuedExact(receipts, completions) { return completionJournal.completeQueuedExact(receipts, completions); },
|
|
3024
|
+
inspectSourceCompletion(expected) { return completionJournal.inspectSourceCompletion(expected); },
|
|
3025
|
+
inspectQueuedReceipt: observeQueuedReceipt,
|
|
3026
|
+
isQueueReceiptCurrent(receipt, owner) {
|
|
3027
|
+
if (receipt.journalBindingKey !== createTelegramUpdateJournalBindingKey({ path, profileName, botIdentity })) return false;
|
|
3028
|
+
return observeQueuedReceipt({ queueKind: receipt.queueKind, receiptId: receipt.receiptId,
|
|
3029
|
+
sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...owner } }) !== undefined;
|
|
3030
|
+
},
|
|
3031
|
+
} : journal,
|
|
2498
3032
|
};
|
|
2499
3033
|
};
|
|
2500
3034
|
}
|
|
@@ -2560,13 +3094,83 @@ export function withTelegramResolvedUpdateJournalReference<T>(input: {
|
|
|
2560
3094
|
recoveryKey: binding.recoveryKey }, () => input.operation(binding));
|
|
2561
3095
|
}
|
|
2562
3096
|
|
|
3097
|
+
function validateQueuedReceiptObservation(expected: TelegramUpdateJournalQueuedCompletion, path: string): TelegramUpdateJournalQueueOwner {
|
|
3098
|
+
if (!isRecord(expected) || !hasOnlyKeys(expected, ["queueKind", "receiptId", "sourceUpdateIds", "queueOwner"]) || (expected.queueKind !== "prompt" && expected.queueKind !== "control") ||
|
|
3099
|
+
!isNonEmptyString(expected.receiptId) ||
|
|
3100
|
+
!Array.isArray(expected.sourceUpdateIds) || expected.sourceUpdateIds.length === 0 ||
|
|
3101
|
+
expected.sourceUpdateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
|
|
3102
|
+
expected.sourceUpdateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= expected.sourceUpdateIds[index - 1]!))) {
|
|
3103
|
+
throw createJournalError("invalid", path, "received invalid queue receipt observation authority");
|
|
3104
|
+
}
|
|
3105
|
+
return validateJournalQueueOwner(expected.queueOwner, path);
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
function inspectJournalQueuedReceipt(file: TelegramUpdateJournalFile, expected: TelegramUpdateJournalQueuedCompletion,
|
|
3109
|
+
path: string): TelegramUpdateJournalQueuedReceiptEvidence | undefined {
|
|
3110
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version)) throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
|
|
3111
|
+
const { queueOwner } = expected;
|
|
3112
|
+
const entries = file.entries.filter(entry => entry.queueReceiptId === expected.receiptId);
|
|
3113
|
+
if (entries.length !== expected.sourceUpdateIds.length || entries.some((entry, index) =>
|
|
3114
|
+
entry.updateId !== expected.sourceUpdateIds[index] || entry.state !== "queued" || entry.queueKind !== expected.queueKind ||
|
|
3115
|
+
entry.queueHandoff !== undefined || !isDeepStrictEqual(entry.queueOwner, queueOwner))) return undefined;
|
|
3116
|
+
return { receipt: { queueKind: expected.queueKind, receiptId: expected.receiptId,
|
|
3117
|
+
sourceUpdateIds: [...expected.sourceUpdateIds], queueOwner: { ...queueOwner } },
|
|
3118
|
+
sources: entries.map(createTelegramUpdateJournalEntryDigest),
|
|
3119
|
+
queueOwnerSha256: createHash("sha256").update(JSON.stringify(queueOwner)).digest("hex") };
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
function inspectJournalSourceCompletion(file: TelegramUpdateJournalFile, completion: TelegramUpdateJournalSourceCompletion,
|
|
3123
|
+
path: string): TelegramUpdateJournalSourceCompletion | undefined {
|
|
3124
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version)) throw createJournalError("invalid", path, "source completion requires a legacy v1 source");
|
|
3125
|
+
const retained = file.sourceCompletions?.find(value => value.updateId === completion.updateId);
|
|
3126
|
+
if (!retained) return undefined;
|
|
3127
|
+
if (!isDeepStrictEqual(retained, completion)) throw createJournalError("conflict", path, "source completion belongs to another exact scope");
|
|
3128
|
+
return { ...retained };
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
/** Operator-distinct authority: routing never treats it as an owner cancellation. */
|
|
3132
|
+
export const TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX = "session-successor:";
|
|
3133
|
+
|
|
3134
|
+
export interface TelegramSessionPendingAdoptionInput {
|
|
3135
|
+
recipientBindingKey: string;
|
|
3136
|
+
predecessorSessionId: string;
|
|
3137
|
+
successorSessionId: string;
|
|
3138
|
+
/** Caller proves the successor registration is current and no predecessor worker runs. */
|
|
3139
|
+
isCurrent: () => boolean;
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
export interface TelegramSessionPendingAdoptionResult {
|
|
3143
|
+
adoptedUpdateIds: number[];
|
|
3144
|
+
/** Already committed away by an earlier attempt; never re-appended, the private original remains. */
|
|
3145
|
+
retainedUpdateIds: number[];
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
function isAdoptablePendingEntry(entry: TelegramUpdateJournalEntry): boolean {
|
|
3149
|
+
return entry.state === "pending" && !entry.inputClaim && !entry.inputProvenance && !entry.routingInput &&
|
|
3150
|
+
!entry.queueOwner && !entry.queueHandoff && !entry.queueReceiptId && !entry.queueKind && !entry.failure &&
|
|
3151
|
+
entry.preApprovalExcluded !== true;
|
|
3152
|
+
}
|
|
3153
|
+
|
|
2563
3154
|
export interface TelegramUpdateJournalBindingRuntime {
|
|
2564
3155
|
resolveLeader: () => TelegramUpdateJournalRuntimeBinding | undefined;
|
|
2565
3156
|
resolveFollower: () => TelegramUpdateJournalRuntimeBinding | undefined;
|
|
2566
3157
|
resolveActive: () => TelegramUpdateJournalRuntimeBinding | undefined;
|
|
2567
3158
|
getActiveRecoveryKey: () => string | undefined;
|
|
3159
|
+
/** Exact historical proof lookup; no store, admission, execution, recovery or mutation port. */
|
|
3160
|
+
inspectSourceAbandonment: (journalBindingKey: string, updateId: number) => TelegramUpdateJournalAbandonedPendingEvidence | undefined;
|
|
3161
|
+
inspectSourceGroupExpiry: (journalBindingKey: string, updateIds: readonly number[]) => TelegramRoutingInputExpiryEvidence[] | undefined;
|
|
3162
|
+
inspectSourceCompletion: (journalBindingKey: string, expected: TelegramUpdateJournalSourceCompletion) => TelegramUpdateJournalSourceCompletion | undefined;
|
|
3163
|
+
inspectQueuedReceipt: (journalBindingKey: string, expected: TelegramUpdateJournalQueuedCompletion) => TelegramUpdateJournalQueuedReceiptEvidence | undefined;
|
|
3164
|
+
/** In-process `/new` succession: move unclaimed predecessor pending inputs into the successor session journal. */
|
|
3165
|
+
adoptPredecessorPending: (input: TelegramSessionPendingAdoptionInput) => TelegramSessionPendingAdoptionResult;
|
|
3166
|
+
/** Process-local: adopts from the previously prepared session of the same recipient key, then records the active one. */
|
|
3167
|
+
prepareActiveFollowerSuccession: (isCurrent: () => boolean) => TelegramSessionPendingAdoptionResult | undefined;
|
|
2568
3168
|
createRecipientResolver: (
|
|
2569
3169
|
recipientBindingKey: string,
|
|
3170
|
+
sessionId?: string,
|
|
3171
|
+
) => () => TelegramUpdateJournalRuntimeBinding | undefined;
|
|
3172
|
+
createLegacyRecipientResolver: (
|
|
3173
|
+
recipientBindingKey: string,
|
|
2570
3174
|
) => () => TelegramUpdateJournalRuntimeBinding | undefined;
|
|
2571
3175
|
createPathResolver: (
|
|
2572
3176
|
path: string,
|
|
@@ -2576,11 +3180,15 @@ export interface TelegramUpdateJournalBindingRuntime {
|
|
|
2576
3180
|
export function createTelegramUpdateJournalBindingRuntime(deps: {
|
|
2577
3181
|
base: Omit<TelegramUpdateJournalRuntimeBindingResolverDeps, "getJournalPath">;
|
|
2578
3182
|
getLeaderJournalPath: (profileName?: string) => string;
|
|
3183
|
+
/** Runtime root; defaults to the leader journal's directory for flat layouts. */
|
|
3184
|
+
getRuntimeDir?: () => string;
|
|
2579
3185
|
getFollowerJournalPath: (
|
|
2580
3186
|
bindingKey: string,
|
|
2581
3187
|
profileName?: string,
|
|
3188
|
+
sessionId?: string,
|
|
2582
3189
|
) => string;
|
|
2583
3190
|
getActiveFollowerBindingKey: () => string;
|
|
3191
|
+
getActiveFollowerSessionId?: () => string | undefined;
|
|
2584
3192
|
isFollowerRegistered: () => boolean;
|
|
2585
3193
|
}): TelegramUpdateJournalBindingRuntime {
|
|
2586
3194
|
const resolveLeader = createTelegramUpdateJournalRuntimeBindingResolver({
|
|
@@ -2590,8 +3198,12 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
|
|
|
2590
3198
|
const createFollowerResolver = (
|
|
2591
3199
|
bindingKey: string,
|
|
2592
3200
|
includeQueueRuntimeIdentity: boolean,
|
|
2593
|
-
|
|
2594
|
-
|
|
3201
|
+
sessionId?: string,
|
|
3202
|
+
legacy = false,
|
|
3203
|
+
) => {
|
|
3204
|
+
// Session-aware composition must not fall back to a process journal before preparation supplies its ID.
|
|
3205
|
+
if (!legacy && deps.getActiveFollowerSessionId && !sessionId) return () => undefined;
|
|
3206
|
+
return createTelegramUpdateJournalRuntimeBindingResolver({
|
|
2595
3207
|
getProfileName: deps.base.getProfileName,
|
|
2596
3208
|
getBotToken: deps.base.getBotToken,
|
|
2597
3209
|
getBotId: deps.base.getBotId,
|
|
@@ -2600,25 +3212,132 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
|
|
|
2600
3212
|
: {}),
|
|
2601
3213
|
...(deps.base.withWriterAdmission
|
|
2602
3214
|
? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
|
|
3215
|
+
// Follower journals need the same strict exact-receipt ports as the leader; without them queue publication cannot complete.
|
|
3216
|
+
...(deps.base.withSourceSerialization
|
|
3217
|
+
? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
|
|
2603
3218
|
...(deps.base.getWorkspaceAdmission
|
|
2604
3219
|
? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
|
|
2605
3220
|
: {}),
|
|
2606
3221
|
...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
|
|
2607
3222
|
getJournalPath(profileName) {
|
|
2608
|
-
return deps.getFollowerJournalPath(bindingKey, profileName);
|
|
3223
|
+
return deps.getFollowerJournalPath(bindingKey, profileName, sessionId);
|
|
2609
3224
|
},
|
|
2610
3225
|
});
|
|
3226
|
+
};
|
|
2611
3227
|
const resolveFollower = () =>
|
|
2612
|
-
createFollowerResolver(deps.getActiveFollowerBindingKey(), true)();
|
|
3228
|
+
createFollowerResolver(deps.getActiveFollowerBindingKey(), true, deps.getActiveFollowerSessionId?.())();
|
|
2613
3229
|
const resolveActive = () =>
|
|
2614
3230
|
deps.isFollowerRegistered() ? resolveFollower() : resolveLeader();
|
|
2615
|
-
|
|
3231
|
+
const inspectHistoricalSource = <T>(journalBindingKey: string,
|
|
3232
|
+
operation: (file: TelegramUpdateJournalFile, originals: TelegramUpdateJournalRetentionInspection["retainedInputs"], path: string) => T | undefined): T | undefined => {
|
|
3233
|
+
const path = getTelegramUpdateJournalBindingPath(journalBindingKey);
|
|
3234
|
+
const token = deps.base.getBotToken(), botId = deps.base.getBotId();
|
|
3235
|
+
const profile = deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME;
|
|
3236
|
+
if (!path || !token) return undefined;
|
|
3237
|
+
const botIdentity = createTelegramUpdateJournalBotIdentity({ botToken: token, botId });
|
|
3238
|
+
const keys = [botIdentity, { tokenSha256: botIdentity.tokenSha256 }].map(identity =>
|
|
3239
|
+
createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: identity }));
|
|
3240
|
+
if (!keys.includes(journalBindingKey)) return undefined;
|
|
3241
|
+
const getRoot = () => deps.getRuntimeDir?.() ?? dirname(deps.getLeaderJournalPath(profile));
|
|
3242
|
+
const root = getRoot();
|
|
3243
|
+
const parts = relative(root, path).split(sep);
|
|
3244
|
+
const suffix = escapeTelegramJournalPathPattern(getTelegramProfilePathSuffix(profile));
|
|
3245
|
+
const flat = new RegExp(`^(?:inbox|follower-inbox-[a-f0-9]{16})${suffix}\\.json$`, "u");
|
|
3246
|
+
const session = new RegExp(`^(?:journal\\.[a-f0-9]{16}|inbox)${suffix}\\.json$`, "u");
|
|
3247
|
+
if (!isAbsolute(root) || resolve(root) !== root || !isAbsolute(path) || resolve(path) !== path ||
|
|
3248
|
+
!(parts.length === 1 && flat.test(parts[0]!) || parts.length === 3 && parts[0] === "sessions" &&
|
|
3249
|
+
decodeTelegramSessionDirectoryName(parts[1]!) !== undefined && session.test(parts[2]!))) return undefined;
|
|
3250
|
+
const current = () => deps.base.getBotToken() === token && deps.base.getBotId() === botId &&
|
|
3251
|
+
(deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME) === profile && getRoot() === root;
|
|
3252
|
+
const inspect = () => {
|
|
3253
|
+
if (!current()) return undefined;
|
|
3254
|
+
const observed = inspectTelegramUpdateJournalRetention({ directory: root, path, profile, botIdentity,
|
|
3255
|
+
limits: { maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
|
|
3256
|
+
maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024 } });
|
|
3257
|
+
if (observed.evidence.kind !== "present" || !current()) return undefined;
|
|
3258
|
+
return operation(observed.evidence.file, observed.retainedInputs, path);
|
|
3259
|
+
};
|
|
3260
|
+
const result = deps.base.withSourceSerialization ? deps.base.withSourceSerialization(inspect) : inspect();
|
|
3261
|
+
return current() ? result : undefined;
|
|
3262
|
+
};
|
|
3263
|
+
let preparedFollower: { recipientBindingKey: string; sessionId: string } | undefined;
|
|
3264
|
+
const runtime: TelegramUpdateJournalBindingRuntime = {
|
|
2616
3265
|
resolveLeader,
|
|
2617
3266
|
resolveFollower,
|
|
2618
3267
|
resolveActive,
|
|
2619
3268
|
getActiveRecoveryKey: () => resolveActive()?.recoveryKey,
|
|
2620
|
-
|
|
2621
|
-
|
|
3269
|
+
prepareActiveFollowerSuccession(isCurrent) {
|
|
3270
|
+
const recipientBindingKey = deps.getActiveFollowerBindingKey();
|
|
3271
|
+
const sessionId = deps.getActiveFollowerSessionId?.();
|
|
3272
|
+
if (!sessionId) return undefined;
|
|
3273
|
+
const previous = preparedFollower;
|
|
3274
|
+
const result = previous && previous.recipientBindingKey === recipientBindingKey && previous.sessionId !== sessionId
|
|
3275
|
+
? runtime.adoptPredecessorPending({ recipientBindingKey, predecessorSessionId: previous.sessionId,
|
|
3276
|
+
successorSessionId: sessionId, isCurrent })
|
|
3277
|
+
: undefined;
|
|
3278
|
+
// Only a completed adoption advances the predecessor; failures retry idempotently on the next preparation.
|
|
3279
|
+
preparedFollower = { recipientBindingKey, sessionId };
|
|
3280
|
+
return result;
|
|
3281
|
+
},
|
|
3282
|
+
inspectSourceAbandonment(journalBindingKey, updateId) {
|
|
3283
|
+
if (!isSafeNonNegativeInteger(updateId)) throw new Error("Telegram abandonment proof requires an exact update ID.");
|
|
3284
|
+
return inspectHistoricalSource(journalBindingKey, (file, originals) => {
|
|
3285
|
+
const original = originals.find(value => value.updateId === updateId && value.state === "committed" &&
|
|
3286
|
+
value.journalBindingKey === journalBindingKey);
|
|
3287
|
+
if (!original) return undefined;
|
|
3288
|
+
const disposition = file.operatorDispositions?.find(value => value.failureId === original.failureId);
|
|
3289
|
+
if (!disposition || !("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody") return undefined;
|
|
3290
|
+
return { journalBindingKey, updateId, retainedPath: original.path, operatorAuthorityId: disposition.operatorAuthorityId };
|
|
3291
|
+
});
|
|
3292
|
+
},
|
|
3293
|
+
inspectSourceGroupExpiry(journalBindingKey, updateIds) {
|
|
3294
|
+
return inspectHistoricalSource(journalBindingKey, file => inspectRoutingGroupExpiry(file, journalBindingKey, updateIds));
|
|
3295
|
+
},
|
|
3296
|
+
inspectSourceCompletion(journalBindingKey, expected) {
|
|
3297
|
+
const completion = validateJournalSourceCompletion(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
|
|
3298
|
+
return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalSourceCompletion(file, completion, path));
|
|
3299
|
+
},
|
|
3300
|
+
inspectQueuedReceipt(journalBindingKey, expected) {
|
|
3301
|
+
const queueOwner = validateQueuedReceiptObservation(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
|
|
3302
|
+
return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalQueuedReceipt(file, { ...expected, queueOwner }, path));
|
|
3303
|
+
},
|
|
3304
|
+
adoptPredecessorPending(input) {
|
|
3305
|
+
const { recipientBindingKey, predecessorSessionId, successorSessionId, isCurrent } = input;
|
|
3306
|
+
if (!recipientBindingKey || !predecessorSessionId || !successorSessionId ||
|
|
3307
|
+
predecessorSessionId === successorSessionId || typeof isCurrent !== "function") {
|
|
3308
|
+
throw new Error("Telegram session adoption requires distinct exact session identities.");
|
|
3309
|
+
}
|
|
3310
|
+
const assertCurrent = () => {
|
|
3311
|
+
if (!isCurrent()) throw new Error("Telegram session adoption lost successor authority.");
|
|
3312
|
+
};
|
|
3313
|
+
assertCurrent();
|
|
3314
|
+
const predecessor = createFollowerResolver(recipientBindingKey, false, predecessorSessionId)();
|
|
3315
|
+
const successor = createFollowerResolver(recipientBindingKey, false, successorSessionId)();
|
|
3316
|
+
if (!predecessor?.readForProtection || !successor || predecessor.recoveryKey === successor.recoveryKey) {
|
|
3317
|
+
throw new Error("Telegram session adoption requires exact predecessor and successor journals.");
|
|
3318
|
+
}
|
|
3319
|
+
const result: TelegramSessionPendingAdoptionResult = { adoptedUpdateIds: [], retainedUpdateIds: [] };
|
|
3320
|
+
// Non-repairing selection; the abandonment CAS rechecks each exact entry under the source lock.
|
|
3321
|
+
const candidates = predecessor.readForProtection().entries.filter(isAdoptablePendingEntry)
|
|
3322
|
+
.sort((left, right) => left.updateId - right.updateId);
|
|
3323
|
+
for (const entry of candidates) {
|
|
3324
|
+
assertCurrent();
|
|
3325
|
+
// Commit away first: a crash afterwards preserves the private original instead of duplicating execution.
|
|
3326
|
+
const committed = predecessor.journal.abandonPending({ journalBindingKey: predecessor.recoveryKey, entry,
|
|
3327
|
+
operatorAuthorityId: `${TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX}${successorSessionId}`, isCurrent });
|
|
3328
|
+
if (committed.duplicate) {
|
|
3329
|
+
result.retainedUpdateIds.push(entry.updateId);
|
|
3330
|
+
continue;
|
|
3331
|
+
}
|
|
3332
|
+
assertCurrent();
|
|
3333
|
+
successor.journal.appendBatch([entry.update]);
|
|
3334
|
+
result.adoptedUpdateIds.push(entry.updateId);
|
|
3335
|
+
}
|
|
3336
|
+
return result;
|
|
3337
|
+
},
|
|
3338
|
+
createRecipientResolver: (bindingKey, sessionId) =>
|
|
3339
|
+
createFollowerResolver(bindingKey, false, sessionId),
|
|
3340
|
+
createLegacyRecipientResolver: (bindingKey) => createFollowerResolver(bindingKey, false, undefined, true),
|
|
2622
3341
|
createPathResolver: (path) =>
|
|
2623
3342
|
createTelegramUpdateJournalRuntimeBindingResolver({
|
|
2624
3343
|
getProfileName: deps.base.getProfileName,
|
|
@@ -2626,6 +3345,8 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
|
|
|
2626
3345
|
getBotId: deps.base.getBotId,
|
|
2627
3346
|
...(deps.base.withWriterAdmission
|
|
2628
3347
|
? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
|
|
3348
|
+
...(deps.base.withSourceSerialization
|
|
3349
|
+
? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
|
|
2629
3350
|
...(deps.base.getWorkspaceAdmission
|
|
2630
3351
|
? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
|
|
2631
3352
|
: {}),
|
|
@@ -2633,6 +3354,7 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
|
|
|
2633
3354
|
getJournalPath: () => path,
|
|
2634
3355
|
}),
|
|
2635
3356
|
};
|
|
3357
|
+
return runtime;
|
|
2636
3358
|
}
|
|
2637
3359
|
|
|
2638
3360
|
export function createTelegramUpdateJournalStore(
|
|
@@ -2720,7 +3442,6 @@ function createJournalStoreCore(
|
|
|
2720
3442
|
};
|
|
2721
3443
|
const getQueueProcessLiveness =
|
|
2722
3444
|
options.getQueueProcessLiveness ?? getTelegramProcessLiveness;
|
|
2723
|
-
|
|
2724
3445
|
const validateQueueHandoffInput = (
|
|
2725
3446
|
input: TelegramUpdateJournalQueueHandoffInput,
|
|
2726
3447
|
operation: "offer" | "accept" | "cancel",
|
|
@@ -2841,6 +3562,9 @@ function createJournalStoreCore(
|
|
|
2841
3562
|
`exceeds its ${maxEntries}-operator-disposition limit`,
|
|
2842
3563
|
);
|
|
2843
3564
|
}
|
|
3565
|
+
if ((file.sourceCompletions?.length ?? 0) > maxEntries) {
|
|
3566
|
+
throw createJournalError("capacity", path, `exceeds its ${maxEntries}-source-completion limit`);
|
|
3567
|
+
}
|
|
2844
3568
|
const serializedBytes = Buffer.byteLength(serialized);
|
|
2845
3569
|
if (serializedBytes > maxBytes) {
|
|
2846
3570
|
throw createJournalError(
|
|
@@ -2859,7 +3583,7 @@ function createJournalStoreCore(
|
|
|
2859
3583
|
entries: [],
|
|
2860
3584
|
});
|
|
2861
3585
|
|
|
2862
|
-
const readCurrentStrict = (): ReadTelegramUpdateJournalResult => {
|
|
3586
|
+
const readCurrentStrict = (allowReconciliation = true): ReadTelegramUpdateJournalResult => {
|
|
2863
3587
|
let source: string;
|
|
2864
3588
|
let recoveringMissingSnapshot = false;
|
|
2865
3589
|
try {
|
|
@@ -2893,6 +3617,7 @@ function createJournalStoreCore(
|
|
|
2893
3617
|
);
|
|
2894
3618
|
}
|
|
2895
3619
|
if (orphanedSegmentNames.length > 0) {
|
|
3620
|
+
if (!allowReconciliation) throw createJournalError("invalid", path, "requires explicit missing-snapshot reconciliation");
|
|
2896
3621
|
if (withPairingAdmission) throw createJournalError("pairing-evidence", path, "requires explicit missing-snapshot reconciliation");
|
|
2897
3622
|
recoveringMissingSnapshot = true;
|
|
2898
3623
|
source = serializeJournalFile(emptyFile());
|
|
@@ -3001,6 +3726,7 @@ function createJournalStoreCore(
|
|
|
3001
3726
|
"belongs to another journal identity",
|
|
3002
3727
|
);
|
|
3003
3728
|
}
|
|
3729
|
+
assertJournalSourceCompletionContinuity(file, segment, segmentPath);
|
|
3004
3730
|
const entriesById = new Map(
|
|
3005
3731
|
file.entries.map((entry) => [entry.updateId, entry]),
|
|
3006
3732
|
);
|
|
@@ -3040,6 +3766,8 @@ function createJournalStoreCore(
|
|
|
3040
3766
|
: file.operatorDispositions?.length
|
|
3041
3767
|
? { operatorDispositions: file.operatorDispositions }
|
|
3042
3768
|
: {}),
|
|
3769
|
+
...(segment.sourceCompletions !== undefined ? { sourceCompletions: segment.sourceCompletions }
|
|
3770
|
+
: file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions } : {}),
|
|
3043
3771
|
},
|
|
3044
3772
|
segmentPath,
|
|
3045
3773
|
version,
|
|
@@ -3049,9 +3777,9 @@ function createJournalStoreCore(
|
|
|
3049
3777
|
const identityChanged =
|
|
3050
3778
|
storedProfile !== profile ||
|
|
3051
3779
|
!identitiesMatch(file.botIdentity, expectedIdentity) ||
|
|
3052
|
-
(withPairingAdmission && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
|
|
3780
|
+
((withPairingAdmission || !!file.sourceCompletions?.length) && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
|
|
3053
3781
|
if (identityChanged) {
|
|
3054
|
-
if (file.entries.length > 0 || withPairingAdmission) {
|
|
3782
|
+
if (file.entries.length > 0 || file.sourceCompletions?.length || withPairingAdmission || !allowReconciliation) {
|
|
3055
3783
|
throw createJournalError(
|
|
3056
3784
|
"identity-mismatch",
|
|
3057
3785
|
path,
|
|
@@ -3205,66 +3933,35 @@ function createJournalStoreCore(
|
|
|
3205
3933
|
}
|
|
3206
3934
|
}
|
|
3207
3935
|
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
basename(segmentDirectory),
|
|
3218
|
-
);
|
|
3219
|
-
if (
|
|
3220
|
-
snapshotExists &&
|
|
3221
|
-
!renameTelegramPathWithRetry(path, snapshotQuarantinePath)
|
|
3222
|
-
) {
|
|
3223
|
-
throw createJournalError(
|
|
3224
|
-
"io",
|
|
3225
|
-
path,
|
|
3226
|
-
"could not quarantine an unrecoverable journal snapshot",
|
|
3227
|
-
error,
|
|
3228
|
-
);
|
|
3229
|
-
}
|
|
3230
|
-
if (!renameTelegramPathWithRetry(segmentDirectory, segmentQuarantinePath)) {
|
|
3231
|
-
if (snapshotExists) {
|
|
3232
|
-
renameTelegramPathWithRetry(snapshotQuarantinePath, path);
|
|
3936
|
+
// Approved corruption policy: delete damaged evidence instead of quarantining it under recovery/.
|
|
3937
|
+
// Segments go first so a fresh revisionless snapshot can never replay them; a crash repeats the reset.
|
|
3938
|
+
const deletedPaths: string[] = [];
|
|
3939
|
+
const deleteDamaged = (target: string, label: string): void => {
|
|
3940
|
+
try {
|
|
3941
|
+
lstatSync(target);
|
|
3942
|
+
} catch (statError) {
|
|
3943
|
+
if ((statError as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
3944
|
+
throw createJournalError("io", target, `could not inspect an unrecoverable ${label}`, statError);
|
|
3233
3945
|
}
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
"could not
|
|
3238
|
-
|
|
3239
|
-
);
|
|
3240
|
-
}
|
|
3946
|
+
try {
|
|
3947
|
+
rmSync(target, { recursive: true, force: true });
|
|
3948
|
+
} catch (removeError) {
|
|
3949
|
+
throw createJournalError("io", target, `could not delete an unrecoverable ${label}`, removeError);
|
|
3950
|
+
}
|
|
3951
|
+
deletedPaths.push(target);
|
|
3952
|
+
};
|
|
3953
|
+
deleteDamaged(segmentDirectory, "journal segment history");
|
|
3954
|
+
// A regular snapshot is replaced atomically below; anything else cannot be a rename target.
|
|
3955
|
+
if (snapshotExists && !lstatSync(path).isFile()) deleteDamaged(path, "journal snapshot");
|
|
3241
3956
|
const reset = emptyFile();
|
|
3242
3957
|
const serialized = serializeJournalFile(reset);
|
|
3243
3958
|
const serializedBytes = assertCapacity(reset, serialized);
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
} catch (publicationError) {
|
|
3247
|
-
const segmentsRestored = renameTelegramPathWithRetry(
|
|
3248
|
-
segmentQuarantinePath,
|
|
3249
|
-
segmentDirectory,
|
|
3250
|
-
);
|
|
3251
|
-
const snapshotRestored =
|
|
3252
|
-
!snapshotExists ||
|
|
3253
|
-
renameTelegramPathWithRetry(snapshotQuarantinePath, path);
|
|
3254
|
-
if (!segmentsRestored || !snapshotRestored) {
|
|
3255
|
-
throw createJournalError(
|
|
3256
|
-
"io",
|
|
3257
|
-
path,
|
|
3258
|
-
"reset publication failed after journal evidence was quarantined",
|
|
3259
|
-
publicationError,
|
|
3260
|
-
);
|
|
3261
|
-
}
|
|
3262
|
-
throw publicationError;
|
|
3263
|
-
}
|
|
3959
|
+
writeJournalFile(path, serialized, onPublicationBoundary);
|
|
3960
|
+
if (snapshotExists && !deletedPaths.includes(path)) deletedPaths.push(path);
|
|
3264
3961
|
notifyRecovery({
|
|
3265
3962
|
kind: "reset",
|
|
3266
3963
|
path,
|
|
3267
|
-
|
|
3964
|
+
deletedPaths,
|
|
3268
3965
|
reason: error.message,
|
|
3269
3966
|
});
|
|
3270
3967
|
return { file: reset, exists: true, serializedBytes };
|
|
@@ -3315,7 +4012,7 @@ function createJournalStoreCore(
|
|
|
3315
4012
|
const inputBindingHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH);
|
|
3316
4013
|
const inputHandoffHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH);
|
|
3317
4014
|
const getFileWork = (file: TelegramUpdateJournalFile): number =>
|
|
3318
|
-
file.entries.length + (file.operatorDispositions?.length ?? 0);
|
|
4015
|
+
file.entries.length + (file.operatorDispositions?.length ?? 0) + (file.sourceCompletions?.length ?? 0);
|
|
3319
4016
|
const advanceInputHeadroomState = (state: InputHeadroomState,
|
|
3320
4017
|
entries: TelegramUpdateJournalEntry[], projectionSlack: number): InputHeadroomState => {
|
|
3321
4018
|
const revision = (state.file.revision ?? 0) + 1;
|
|
@@ -3491,7 +4188,7 @@ function createJournalStoreCore(
|
|
|
3491
4188
|
): TelegramUpdateJournalSegment => {
|
|
3492
4189
|
const previousEntries = new Map(previous.entries.map(entry => [entry.updateId, entry]));
|
|
3493
4190
|
const nextEntries = new Map(next.entries.map(entry => [entry.updateId, entry]));
|
|
3494
|
-
return { version
|
|
4191
|
+
return { version,
|
|
3495
4192
|
revision: next.revision!, previousRevision: next.revision! - 1,
|
|
3496
4193
|
profile: next.profile, botIdentity: next.botIdentity,
|
|
3497
4194
|
upsertedEntries: next.entries.filter(entry =>
|
|
@@ -3521,7 +4218,7 @@ function createJournalStoreCore(
|
|
|
3521
4218
|
: { files: 1, bytes: publishedBytes, work: getFileWork(publishedFile) };
|
|
3522
4219
|
if (publishedSegment) {
|
|
3523
4220
|
const segmentWork = publishedSegment.upsertedEntries.length + publishedSegment.removedUpdateIds.length +
|
|
3524
|
-
(publishedSegment.operatorDispositions?.length ?? 0);
|
|
4221
|
+
(publishedSegment.operatorDispositions?.length ?? 0) + (publishedSegment.sourceCompletions?.length ?? 0);
|
|
3525
4222
|
const segmentBytes = Buffer.byteLength(`${JSON.stringify(publishedSegment, null, 2)}\n`);
|
|
3526
4223
|
baseRetainedCount += 1;
|
|
3527
4224
|
baseRetainedBytes += segmentBytes;
|
|
@@ -3580,9 +4277,10 @@ function createJournalStoreCore(
|
|
|
3580
4277
|
const segmentText = `${JSON.stringify(segment, null, 2)}\n`;
|
|
3581
4278
|
const segmentBytes = Buffer.byteLength(segmentText);
|
|
3582
4279
|
const collections = [segment.upsertedEntries.length, segment.removedUpdateIds.length,
|
|
3583
|
-
segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0
|
|
3584
|
-
|
|
3585
|
-
const
|
|
4280
|
+
segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0,
|
|
4281
|
+
segment.sourceCompletions?.length ?? 0, file.sourceCompletions?.length ?? 0];
|
|
4282
|
+
const segmentWork = collections[0]! + collections[1]! + collections[2]! + collections[5]!;
|
|
4283
|
+
const fileWork = collections[3]! + collections[4]! + collections[6]!;
|
|
3586
4284
|
const accounting = source.evidence.accounting;
|
|
3587
4285
|
// Both the segment-first state and snapshot-first cleanup residue must fit.
|
|
3588
4286
|
assertSourceResources(accounting.files + 1, accounting.bytes + segmentBytes,
|
|
@@ -3625,7 +4323,8 @@ function createJournalStoreCore(
|
|
|
3625
4323
|
acceptedThroughUpdateId = current.file.acceptedThroughUpdateId,
|
|
3626
4324
|
publicationBoundary = onPublicationBoundary,
|
|
3627
4325
|
inputHeadroom: number | readonly number[] | "all" | false =
|
|
3628
|
-
version
|
|
4326
|
+
journalHasCustodyFields(version) ? "all" : false,
|
|
4327
|
+
sourceCompletions = current.file.sourceCompletions,
|
|
3629
4328
|
): { file: TelegramUpdateJournalFile; serializedBytes: number } => {
|
|
3630
4329
|
const botIdentity = current.source ? current.file.botIdentity : mergeBotIdentity(
|
|
3631
4330
|
current.file.botIdentity,
|
|
@@ -3638,7 +4337,8 @@ function createJournalStoreCore(
|
|
|
3638
4337
|
current.file.operatorDispositions ?? [],
|
|
3639
4338
|
operatorDispositions ?? [],
|
|
3640
4339
|
) &&
|
|
3641
|
-
current.file.acceptedThroughUpdateId === acceptedThroughUpdateId
|
|
4340
|
+
current.file.acceptedThroughUpdateId === acceptedThroughUpdateId &&
|
|
4341
|
+
isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? [])
|
|
3642
4342
|
) {
|
|
3643
4343
|
return { file: current.file, serializedBytes: current.serializedBytes };
|
|
3644
4344
|
}
|
|
@@ -3653,21 +4353,24 @@ function createJournalStoreCore(
|
|
|
3653
4353
|
...(operatorDispositions?.length
|
|
3654
4354
|
? { operatorDispositions }
|
|
3655
4355
|
: {}),
|
|
4356
|
+
...(sourceCompletions?.length ? { sourceCompletions } : {}),
|
|
3656
4357
|
};
|
|
4358
|
+
assertJournalRoutingInputContinuity(current.file, entries, path);
|
|
3657
4359
|
if (withPairingAdmission || current.source) parseJournalFile(file, path, version);
|
|
3658
4360
|
const serialized = serializeJournalFile(file);
|
|
3659
4361
|
const serializedBytes = assertCapacity(file, serialized);
|
|
3660
4362
|
const changed =
|
|
3661
4363
|
contentChanged ||
|
|
3662
4364
|
current.file.acceptedThroughUpdateId !== acceptedThroughUpdateId ||
|
|
3663
|
-
!isDeepStrictEqual(current.file.botIdentity, file.botIdentity)
|
|
4365
|
+
!isDeepStrictEqual(current.file.botIdentity, file.botIdentity) ||
|
|
4366
|
+
!isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []);
|
|
3664
4367
|
if (!changed) {
|
|
3665
4368
|
return { file, serializedBytes: current.serializedBytes };
|
|
3666
4369
|
}
|
|
3667
4370
|
if (!current.exists) {
|
|
3668
4371
|
if (current.source) {
|
|
3669
|
-
const collections = [file.entries.length, file.operatorDispositions?.length ?? 0];
|
|
3670
|
-
assertSourceResources(1, serializedBytes, collections[0]! + collections[1]!, collections);
|
|
4372
|
+
const collections = [file.entries.length, file.operatorDispositions?.length ?? 0, file.sourceCompletions?.length ?? 0];
|
|
4373
|
+
assertSourceResources(1, serializedBytes, collections[0]! + collections[1]! + collections[2]!, collections);
|
|
3671
4374
|
}
|
|
3672
4375
|
if (inputHeadroom !== false) assertInputProgressHeadroom(current, file, inputHeadroom);
|
|
3673
4376
|
writeJournalFile(path, serialized, publicationBoundary);
|
|
@@ -3709,13 +4412,14 @@ function createJournalStoreCore(
|
|
|
3709
4412
|
)
|
|
3710
4413
|
? { operatorDispositions: operatorDispositions ?? [] }
|
|
3711
4414
|
: {}),
|
|
4415
|
+
...(!isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []) ? { sourceCompletions: sourceCompletions ?? [] } : {}),
|
|
3712
4416
|
};
|
|
3713
4417
|
if (current.source) {
|
|
3714
4418
|
if (!isSafePositiveInteger(revision)) throw createJournalError("capacity", path, "exhausted source revisions");
|
|
3715
4419
|
const revisedFile = { ...file, revision };
|
|
3716
4420
|
if (inputHeadroom !== false) assertInputProgressHeadroom(current, revisedFile, inputHeadroom, segment);
|
|
3717
4421
|
return publishSourceSegment(current, file, segment, publicationBoundary,
|
|
3718
|
-
version
|
|
4422
|
+
journalHasCustodyFields(version));
|
|
3719
4423
|
}
|
|
3720
4424
|
publishTelegramUpdateJournalSegmentUnlocked(path, segment, publicationBoundary);
|
|
3721
4425
|
const revisedFile: TelegramUpdateJournalFile = { ...file, revision };
|
|
@@ -3768,6 +4472,160 @@ function createJournalStoreCore(
|
|
|
3768
4472
|
return { file: revisedFile, serializedBytes };
|
|
3769
4473
|
};
|
|
3770
4474
|
|
|
4475
|
+
const removeCompleted = (updateIds: readonly number[], expectedSources?: readonly TelegramUpdateJournalEntryDigest[], exact = false,
|
|
4476
|
+
completionInput?: readonly TelegramUpdateJournalSourceCompletion[]): TelegramUpdateJournalRemoveResult =>
|
|
4477
|
+
runMutation((readCurrent) => {
|
|
4478
|
+
const current = exact && !sourceAccess ? readCurrentStrict(false) : readCurrent();
|
|
4479
|
+
if (completionInput !== undefined && !sourceAccess) throw createJournalError("invalid", path, "source completion requires an exact source handle");
|
|
4480
|
+
const completions = completionInput === undefined ? undefined : validateJournalSourceCompletions(completionInput, path, version);
|
|
4481
|
+
if (exact && expectedSources === undefined) throw createJournalError("invalid", path, "received no exact completion sources");
|
|
4482
|
+
const requestedIds = new Set<number>();
|
|
4483
|
+
for (const updateId of updateIds) {
|
|
4484
|
+
if (!isSafeNonNegativeInteger(updateId)) throw createJournalError("invalid", path, "received an invalid removal update id");
|
|
4485
|
+
requestedIds.add(updateId);
|
|
4486
|
+
}
|
|
4487
|
+
if (expectedSources !== undefined) {
|
|
4488
|
+
if (!Array.isArray(expectedSources) || !expectedSources.length) throw createJournalError("invalid", path, "received no exact completion sources");
|
|
4489
|
+
const expectedIds = new Set<number>();
|
|
4490
|
+
for (const expected of expectedSources) {
|
|
4491
|
+
if (!isRecord(expected) || Object.keys(expected).some(key => !["updateId", "sourceSha256"].includes(key)) ||
|
|
4492
|
+
!isSafeNonNegativeInteger(expected.updateId) || !requestedIds.has(expected.updateId) || expectedIds.has(expected.updateId) ||
|
|
4493
|
+
typeof expected.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(expected.sourceSha256)) {
|
|
4494
|
+
throw createJournalError("invalid", path, "received an invalid exact completion source");
|
|
4495
|
+
}
|
|
4496
|
+
expectedIds.add(expected.updateId);
|
|
4497
|
+
const entry = current.file.entries.find(value => value.updateId === expected.updateId);
|
|
4498
|
+
if (!entry || createTelegramUpdateJournalEntryDigest(entry).sourceSha256 !== expected.sourceSha256) {
|
|
4499
|
+
throw createJournalError("conflict", path, "exact completion source changed or disappeared");
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
}
|
|
4503
|
+
if (completions) {
|
|
4504
|
+
const guards = new Map((expectedSources ?? []).map(source => [source.updateId, source.sourceSha256]));
|
|
4505
|
+
if (completions.some(completion => guards.get(completion.updateId) !== completion.sourceSha256)) {
|
|
4506
|
+
throw createJournalError("invalid", path, "source completion has no matching exact guard");
|
|
4507
|
+
}
|
|
4508
|
+
}
|
|
4509
|
+
const requestedEntries = current.file.entries.filter(entry => requestedIds.has(entry.updateId));
|
|
4510
|
+
const protectedEntry = requestedEntries.find(entry => entry.state === "failed" || entry.state === "queued");
|
|
4511
|
+
if (protectedEntry) throw createJournalError("conflict", path, protectedEntry.state === "failed"
|
|
4512
|
+
? `cannot complete terminal update ${protectedEntry.updateId} without an operator disposition`
|
|
4513
|
+
: `cannot complete queued update ${protectedEntry.updateId} without its exact owner receipt`);
|
|
4514
|
+
const removedUpdateIds = requestedEntries.map(entry => entry.updateId);
|
|
4515
|
+
const sourceCompletions = completions
|
|
4516
|
+
? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId) : current.file.sourceCompletions;
|
|
4517
|
+
if (sourceCompletions?.length) validateJournalSourceCompletions(sourceCompletions, path, version);
|
|
4518
|
+
const published = publishMutation(current, current.file.entries.filter(entry => !requestedIds.has(entry.updateId)), removedUpdateIds.length > 0,
|
|
4519
|
+
current.file.operatorDispositions, current.file.acceptedThroughUpdateId, onPublicationBoundary, false, sourceCompletions);
|
|
4520
|
+
return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
|
|
4521
|
+
...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
|
|
4522
|
+
});
|
|
4523
|
+
const completeQueued = (receipts: readonly TelegramUpdateJournalQueuedCompletion[],
|
|
4524
|
+
completionInput?: readonly TelegramUpdateJournalSourceCompletion[], exact = false): TelegramUpdateJournalRemoveResult =>
|
|
4525
|
+
runMutation((readCurrent) => {
|
|
4526
|
+
if (exact && !sourceAccess) throw createJournalError("invalid", path, "source completion requires an exact source handle");
|
|
4527
|
+
const completions = exact ? validateJournalSourceCompletions(completionInput, path, version) : undefined;
|
|
4528
|
+
if (!Array.isArray(receipts) || receipts.length === 0) throw createJournalError("invalid", path, "received no queued receipts to complete");
|
|
4529
|
+
const current = readCurrent();
|
|
4530
|
+
const receiptIds = new Set<string>(), requestedUpdateIds = new Set<number>();
|
|
4531
|
+
for (const receipt of receipts) {
|
|
4532
|
+
if ((receipt.queueKind !== "prompt" && receipt.queueKind !== "control") || !isNonEmptyString(receipt.receiptId) ||
|
|
4533
|
+
receiptIds.has(receipt.receiptId) || !Array.isArray(receipt.sourceUpdateIds) || receipt.sourceUpdateIds.length === 0) {
|
|
4534
|
+
throw createJournalError("invalid", path, "received an invalid queued completion receipt");
|
|
4535
|
+
}
|
|
4536
|
+
receiptIds.add(receipt.receiptId);
|
|
4537
|
+
const queueOwner = validateJournalQueueOwner(receipt.queueOwner, path);
|
|
4538
|
+
if (queueRuntimeIdentity && (queueOwner.instanceId !== queueRuntimeIdentity.instanceId || queueOwner.processId !== queueRuntimeIdentity.processId ||
|
|
4539
|
+
queueOwner.processBirthId !== queueRuntimeIdentity.processBirthId)) {
|
|
4540
|
+
throw createJournalError("conflict", path, `cannot complete foreign queue receipt ${receipt.receiptId}`);
|
|
4541
|
+
}
|
|
4542
|
+
const sourceUpdateIds = new Set<number>();
|
|
4543
|
+
for (const updateId of receipt.sourceUpdateIds) {
|
|
4544
|
+
if (!isSafeNonNegativeInteger(updateId) || sourceUpdateIds.has(updateId) || requestedUpdateIds.has(updateId)) {
|
|
4545
|
+
throw createJournalError("invalid", path, "received overlapping queued completion update ids");
|
|
4546
|
+
}
|
|
4547
|
+
sourceUpdateIds.add(updateId); requestedUpdateIds.add(updateId);
|
|
4548
|
+
}
|
|
4549
|
+
const persistedReceiptEntries = current.file.entries.filter(entry => entry.queueReceiptId === receipt.receiptId);
|
|
4550
|
+
if (persistedReceiptEntries.length !== sourceUpdateIds.size || persistedReceiptEntries.some(entry =>
|
|
4551
|
+
!sourceUpdateIds.has(entry.updateId) || entry.state !== "queued" || entry.queueKind !== receipt.queueKind || !entry.queueOwner ||
|
|
4552
|
+
entry.queueHandoff !== undefined || !areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, queueOwner))) {
|
|
4553
|
+
throw createJournalError("conflict", path, `cannot complete stale or foreign queue receipt ${receipt.receiptId}`);
|
|
4554
|
+
}
|
|
4555
|
+
}
|
|
4556
|
+
const removedEntries = current.file.entries.filter(entry => requestedUpdateIds.has(entry.updateId));
|
|
4557
|
+
const removedUpdateIds = removedEntries.map(entry => entry.updateId);
|
|
4558
|
+
if (removedUpdateIds.length !== requestedUpdateIds.size) throw createJournalError("conflict", path, "queued completion did not resolve every source update");
|
|
4559
|
+
if (completions) {
|
|
4560
|
+
const sources = new Map(removedEntries.map(entry => [entry.updateId, createTelegramUpdateJournalEntryDigest(entry).sourceSha256]));
|
|
4561
|
+
if (completions.some(completion => sources.get(completion.updateId) !== completion.sourceSha256)) {
|
|
4562
|
+
throw createJournalError("conflict", path, "queued completion source changed or belongs to another receipt");
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4565
|
+
const sourceCompletions = completions ? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId)
|
|
4566
|
+
: current.file.sourceCompletions;
|
|
4567
|
+
if (sourceCompletions?.length) validateJournalSourceCompletions(sourceCompletions, path, version);
|
|
4568
|
+
const published = publishMutation(current, current.file.entries.filter(entry => !requestedUpdateIds.has(entry.updateId)), true,
|
|
4569
|
+
current.file.operatorDispositions, current.file.acceptedThroughUpdateId, onPublicationBoundary, exact ? false : undefined, sourceCompletions);
|
|
4570
|
+
return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
|
|
4571
|
+
...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
|
|
4572
|
+
});
|
|
4573
|
+
const mutateRoutingInputs = (input: TelegramRoutingInputAuthority, select: boolean, publishedAtMs?: number) => runMutation(readCurrent => {
|
|
4574
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) throw createJournalError("unsupported-version", path, "routing input lifetime requires a private v1 source");
|
|
4575
|
+
const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
4576
|
+
if (input.journalBindingKey !== binding || !isSafePositiveInteger(input.operatorUserId) ||
|
|
4577
|
+
typeof input.isCurrent !== "function" || !Array.isArray(input.entries) || !input.entries.length || input.entries.length > maxEntries) {
|
|
4578
|
+
throw createJournalError("invalid", path, "invalid routing input lifetime authority");
|
|
4579
|
+
}
|
|
4580
|
+
const assertCurrent = () => { if (!input.isCurrent()) throw createJournalError("conflict", path, "routing input lifetime authority ended"); };
|
|
4581
|
+
assertCurrent();
|
|
4582
|
+
const expected = input.entries.map(entry => validateJournalEntry(entry, path, version));
|
|
4583
|
+
if (new Set(expected.map(entry => entry.updateId)).size !== expected.length) throw createJournalError("invalid", path, "duplicate routing input sources");
|
|
4584
|
+
const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
|
|
4585
|
+
const nowMs = select ? getNowMs() : publishedAtMs;
|
|
4586
|
+
if (!isSafeNonNegativeInteger(nowMs) || !Number.isSafeInteger(nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS) || (!select && nowMs > getNowMs())) throw createJournalError("invalid", path, "invalid routing input lifetime clock");
|
|
4587
|
+
let issued = false;
|
|
4588
|
+
const changed = new Map<number, TelegramUpdateJournalEntry>();
|
|
4589
|
+
const result = expected.map(original => {
|
|
4590
|
+
const entry = current.file.entries.find(value => value.updateId === original.updateId);
|
|
4591
|
+
if (!entry || entry.state !== "pending" || original.state !== "pending" || entry.inputClaim || entry.inputProvenance || entry.queueOwner) {
|
|
4592
|
+
throw createJournalError("conflict", path, "routing input source is not unclaimed pending work");
|
|
4593
|
+
}
|
|
4594
|
+
const withoutLifetime = (value: TelegramUpdateJournalEntry) => { const copy = cloneEntry(value); delete copy.routingInput; return copy; };
|
|
4595
|
+
if (!isDeepStrictEqual(entry, original) && (!isDeepStrictEqual(withoutLifetime(entry), withoutLifetime(original)) ||
|
|
4596
|
+
(original.routingInput && !isDeepStrictEqual(original.routingInput, entry.routingInput)))) {
|
|
4597
|
+
throw createJournalError("conflict", path, "routing input source changed");
|
|
4598
|
+
}
|
|
4599
|
+
const lifetime = entry.routingInput;
|
|
4600
|
+
if (lifetime && lifetime.operatorUserId !== input.operatorUserId) throw createJournalError("conflict", path, "routing input belongs to another operator");
|
|
4601
|
+
if (createPendingRetentionReference({ path, journalBindingKey: binding, entry, maxBytes }).read()) {
|
|
4602
|
+
throw createJournalError("conflict", path, "routing input has protected retention");
|
|
4603
|
+
}
|
|
4604
|
+
if (select) {
|
|
4605
|
+
if (!lifetime || (lifetime.phase === "waiting" && nowMs >= lifetime.expiresAtMs)) throw createJournalError("conflict", path, "routing input choice expired or was not armed");
|
|
4606
|
+
if (lifetime.phase === "selected") return cloneEntry(entry);
|
|
4607
|
+
issued = true;
|
|
4608
|
+
const selected = { ...cloneEntry(entry), routingInput: { ...lifetime, phase: "selected" as const } };
|
|
4609
|
+
changed.set(entry.updateId, selected); return cloneEntry(selected);
|
|
4610
|
+
}
|
|
4611
|
+
if (lifetime) {
|
|
4612
|
+
if (lifetime.phase !== "waiting") throw createJournalError("conflict", path, "selected routing input cannot renew its choice");
|
|
4613
|
+
return cloneEntry(entry);
|
|
4614
|
+
}
|
|
4615
|
+
if (nowMs < entry.admittedAtMs) throw createJournalError("conflict", path, "routing input publication predates admission");
|
|
4616
|
+
issued = true;
|
|
4617
|
+
const armed = { ...cloneEntry(entry), routingInput: { operatorUserId: input.operatorUserId,
|
|
4618
|
+
publishedAtMs: nowMs, expiresAtMs: nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS, phase: "waiting" as const } };
|
|
4619
|
+
changed.set(entry.updateId, armed); return cloneEntry(armed);
|
|
4620
|
+
});
|
|
4621
|
+
assertCurrent();
|
|
4622
|
+
if (select && issued && result.some(entry => !changed.has(entry.updateId))) throw createJournalError("conflict", path, "routing input selection has mixed prior phases");
|
|
4623
|
+
if (changed.size) publishMutation(current, current.file.entries.map(entry => changed.get(entry.updateId) ?? entry), true,
|
|
4624
|
+
current.file.operatorDispositions, current.file.acceptedThroughUpdateId,
|
|
4625
|
+
(boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
|
|
4626
|
+
assertCurrent();
|
|
4627
|
+
return { issued, entries: result };
|
|
4628
|
+
});
|
|
3771
4629
|
const journal: TelegramUpdateJournalStore = {
|
|
3772
4630
|
read() {
|
|
3773
4631
|
return runMutation((readCurrent) => {
|
|
@@ -3815,11 +4673,12 @@ function createJournalStoreCore(
|
|
|
3815
4673
|
);
|
|
3816
4674
|
const previousAcceptedThroughUpdateId =
|
|
3817
4675
|
current.file.acceptedThroughUpdateId;
|
|
3818
|
-
const discardedUpdateIds = new Set(
|
|
3819
|
-
(current.file.operatorDispositions ?? [])
|
|
4676
|
+
const discardedUpdateIds = new Set([
|
|
4677
|
+
...(current.file.operatorDispositions ?? [])
|
|
3820
4678
|
.filter((disposition) => disposition.action === "discard")
|
|
3821
4679
|
.map((disposition) => disposition.updateId),
|
|
3822
|
-
|
|
4680
|
+
...(current.file.sourceCompletions ?? []).map(completion => completion.updateId),
|
|
4681
|
+
]);
|
|
3823
4682
|
let admittedAtMs: number | undefined;
|
|
3824
4683
|
const addedUpdateIds: number[] = [];
|
|
3825
4684
|
const duplicateUpdateIds: number[] = [];
|
|
@@ -4069,6 +4928,7 @@ function createJournalStoreCore(
|
|
|
4069
4928
|
update: entry.update,
|
|
4070
4929
|
admittedAtMs: entry.admittedAtMs,
|
|
4071
4930
|
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
4931
|
+
...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
|
|
4072
4932
|
state: "queued",
|
|
4073
4933
|
queueKind: receipt.queueKind,
|
|
4074
4934
|
queueReceiptId: receipt.receiptId,
|
|
@@ -4193,6 +5053,7 @@ function createJournalStoreCore(
|
|
|
4193
5053
|
updateId: entry.updateId,
|
|
4194
5054
|
update: entry.update,
|
|
4195
5055
|
admittedAtMs: entry.admittedAtMs,
|
|
5056
|
+
...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
|
|
4196
5057
|
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
4197
5058
|
state: input.disposition,
|
|
4198
5059
|
failure,
|
|
@@ -4328,9 +5189,9 @@ function createJournalStoreCore(
|
|
|
4328
5189
|
applyLegacyCustodyDisposition(authority) {
|
|
4329
5190
|
return runMutation((readCurrent) => {
|
|
4330
5191
|
const current = readCurrent();
|
|
4331
|
-
if (current.file.version
|
|
5192
|
+
if (!journalHasCustodyFields(current.file.version))
|
|
4332
5193
|
throw createJournalError("conflict", path,
|
|
4333
|
-
"legacy custody disposition requires a
|
|
5194
|
+
"legacy custody disposition requires a custody journal");
|
|
4334
5195
|
const existing = current.file.operatorDispositions?.find(
|
|
4335
5196
|
candidate => candidate.failureId === authority.dispositionId);
|
|
4336
5197
|
if (existing) {
|
|
@@ -4647,116 +5508,8 @@ function createJournalStoreCore(
|
|
|
4647
5508
|
};
|
|
4648
5509
|
});
|
|
4649
5510
|
},
|
|
4650
|
-
completeQueued(receipts) {
|
|
4651
|
-
|
|
4652
|
-
if (!Array.isArray(receipts) || receipts.length === 0) {
|
|
4653
|
-
throw createJournalError(
|
|
4654
|
-
"invalid",
|
|
4655
|
-
path,
|
|
4656
|
-
"received no queued receipts to complete",
|
|
4657
|
-
);
|
|
4658
|
-
}
|
|
4659
|
-
const current = readCurrent();
|
|
4660
|
-
const receiptIds = new Set<string>();
|
|
4661
|
-
const requestedUpdateIds = new Set<number>();
|
|
4662
|
-
for (const receipt of receipts) {
|
|
4663
|
-
if (
|
|
4664
|
-
(receipt.queueKind !== "prompt" &&
|
|
4665
|
-
receipt.queueKind !== "control") ||
|
|
4666
|
-
!isNonEmptyString(receipt.receiptId) ||
|
|
4667
|
-
receiptIds.has(receipt.receiptId) ||
|
|
4668
|
-
!Array.isArray(receipt.sourceUpdateIds) ||
|
|
4669
|
-
receipt.sourceUpdateIds.length === 0
|
|
4670
|
-
) {
|
|
4671
|
-
throw createJournalError(
|
|
4672
|
-
"invalid",
|
|
4673
|
-
path,
|
|
4674
|
-
"received an invalid queued completion receipt",
|
|
4675
|
-
);
|
|
4676
|
-
}
|
|
4677
|
-
receiptIds.add(receipt.receiptId);
|
|
4678
|
-
const queueOwner = validateJournalQueueOwner(
|
|
4679
|
-
receipt.queueOwner,
|
|
4680
|
-
path,
|
|
4681
|
-
);
|
|
4682
|
-
if (
|
|
4683
|
-
queueRuntimeIdentity &&
|
|
4684
|
-
(queueOwner.instanceId !== queueRuntimeIdentity.instanceId ||
|
|
4685
|
-
queueOwner.processId !== queueRuntimeIdentity.processId ||
|
|
4686
|
-
queueOwner.processBirthId !==
|
|
4687
|
-
queueRuntimeIdentity.processBirthId)
|
|
4688
|
-
) {
|
|
4689
|
-
throw createJournalError(
|
|
4690
|
-
"conflict",
|
|
4691
|
-
path,
|
|
4692
|
-
`cannot complete foreign queue receipt ${receipt.receiptId}`,
|
|
4693
|
-
);
|
|
4694
|
-
}
|
|
4695
|
-
const sourceUpdateIds = new Set<number>();
|
|
4696
|
-
for (const updateId of receipt.sourceUpdateIds) {
|
|
4697
|
-
if (
|
|
4698
|
-
!isSafeNonNegativeInteger(updateId) ||
|
|
4699
|
-
sourceUpdateIds.has(updateId) ||
|
|
4700
|
-
requestedUpdateIds.has(updateId)
|
|
4701
|
-
) {
|
|
4702
|
-
throw createJournalError(
|
|
4703
|
-
"invalid",
|
|
4704
|
-
path,
|
|
4705
|
-
"received overlapping queued completion update ids",
|
|
4706
|
-
);
|
|
4707
|
-
}
|
|
4708
|
-
sourceUpdateIds.add(updateId);
|
|
4709
|
-
requestedUpdateIds.add(updateId);
|
|
4710
|
-
}
|
|
4711
|
-
const persistedReceiptEntries = current.file.entries.filter(
|
|
4712
|
-
(entry) => entry.queueReceiptId === receipt.receiptId,
|
|
4713
|
-
);
|
|
4714
|
-
if (
|
|
4715
|
-
persistedReceiptEntries.length !== sourceUpdateIds.size ||
|
|
4716
|
-
persistedReceiptEntries.some(
|
|
4717
|
-
(entry) =>
|
|
4718
|
-
!sourceUpdateIds.has(entry.updateId) ||
|
|
4719
|
-
entry.state !== "queued" ||
|
|
4720
|
-
entry.queueKind !== receipt.queueKind ||
|
|
4721
|
-
!entry.queueOwner ||
|
|
4722
|
-
entry.queueHandoff !== undefined ||
|
|
4723
|
-
!areTelegramUpdateJournalQueueOwnersEqual(
|
|
4724
|
-
entry.queueOwner,
|
|
4725
|
-
queueOwner,
|
|
4726
|
-
),
|
|
4727
|
-
)
|
|
4728
|
-
) {
|
|
4729
|
-
throw createJournalError(
|
|
4730
|
-
"conflict",
|
|
4731
|
-
path,
|
|
4732
|
-
`cannot complete stale or foreign queue receipt ${receipt.receiptId}`,
|
|
4733
|
-
);
|
|
4734
|
-
}
|
|
4735
|
-
}
|
|
4736
|
-
const removedUpdateIds = current.file.entries
|
|
4737
|
-
.filter((entry) => requestedUpdateIds.has(entry.updateId))
|
|
4738
|
-
.map((entry) => entry.updateId);
|
|
4739
|
-
if (removedUpdateIds.length !== requestedUpdateIds.size) {
|
|
4740
|
-
throw createJournalError(
|
|
4741
|
-
"conflict",
|
|
4742
|
-
path,
|
|
4743
|
-
"queued completion did not resolve every source update",
|
|
4744
|
-
);
|
|
4745
|
-
}
|
|
4746
|
-
const published = publishMutation(
|
|
4747
|
-
current,
|
|
4748
|
-
current.file.entries.filter(
|
|
4749
|
-
(entry) => !requestedUpdateIds.has(entry.updateId),
|
|
4750
|
-
),
|
|
4751
|
-
true,
|
|
4752
|
-
);
|
|
4753
|
-
return {
|
|
4754
|
-
removedUpdateIds,
|
|
4755
|
-
entryCount: published.file.entries.length,
|
|
4756
|
-
serializedBytes: published.serializedBytes,
|
|
4757
|
-
};
|
|
4758
|
-
});
|
|
4759
|
-
},
|
|
5511
|
+
completeQueued(receipts) { return completeQueued(receipts); },
|
|
5512
|
+
completeQueuedExact(receipts, completions) { return completeQueued(receipts, completions, true); },
|
|
4760
5513
|
discardQueued(input) {
|
|
4761
5514
|
return runMutation((readCurrent) => {
|
|
4762
5515
|
if (
|
|
@@ -4957,50 +5710,222 @@ function createJournalStoreCore(
|
|
|
4957
5710
|
};
|
|
4958
5711
|
});
|
|
4959
5712
|
},
|
|
4960
|
-
|
|
5713
|
+
inspectAbandonedPending(updateId) {
|
|
4961
5714
|
return runMutation((readCurrent) => {
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
5715
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
5716
|
+
throw createJournalError("unsupported-version", path, "abandonment inspection requires the legacy v1 source");
|
|
5717
|
+
}
|
|
5718
|
+
if (!isSafeNonNegativeInteger(updateId)) throw createJournalError("invalid", path, "invalid abandonment update id");
|
|
5719
|
+
// Inspection never repairs or quarantines source evidence.
|
|
5720
|
+
const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
|
|
5721
|
+
const matches = (current.file.operatorDispositions ?? []).filter(item => item.updateId === updateId);
|
|
5722
|
+
const disposition = matches[0];
|
|
5723
|
+
if (matches.length !== 1 || !disposition || !("dispositionKind" in disposition) ||
|
|
5724
|
+
disposition.dispositionKind !== "legacy-custody" || disposition.action !== "discard" ||
|
|
5725
|
+
disposition.failureId !== `abandon-${disposition.evidenceSha256}` ||
|
|
5726
|
+
current.file.entries.some(entry => entry.updateId === updateId)) return undefined;
|
|
5727
|
+
const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
5728
|
+
const retainedPath = join(`${path}.retained`, `${disposition.failureId}.json`);
|
|
5729
|
+
let stat;
|
|
5730
|
+
try { stat = lstatSync(retainedPath); }
|
|
5731
|
+
catch (error) {
|
|
5732
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw createJournalError("conflict", retainedPath, "committed abandonment lost its retained input");
|
|
5733
|
+
throw error;
|
|
5734
|
+
}
|
|
5735
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
|
|
5736
|
+
throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
|
|
5737
|
+
}
|
|
5738
|
+
const parsed: unknown = JSON.parse(readFileSync(retainedPath, "utf8"));
|
|
5739
|
+
if (!isRecord(parsed)) throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
|
|
5740
|
+
const entry = validateJournalEntry(parsed.entry, retainedPath, version);
|
|
5741
|
+
const reference = createPendingRetentionReference({ path, journalBindingKey, entry, maxBytes });
|
|
5742
|
+
const retained = reference.read();
|
|
5743
|
+
if (entry.updateId !== updateId || reference.retainedPath !== retainedPath ||
|
|
5744
|
+
!isDeepStrictEqual(retained?.requestedDisposition, disposition)) {
|
|
5745
|
+
throw createJournalError("conflict", retainedPath, "retained input does not match committed abandonment");
|
|
5746
|
+
}
|
|
5747
|
+
return { journalBindingKey, updateId, retainedPath, operatorAuthorityId: disposition.operatorAuthorityId };
|
|
5748
|
+
});
|
|
5749
|
+
},
|
|
5750
|
+
inspectPendingRetention(entry) {
|
|
5751
|
+
return runMutation(() => {
|
|
5752
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
5753
|
+
throw createJournalError("unsupported-version", path, "pending retention requires the legacy v1 source");
|
|
5754
|
+
}
|
|
5755
|
+
const expected = validateJournalEntry(entry, path, version);
|
|
5756
|
+
if (expected.state !== "pending") throw createJournalError("conflict", path, "retention inspection requires a pending source");
|
|
5757
|
+
const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
5758
|
+
const reference = createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
|
|
5759
|
+
const retained = reference.read();
|
|
5760
|
+
return retained ? { journalBindingKey, retainedPath: reference.retainedPath,
|
|
5761
|
+
requestedDisposition: retained.requestedDisposition } : undefined;
|
|
5762
|
+
});
|
|
5763
|
+
},
|
|
5764
|
+
abandonPending(input) {
|
|
5765
|
+
return runMutation((readCurrent) => {
|
|
5766
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
5767
|
+
throw createJournalError("unsupported-version", path, "pending abandonment requires the legacy v1 source");
|
|
5768
|
+
}
|
|
5769
|
+
const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
5770
|
+
if (input.journalBindingKey !== journalBindingKey) {
|
|
5771
|
+
throw createJournalError("identity-mismatch", path, "pending abandonment belongs to another source");
|
|
5772
|
+
}
|
|
5773
|
+
if (!isBoundedString(input.operatorAuthorityId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
|
|
5774
|
+
!input.operatorAuthorityId.trim() || typeof input.isCurrent !== "function") {
|
|
5775
|
+
throw createJournalError("invalid", path, "pending abandonment requires explicit operator authority");
|
|
5776
|
+
}
|
|
5777
|
+
const assertCurrent = () => {
|
|
5778
|
+
if (!input.isCurrent()) throw createJournalError("conflict", path, "pending abandonment lost authority");
|
|
5779
|
+
};
|
|
5780
|
+
assertCurrent();
|
|
5781
|
+
const expected = validateJournalEntry(input.entry, path, version);
|
|
5782
|
+
if (expected.state !== "pending" || expected.inputClaim || expected.inputProvenance ||
|
|
5783
|
+
expected.queueOwner || expected.queueHandoff || expected.queueReceiptId || expected.failure) {
|
|
5784
|
+
throw createJournalError("conflict", path, "only an exact unclaimed pending source may be abandoned");
|
|
5785
|
+
}
|
|
5786
|
+
// Never repair/quarantine source evidence while deciding an operator cancellation.
|
|
5787
|
+
const readStrict = sourceAccess ? readCurrent : () => readCurrentStrict(false);
|
|
5788
|
+
const current = readStrict();
|
|
5789
|
+
const { evidenceSha256, failureId, retainedPath, read: readRetention } =
|
|
5790
|
+
createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
|
|
5791
|
+
const existing = current.file.operatorDispositions?.find(item =>
|
|
5792
|
+
item.failureId === failureId || item.updateId === expected.updateId);
|
|
5793
|
+
const currentEntry = current.file.entries.find(entry => entry.updateId === expected.updateId);
|
|
5794
|
+
const assertRetention = (retained: TelegramUpdateJournalRetainedInput | undefined,
|
|
5795
|
+
disposition: TelegramUpdateJournalLegacyCustodyDisposition): void => {
|
|
5796
|
+
if (!retained || !isDeepStrictEqual(retained.requestedDisposition, disposition)) {
|
|
5797
|
+
throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
|
|
4971
5798
|
}
|
|
4972
|
-
|
|
5799
|
+
};
|
|
5800
|
+
if (existing) {
|
|
5801
|
+
if (currentEntry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" ||
|
|
5802
|
+
existing.failureId !== failureId || existing.action !== "discard" || existing.evidenceSha256 !== evidenceSha256) {
|
|
5803
|
+
throw createJournalError("conflict", path, "pending source already has another disposition");
|
|
5804
|
+
}
|
|
5805
|
+
assertRetention(readRetention(), existing);
|
|
5806
|
+
assertCurrent();
|
|
5807
|
+
return { disposition: existing, retainedPath, duplicate: true,
|
|
5808
|
+
entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
4973
5809
|
}
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
);
|
|
4977
|
-
const protectedEntry = requestedEntries.find(
|
|
4978
|
-
(entry) => entry.state === "failed" || entry.state === "queued",
|
|
4979
|
-
);
|
|
4980
|
-
if (protectedEntry) {
|
|
4981
|
-
throw createJournalError(
|
|
4982
|
-
"conflict",
|
|
4983
|
-
path,
|
|
4984
|
-
protectedEntry.state === "failed"
|
|
4985
|
-
? `cannot complete terminal update ${protectedEntry.updateId} without an operator disposition`
|
|
4986
|
-
: `cannot complete queued update ${protectedEntry.updateId} without its exact owner receipt`,
|
|
4987
|
-
);
|
|
5810
|
+
if (!currentEntry || !isDeepStrictEqual(currentEntry, expected)) {
|
|
5811
|
+
throw createJournalError("conflict", path, "pending source changed before abandonment");
|
|
4988
5812
|
}
|
|
4989
|
-
const
|
|
4990
|
-
|
|
4991
|
-
);
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5813
|
+
const nowMs = getNowMs();
|
|
5814
|
+
if (!isSafeNonNegativeInteger(nowMs)) throw createJournalError("invalid", path, "invalid abandonment timestamp");
|
|
5815
|
+
const retained = readRetention();
|
|
5816
|
+
let disposition: TelegramUpdateJournalLegacyCustodyDisposition;
|
|
5817
|
+
if (retained) {
|
|
5818
|
+
disposition = retained.requestedDisposition;
|
|
5819
|
+
} else {
|
|
5820
|
+
// Reuse the existing legacy discard tombstone so older v1 readers also refuse replay.
|
|
5821
|
+
// This is an operator disposition, not a fabricated execution failure or completion.
|
|
5822
|
+
disposition = { dispositionKind: "legacy-custody", failureId, updateId: expected.updateId,
|
|
5823
|
+
action: "discard", committedAtMs: nowMs, evidenceSha256,
|
|
5824
|
+
operatorAuthorityId: input.operatorAuthorityId, authorizedAtMs: nowMs };
|
|
5825
|
+
const copy: TelegramUpdateJournalRetainedInput = { version: 1, kind: "pending-input-retention",
|
|
5826
|
+
journalBindingKey, entry: expected, requestedDisposition: disposition };
|
|
5827
|
+
writeJournalFile(retainedPath, `${JSON.stringify(copy)}\n`, (boundary, target) => {
|
|
5828
|
+
onPublicationBoundary?.(boundary, target);
|
|
5829
|
+
assertCurrent();
|
|
5830
|
+
});
|
|
5831
|
+
}
|
|
5832
|
+
assertCurrent();
|
|
5833
|
+
assertRetention(readRetention(), disposition);
|
|
5834
|
+
const committed = (snapshot: ReadTelegramUpdateJournalResult) =>
|
|
5835
|
+
!snapshot.file.entries.some(entry => entry.updateId === expected.updateId) &&
|
|
5836
|
+
snapshot.file.operatorDispositions?.some(item => isDeepStrictEqual(item, disposition));
|
|
5837
|
+
try {
|
|
5838
|
+
const published = publishMutation(current,
|
|
5839
|
+
current.file.entries.filter(entry => entry.updateId !== expected.updateId), true,
|
|
5840
|
+
[...(current.file.operatorDispositions ?? []), disposition], current.file.acceptedThroughUpdateId,
|
|
5841
|
+
(boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
|
|
5842
|
+
return { disposition, retainedPath, duplicate: false,
|
|
5843
|
+
entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
5844
|
+
} catch (error) {
|
|
5845
|
+
// A segment may be durable even if later snapshot compaction failed.
|
|
5846
|
+
// Only exact read-back can turn that unknown acknowledgement into success.
|
|
5847
|
+
let observed: ReadTelegramUpdateJournalResult;
|
|
5848
|
+
try { observed = readStrict(); } catch { throw error; }
|
|
5849
|
+
if (!committed(observed)) throw error;
|
|
5850
|
+
assertRetention(readRetention(), disposition);
|
|
5851
|
+
return { disposition, retainedPath, duplicate: false,
|
|
5852
|
+
entryCount: observed.file.entries.length, serializedBytes: observed.serializedBytes };
|
|
5853
|
+
}
|
|
5854
|
+
});
|
|
5855
|
+
},
|
|
5856
|
+
removeCompleted(updateIds) { return removeCompleted(updateIds); },
|
|
5857
|
+
removeCompletedExact(updateIds, expectedSources, completions) { return removeCompleted(updateIds, expectedSources, true, completions); },
|
|
5858
|
+
inspectQueuedReceipt(expected) {
|
|
5859
|
+
return runMutationCore(readCurrent => {
|
|
5860
|
+
if (!sourceAccess || !isTelegramUpdateJournalLegacyFamilyVersion(version)) {
|
|
5861
|
+
throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
|
|
5862
|
+
}
|
|
5863
|
+
const queueOwner = validateQueuedReceiptObservation(expected, path);
|
|
5864
|
+
const current = readCurrent();
|
|
5865
|
+
return inspectJournalQueuedReceipt(current.file, { ...expected, queueOwner }, path);
|
|
5866
|
+
});
|
|
5867
|
+
},
|
|
5868
|
+
inspectSourceCompletion(expected) {
|
|
5869
|
+
return runMutation(readCurrent => {
|
|
5870
|
+
if (!sourceAccess) throw createJournalError("invalid", path, "source completion requires an exact source handle");
|
|
5871
|
+
const completion = validateJournalSourceCompletion(expected, path);
|
|
5872
|
+
const current = readCurrent();
|
|
5873
|
+
return inspectJournalSourceCompletion(current.file, completion, path);
|
|
5874
|
+
});
|
|
5875
|
+
},
|
|
5876
|
+
};
|
|
5877
|
+
if (isTelegramUpdateJournalLegacyFamilyVersion(version)) journal.routingInputs = {
|
|
5878
|
+
arm(input) { return mutateRoutingInputs(input, false, input.publishedAtMs).entries; },
|
|
5879
|
+
select(input) { return mutateRoutingInputs(input, true); },
|
|
5880
|
+
inspectExpiry(updateId) {
|
|
5881
|
+
return runMutation(readCurrent => {
|
|
5882
|
+
if (!isSafeNonNegativeInteger(updateId)) throw createJournalError("invalid", path, "invalid routing expiry update ID");
|
|
5883
|
+
return inspectRoutingInputExpiry(readCurrent().file,
|
|
5884
|
+
createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateId);
|
|
5885
|
+
});
|
|
5886
|
+
},
|
|
5887
|
+
inspectGroupExpiry(updateIds) {
|
|
5888
|
+
return runMutation(readCurrent => inspectRoutingGroupExpiry(readCurrent().file,
|
|
5889
|
+
createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateIds));
|
|
5890
|
+
},
|
|
5891
|
+
expire(input) {
|
|
5892
|
+
return runMutation(readCurrent => {
|
|
5893
|
+
const original = validateJournalEntry(input.entry, path, version), lifetime = original.routingInput;
|
|
5894
|
+
const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
5895
|
+
if (!lifetime || original.state !== "pending" || original.inputClaim || original.inputProvenance || original.queueOwner ||
|
|
5896
|
+
original.queueReceiptId || original.queueHandoff || original.failure || input.journalBindingKey !== binding ||
|
|
5897
|
+
lifetime.operatorUserId !== input.operatorUserId || !isSafePositiveInteger(input.operatorUserId) || typeof input.isCurrent !== "function") {
|
|
5898
|
+
throw createJournalError("conflict", path, "routing expiry requires an exact unclaimed chooser source");
|
|
5899
|
+
}
|
|
5900
|
+
const assertCurrent = () => {
|
|
5901
|
+
const now = getNowMs();
|
|
5902
|
+
if (!isSafeNonNegativeInteger(now) || now < lifetime.expiresAtMs || !input.isCurrent())
|
|
5903
|
+
throw createJournalError("conflict", path, "routing source has not expired under current authority");
|
|
5003
5904
|
};
|
|
5905
|
+
assertCurrent();
|
|
5906
|
+
const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
|
|
5907
|
+
const evidenceSha256 = createHash("sha256").update(JSON.stringify(original)).digest("hex");
|
|
5908
|
+
const failureId = `routing-expiry:${evidenceSha256}`;
|
|
5909
|
+
const existing = current.file.operatorDispositions?.find(value => value.updateId === original.updateId);
|
|
5910
|
+
const entry = current.file.entries.find(value => value.updateId === original.updateId);
|
|
5911
|
+
if (existing) {
|
|
5912
|
+
if (entry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" || existing.action !== "discard" ||
|
|
5913
|
+
existing.failureId !== failureId || existing.evidenceSha256 !== evidenceSha256 || existing.operatorAuthorityId !== `telegram-owner:${input.operatorUserId}`)
|
|
5914
|
+
throw createJournalError("conflict", path, "routing source has contradictory expiry evidence");
|
|
5915
|
+
assertCurrent();
|
|
5916
|
+
return { disposition: existing, duplicate: true, entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
5917
|
+
}
|
|
5918
|
+
if (!entry || !isDeepStrictEqual(entry, original)) throw createJournalError("conflict", path, "routing source changed before expiry");
|
|
5919
|
+
// A discard tombstone prevents old readers and delayed reports from replaying; no prompt-body archive is written.
|
|
5920
|
+
const now = getNowMs();
|
|
5921
|
+
const disposition: TelegramUpdateJournalLegacyCustodyDisposition = { dispositionKind: "legacy-custody", failureId,
|
|
5922
|
+
updateId: original.updateId, action: "discard", committedAtMs: now, authorizedAtMs: now, evidenceSha256,
|
|
5923
|
+
operatorAuthorityId: `telegram-owner:${input.operatorUserId}` };
|
|
5924
|
+
const published = publishMutation(current, current.file.entries.filter(value => value.updateId !== original.updateId), true,
|
|
5925
|
+
[...(current.file.operatorDispositions ?? []), disposition], current.file.acceptedThroughUpdateId,
|
|
5926
|
+
(boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
|
|
5927
|
+
assertCurrent();
|
|
5928
|
+
return { disposition, duplicate: false, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
5004
5929
|
});
|
|
5005
5930
|
},
|
|
5006
5931
|
};
|
|
@@ -5178,7 +6103,7 @@ function createJournalStoreCore(
|
|
|
5178
6103
|
const evidence = readTelegramUpdateJournalSource({
|
|
5179
6104
|
directory: sourceAccess.directory, path, profile,
|
|
5180
6105
|
botIdentity: expectedIdentity, limits: sourceAccess.limits,
|
|
5181
|
-
version
|
|
6106
|
+
version,
|
|
5182
6107
|
});
|
|
5183
6108
|
return evidence.kind === "present"
|
|
5184
6109
|
? listTelegramUpdateJournalLegacyCustodyCandidates(evidence.file) : [];
|